Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

mcp-tools.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

mcp-tools.tsBlame1597 lines · 4 contributors
2c2163eClaude1/**
2 * MCP tool handlers — read-only v1 set.
3 *
4 * Each handler returns either a string (auto-wrapped to text content)
5 * or the full MCP `{content: [...]}` shape. Errors throw `McpError` from
6 * `mcp.ts` so the router can surface them as JSON-RPC -32xxx codes.
7 *
8 * Tool surface (v1, all read-only):
9 * - gluecron_repo_search — search public repos by keyword
10 * - gluecron_repo_read_file — read a file from a repo at a ref
11 * - gluecron_repo_list_issues — list open issues for a repo
12 * - gluecron_repo_explain_codebase — return cached AI explanation
13 *
14 * v2 will add write tools (create_issue, post_comment, run_workflow)
15 * gated on `userId` + write-access on the target repo.
16 */
17
6551045Claude18import { and, asc, desc, eq, like, or, sql as drizzleSql } from "drizzle-orm";
2c2163eClaude19import { db } from "../db";
20import {
21 issues,
6551045Claude22 issueComments,
23 pullRequests,
24 prComments,
2c2163eClaude25 repositories,
26 users,
27 codebaseExplanations,
28} from "../db/schema";
6551045Claude29import { getBlob, repoExists, resolveRef, getRepoPath } from "../git/repository";
f5a18f9Claude30import { computeHealthScore } from "./intelligence";
2c2163eClaude31import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
32import type { McpContext } from "./mcp";
6551045Claude33import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
34import type { RepoAccessLevel } from "../middleware/repo-access";
b7b5f75ccanty labs35import { notify, audit, logActivity } from "./notify";
a54e588Test User36import { fireWebhooks } from "../routes/webhooks";
6551045Claude37import { runAllGateChecks } from "./gate";
38import {
39 matchProtection,
40 countHumanApprovals,
41 listRequiredChecks,
42 passingCheckNames,
43 evaluateProtection,
44} from "./branch-protection";
45import { mergeWithAutoResolve } from "./merge-resolver";
c166384ccantynz-alt46import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } from "./ai-review";
79a52c8ccantynz-alt47import { triggerPrTriage } from "./pr-triage";
91b054eccanty labs48import { requiredOwnersApproved } from "./codeowners";
534f04aClaude49import {
50 computePrRiskForPullRequest,
51 getCachedPrRisk,
52 getLatestCachedPrRisk,
53 type PrRiskScore,
54} from "./pr-risk";
0feb720Claude55import { expandedTools } from "./mcp-tools-expanded";
2c2163eClaude56
523ddadccanty labs57/**
58 * MCP spec ToolAnnotations — behavioural hints surfaced to clients via
59 * tools/list. Required for Anthropic connector-directory review. These are
60 * hints only; they never gate execution server-side.
61 */
62export type McpToolAnnotations = {
63 title?: string;
64 readOnlyHint?: boolean;
65 destructiveHint?: boolean;
66 idempotentHint?: boolean;
67 openWorldHint?: boolean;
68};
69
2c2163eClaude70export type McpTool = {
71 name: string;
72 description: string;
523ddadccanty labs73 annotations?: McpToolAnnotations;
2c2163eClaude74 inputSchema: {
75 type: "object";
76 properties: Record<string, { type: string; description?: string }>;
77 required?: string[];
78 };
79};
80
81export type McpToolHandler = {
82 tool: McpTool;
83 run: (
84 args: Record<string, unknown>,
85 ctx: McpContext
86 ) => Promise<unknown>;
87};
88
89const argString = (
90 args: Record<string, unknown>,
91 key: string,
92 fallback?: string
93): string => {
94 const v = args[key];
95 if (typeof v === "string" && v.trim().length > 0) return v.trim();
96 if (fallback !== undefined) return fallback;
97 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' is required`);
98};
99
100const argNumber = (
101 args: Record<string, unknown>,
102 key: string,
103 fallback?: number
104): number => {
105 const v = args[key];
106 if (typeof v === "number" && Number.isFinite(v)) return v;
107 if (typeof v === "string" && /^\d+$/.test(v)) return Number.parseInt(v, 10);
108 if (fallback !== undefined) return fallback;
109 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`);
110};
111
85fc0efccantynz-alt112/**
113 * Gate for the "repo:*" read tools. A private repo is readable when the
114 * AUTHENTICATED caller (ctx.userId) has read access to it — owner,
115 * collaborator, or org member. These tools used to reject EVERY private repo
116 * ("v1 public-only"), which meant a user who signed in via the OAuth
117 * connector couldn't read their own private repos (e.g. Vapron) at all.
118 *
119 * Privacy-preserving: an unknown repo AND a private repo the caller can't
120 * read both throw the same ERR_METHOD_NOT_FOUND, so we never confirm a
121 * private repo's existence to a non-collaborator. Returns {id, isPrivate}.
122 */
123async function resolveReadableRepo(
124 owner: string,
125 repo: string,
126 ctx: McpContext
127): Promise<{ id: string; isPrivate: boolean }> {
128 const [r] = await db
129 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
130 .from(repositories)
131 .innerJoin(users, eq(repositories.ownerId, users.id))
132 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
133 .limit(1);
134 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
135 if (r.isPrivate) {
136 const access = await resolveRepoAccess({
137 repoId: r.id,
138 userId: ctx.userId ?? null,
139 isPublic: false,
140 });
141 if (!satisfiesAccess(access, "read")) {
142 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
143 }
144 }
145 return r;
146}
147
2c2163eClaude148// ---------------------------------------------------------------------------
149// gluecron_repo_search
150// ---------------------------------------------------------------------------
151
152const repoSearch: McpToolHandler = {
153 tool: {
154 name: "gluecron_repo_search",
155 description:
85fc0efccantynz-alt156 "Search Gluecron repositories by keyword (name + description). Returns public repos plus any private repos owned by the authenticated caller. Up to 20 results.",
523ddadccanty labs157 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
2c2163eClaude158 inputSchema: {
159 type: "object",
160 properties: {
161 query: { type: "string", description: "Search keyword (1-100 chars)" },
162 limit: { type: "number", description: "Max results, default 20" },
163 },
164 required: ["query"],
165 },
166 },
85fc0efccantynz-alt167 async run(args, ctx) {
2c2163eClaude168 const q = argString(args, "query");
169 if (q.length > 100) {
170 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
171 }
172 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
173 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
85fc0efccantynz-alt174 // Visible = public, OR private-but-owned-by-the-authenticated-caller.
175 const visibility = ctx.userId
176 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
177 : eq(repositories.isPrivate, false);
2c2163eClaude178 const rows = await db
179 .select({
180 id: repositories.id,
181 name: repositories.name,
182 description: repositories.description,
183 ownerName: users.username,
184 stars: repositories.starCount,
185 })
186 .from(repositories)
187 .innerJoin(users, eq(repositories.ownerId, users.id))
188 .where(
189 and(
85fc0efccantynz-alt190 visibility,
2c2163eClaude191 or(
192 like(repositories.name, pattern),
193 like(repositories.description, pattern)
194 )
195 )
196 )
197 .orderBy(desc(repositories.starCount))
198 .limit(limit);
199 return {
200 total: rows.length,
201 repos: rows.map((r) => ({
202 fullName: `${r.ownerName}/${r.name}`,
203 description: r.description || "",
204 stars: r.stars,
205 })),
206 };
207 },
208};
209
210// ---------------------------------------------------------------------------
211// gluecron_repo_read_file
212// ---------------------------------------------------------------------------
213
214const repoReadFile: McpToolHandler = {
215 tool: {
216 name: "gluecron_repo_read_file",
217 description:
85fc0efccantynz-alt218 "Read a single file from a repository at a given ref (branch / tag / commit). Private repos are readable when the authenticated caller has access. Returns the text content (binary files rejected).",
523ddadccanty labs219 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
2c2163eClaude220 inputSchema: {
221 type: "object",
222 properties: {
223 owner: { type: "string", description: "Repo owner username" },
224 repo: { type: "string", description: "Repo name" },
225 ref: { type: "string", description: "Branch / tag / commit (default: main)" },
226 path: { type: "string", description: "File path within the repo" },
227 },
228 required: ["owner", "repo", "path"],
229 },
230 },
85fc0efccantynz-alt231 async run(args, ctx) {
2c2163eClaude232 const owner = argString(args, "owner");
233 const repo = argString(args, "repo");
234 const ref = argString(args, "ref", "main");
235 const path = argString(args, "path");
236
85fc0efccantynz-alt237 // Readable if public, or private + the authenticated caller has access.
238 await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude239
240 const blob = await getBlob(owner, repo, ref, path);
241 if (!blob) {
242 throw new McpError(
243 ERR_METHOD_NOT_FOUND,
244 `path not found: ${owner}/${repo}@${ref}:${path}`
245 );
246 }
247 return {
248 content: [
249 {
250 type: "text",
251 text: blob.content,
252 },
253 ],
254 };
255 },
256};
257
258// ---------------------------------------------------------------------------
259// gluecron_repo_list_issues
260// ---------------------------------------------------------------------------
261
262const repoListIssues: McpToolHandler = {
263 tool: {
264 name: "gluecron_repo_list_issues",
265 description:
266 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
523ddadccanty labs267 annotations: { title: "List open issues", readOnlyHint: true, destructiveHint: false },
2c2163eClaude268 inputSchema: {
269 type: "object",
270 properties: {
271 owner: { type: "string", description: "Repo owner username" },
272 repo: { type: "string", description: "Repo name" },
273 limit: { type: "number", description: "Max results, default 25" },
274 },
275 required: ["owner", "repo"],
276 },
277 },
85fc0efccantynz-alt278 async run(args, ctx) {
2c2163eClaude279 const owner = argString(args, "owner");
280 const repo = argString(args, "repo");
281 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
282
85fc0efccantynz-alt283 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude284
285 const rows = await db
286 .select({
287 number: issues.number,
288 title: issues.title,
289 body: issues.body,
290 state: issues.state,
291 createdAt: issues.createdAt,
292 })
293 .from(issues)
294 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
295 .orderBy(desc(issues.createdAt))
296 .limit(limit);
297 return {
298 total: rows.length,
299 issues: rows.map((i) => ({
300 number: i.number,
301 title: i.title,
302 body: i.body || "",
303 state: i.state,
304 createdAt: i.createdAt,
305 })),
306 };
307 },
308};
309
310// ---------------------------------------------------------------------------
311// gluecron_repo_explain_codebase
312// ---------------------------------------------------------------------------
313
314const repoExplain: McpToolHandler = {
315 tool: {
316 name: "gluecron_repo_explain_codebase",
317 description:
318 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
523ddadccanty labs319 annotations: { title: "Explain codebase (cached)", readOnlyHint: true, destructiveHint: false },
2c2163eClaude320 inputSchema: {
321 type: "object",
322 properties: {
323 owner: { type: "string", description: "Repo owner username" },
324 repo: { type: "string", description: "Repo name" },
325 },
326 required: ["owner", "repo"],
327 },
328 },
85fc0efccantynz-alt329 async run(args, ctx) {
2c2163eClaude330 const owner = argString(args, "owner");
331 const repo = argString(args, "repo");
85fc0efccantynz-alt332 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude333 const [row] = await db
334 .select({
335 commitSha: codebaseExplanations.commitSha,
336 markdown: codebaseExplanations.markdown,
2316901Claude337 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude338 })
339 .from(codebaseExplanations)
340 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude341 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude342 .limit(1);
343 if (!row) {
344 return { explanation: null };
345 }
346 return {
347 commitSha: row.commitSha,
2316901Claude348 generatedAt: row.generatedAt,
2c2163eClaude349 markdown: row.markdown,
350 };
351 },
352};
353
f5a18f9Claude354// ---------------------------------------------------------------------------
355// gluecron_repo_health
356// ---------------------------------------------------------------------------
357
358const repoHealth: McpToolHandler = {
359 tool: {
360 name: "gluecron_repo_health",
361 description:
362 "Compute the current health report for a public repo: overall score (0-100), letter grade, per-category breakdown (security/testing/complexity/dependencies/documentation/activity), and a list of insights to fix next. Backed by computeHealthScore in src/lib/intelligence.ts.",
523ddadccanty labs363 annotations: { title: "Repository health report", readOnlyHint: true, destructiveHint: false },
f5a18f9Claude364 inputSchema: {
365 type: "object",
366 properties: {
367 owner: { type: "string", description: "Repo owner username" },
368 repo: { type: "string", description: "Repo name" },
369 },
370 required: ["owner", "repo"],
371 },
372 },
85fc0efccantynz-alt373 async run(args, ctx) {
f5a18f9Claude374 const owner = argString(args, "owner");
375 const repo = argString(args, "repo");
376
85fc0efccantynz-alt377 await resolveReadableRepo(owner, repo, ctx);
f5a18f9Claude378 if (!(await repoExists(owner, repo))) {
379 throw new McpError(
380 ERR_METHOD_NOT_FOUND,
381 `${owner}/${repo} has no on-disk git data yet`
382 );
383 }
384 const report = await computeHealthScore(owner, repo);
385 return {
386 score: report.score,
387 grade: report.grade,
388 breakdown: report.breakdown,
389 insights: report.insights,
390 generatedAt: report.generatedAt,
391 };
392 },
393};
394
6551045Claude395// ---------------------------------------------------------------------------
396// Write-surface shared helpers (Block K1)
397// ---------------------------------------------------------------------------
398
399/**
400 * Require an authenticated context — every write tool gates on this first.
401 * Throws -32602 invalid_params with a tool-specific message so the client
402 * surface knows exactly which call needs auth.
403 */
404function requireAuthedCtx(ctx: McpContext, toolName: string): string {
405 if (!ctx.userId) {
406 throw new McpError(
407 ERR_INVALID_PARAMS,
408 `authentication required for ${toolName}`
409 );
410 }
411 return ctx.userId;
412}
413
414/**
415 * Resolve an `owner/repo` pair to its full row, and confirm the caller has
416 * at least `read` access. Privacy: when the repo is missing OR the caller
417 * cannot see it, throw -32601 method_not_found with the same message —
418 * matches the read-tool privacy contract so private-repo existence does
419 * not leak.
420 */
421async function resolveAccessibleRepo(
422 owner: string,
423 repo: string,
424 userId: string | null
425): Promise<{
426 repoId: string;
427 ownerId: string;
428 isPrivate: boolean;
429 defaultBranch: string;
430 access: RepoAccessLevel;
431}> {
432 const [row] = await db
433 .select({
434 id: repositories.id,
435 ownerId: repositories.ownerId,
436 isPrivate: repositories.isPrivate,
437 defaultBranch: repositories.defaultBranch,
438 })
439 .from(repositories)
440 .innerJoin(users, eq(repositories.ownerId, users.id))
441 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
442 .limit(1);
443 if (!row) {
444 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
445 }
446 const access = await resolveRepoAccess({
447 repoId: row.id,
448 userId,
449 isPublic: !row.isPrivate,
450 });
451 if (!satisfiesAccess(access, "read")) {
452 // Hide existence of private repos from non-collaborators.
453 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
454 }
455 return {
456 repoId: row.id,
457 ownerId: row.ownerId,
458 isPrivate: row.isPrivate,
459 defaultBranch: row.defaultBranch,
460 access,
461 };
462}
463
464/**
465 * Combine `requireAuthedCtx` + `resolveAccessibleRepo` + write-access gate.
466 * Used by every write tool. Returns the repo metadata for downstream use.
467 *
468 * Per spec: reject with -32601 method_not_found (not -32603) so the
469 * existence of private repos the caller cannot see is not leaked through
470 * the error code shape.
471 */
472async function gateWriteAccess(
473 args: { owner: string; repo: string },
474 ctx: McpContext,
475 toolName: string
476): Promise<{
477 userId: string;
478 owner: string;
479 repo: string;
480 repoId: string;
481 ownerId: string;
482 isPrivate: boolean;
483 defaultBranch: string;
484}> {
485 const userId = requireAuthedCtx(ctx, toolName);
486 const info = await resolveAccessibleRepo(args.owner, args.repo, userId);
487 if (!satisfiesAccess(info.access, "write")) {
488 throw new McpError(
489 ERR_METHOD_NOT_FOUND,
490 `no write access to ${args.owner}/${args.repo}`
491 );
492 }
493 return {
494 userId,
495 owner: args.owner,
496 repo: args.repo,
497 repoId: info.repoId,
498 ownerId: info.ownerId,
499 isPrivate: info.isPrivate,
500 defaultBranch: info.defaultBranch,
501 };
502}
503
504function prUrl(owner: string, repo: string, number: number): string {
505 return `/${owner}/${repo}/pulls/${number}`;
506}
507function issueUrl(owner: string, repo: string, number: number): string {
508 return `/${owner}/${repo}/issues/${number}`;
509}
510
511async function loadPrByNumber(repoId: string, prNumber: number) {
512 const [pr] = await db
513 .select()
514 .from(pullRequests)
515 .where(
516 and(
517 eq(pullRequests.repositoryId, repoId),
518 eq(pullRequests.number, prNumber)
519 )
520 )
521 .limit(1);
522 return pr ?? null;
523}
524
525async function loadIssueByNumber(repoId: string, issueNumber: number) {
526 const [row] = await db
527 .select()
528 .from(issues)
529 .where(
530 and(
531 eq(issues.repositoryId, repoId),
532 eq(issues.number, issueNumber)
533 )
534 )
535 .limit(1);
536 return row ?? null;
537}
538
539// ---------------------------------------------------------------------------
540// gluecron_create_issue
541// ---------------------------------------------------------------------------
542
543const createIssue: McpToolHandler = {
544 tool: {
545 name: "gluecron_create_issue",
546 description:
547 "Create a new issue on a Gluecron repository. Requires authenticated caller with write access on the target repo. Returns {number, url}.",
523ddadccanty labs548 annotations: { title: "Create issue", readOnlyHint: false, destructiveHint: false },
6551045Claude549 inputSchema: {
550 type: "object",
551 properties: {
552 owner: { type: "string", description: "Repo owner username" },
553 repo: { type: "string", description: "Repo name" },
554 title: { type: "string", description: "Issue title" },
555 body: { type: "string", description: "Issue body (Markdown). Optional." },
556 },
557 required: ["owner", "repo", "title"],
558 },
559 },
560 async run(args, ctx) {
561 const owner = argString(args, "owner");
562 const repo = argString(args, "repo");
563 const title = argString(args, "title");
564 const body = argString(args, "body", "");
565
566 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_issue");
567
568 const [inserted] = await db
569 .insert(issues)
570 .values({
571 repositoryId: gate.repoId,
572 authorId: gate.userId,
573 title,
574 body: body || null,
575 })
576 .returning();
577
578 // Bump issue count (best-effort, mirrors the HTTP route)
579 try {
580 await db
581 .update(repositories)
582 .set({ issueCount: sqlExpr("issue_count + 1") })
583 .where(eq(repositories.id, gate.repoId));
584 } catch {
585 /* non-fatal */
586 }
587
588 await audit({
589 userId: gate.userId,
590 repositoryId: gate.repoId,
591 action: "issue.created",
592 targetType: "issue",
593 targetId: inserted.id,
594 metadata: { source: "mcp", number: inserted.number, title },
595 });
a54e588Test User596 void fireWebhooks(gate.repoId, "issue", { action: "opened", number: inserted.number, title });
6551045Claude597
598 // Notify repo owner if it's not the same user (mirrors typical web flow).
599 if (gate.ownerId !== gate.userId) {
600 notify(gate.ownerId, {
601 kind: "issue_opened",
602 title: `New issue: ${title}`,
603 url: issueUrl(owner, repo, inserted.number),
604 repositoryId: gate.repoId,
a28cedeClaude605 }).catch((err) => {
606 console.warn(
607 `[mcp] issue_opened notify failed for ${owner}/${repo}#${inserted.number}:`,
608 err instanceof Error ? err.message : err
609 );
610 });
6551045Claude611 }
612
613 return {
614 number: inserted.number,
615 url: issueUrl(owner, repo, inserted.number),
616 };
617 },
618};
619
620// ---------------------------------------------------------------------------
621// gluecron_comment_issue
622// ---------------------------------------------------------------------------
623
624const commentIssue: McpToolHandler = {
625 tool: {
626 name: "gluecron_comment_issue",
627 description:
628 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs629 annotations: { title: "Comment on issue", readOnlyHint: false, destructiveHint: false },
6551045Claude630 inputSchema: {
631 type: "object",
632 properties: {
633 owner: { type: "string", description: "Repo owner username" },
634 repo: { type: "string", description: "Repo name" },
635 number: { type: "number", description: "Issue number" },
636 body: { type: "string", description: "Comment body (Markdown)" },
637 },
638 required: ["owner", "repo", "number", "body"],
639 },
640 },
641 async run(args, ctx) {
642 const owner = argString(args, "owner");
643 const repo = argString(args, "repo");
644 const number = argNumber(args, "number");
645 const body = argString(args, "body");
646
647 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
648 const issue = await loadIssueByNumber(gate.repoId, number);
649 if (!issue) {
650 throw new McpError(
651 ERR_METHOD_NOT_FOUND,
652 `issue not found: ${owner}/${repo}#${number}`
653 );
654 }
655
656 const [inserted] = await db
657 .insert(issueComments)
658 .values({
659 issueId: issue.id,
660 authorId: gate.userId,
661 body,
662 })
663 .returning();
664
665 // SSE fanout — best-effort.
666 try {
667 const { publish } = await import("./sse");
668 publish(`repo:${gate.repoId}:issue:${number}`, {
669 event: "issue-comment",
670 data: {
671 issueId: issue.id,
672 commentId: inserted.id,
673 authorId: gate.userId,
674 authorUsername: null,
675 },
676 });
677 } catch {
678 /* SSE best-effort */
679 }
680
681 await audit({
682 userId: gate.userId,
683 repositoryId: gate.repoId,
684 action: "issue.commented",
685 targetType: "issue",
686 targetId: issue.id,
687 metadata: { source: "mcp", number },
688 });
a54e588Test User689 void fireWebhooks(gate.repoId, "issue", { action: "commented", number });
6551045Claude690
691 return { commentId: inserted.id };
692 },
693};
694
695// ---------------------------------------------------------------------------
696// gluecron_close_issue
697// ---------------------------------------------------------------------------
698
699const closeIssue: McpToolHandler = {
700 tool: {
701 name: "gluecron_close_issue",
702 description:
703 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
523ddadccanty labs704 annotations: { title: "Close issue", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude705 inputSchema: {
706 type: "object",
707 properties: {
708 owner: { type: "string", description: "Repo owner username" },
709 repo: { type: "string", description: "Repo name" },
710 number: { type: "number", description: "Issue number" },
711 },
712 required: ["owner", "repo", "number"],
713 },
714 },
715 async run(args, ctx) {
716 const owner = argString(args, "owner");
717 const repo = argString(args, "repo");
718 const number = argNumber(args, "number");
719
720 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
721 const issue = await loadIssueByNumber(gate.repoId, number);
722 if (!issue) {
723 throw new McpError(
724 ERR_METHOD_NOT_FOUND,
725 `issue not found: ${owner}/${repo}#${number}`
726 );
727 }
728
729 if (issue.state !== "closed") {
730 await db
731 .update(issues)
732 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
733 .where(eq(issues.id, issue.id));
734
735 await audit({
736 userId: gate.userId,
737 repositoryId: gate.repoId,
738 action: "issue.closed",
739 targetType: "issue",
740 targetId: issue.id,
741 metadata: { source: "mcp", number },
742 });
a54e588Test User743 void fireWebhooks(gate.repoId, "issue", { action: "closed", number });
6551045Claude744 }
745
746 return { state: "closed" };
747 },
748};
749
750// ---------------------------------------------------------------------------
751// gluecron_reopen_issue
752// ---------------------------------------------------------------------------
753
754const reopenIssue: McpToolHandler = {
755 tool: {
756 name: "gluecron_reopen_issue",
757 description:
758 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs759 // Reopen merely undoes a close (trivially reversible) — not destructive.
760 annotations: { title: "Reopen issue", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
6551045Claude761 inputSchema: {
762 type: "object",
763 properties: {
764 owner: { type: "string", description: "Repo owner username" },
765 repo: { type: "string", description: "Repo name" },
766 number: { type: "number", description: "Issue number" },
767 },
768 required: ["owner", "repo", "number"],
769 },
770 },
771 async run(args, ctx) {
772 const owner = argString(args, "owner");
773 const repo = argString(args, "repo");
774 const number = argNumber(args, "number");
775
776 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
777 const issue = await loadIssueByNumber(gate.repoId, number);
778 if (!issue) {
779 throw new McpError(
780 ERR_METHOD_NOT_FOUND,
781 `issue not found: ${owner}/${repo}#${number}`
782 );
783 }
784
785 if (issue.state !== "open") {
786 await db
787 .update(issues)
788 .set({ state: "open", closedAt: null, updatedAt: new Date() })
789 .where(eq(issues.id, issue.id));
790
791 await audit({
792 userId: gate.userId,
793 repositoryId: gate.repoId,
794 action: "issue.reopened",
795 targetType: "issue",
796 targetId: issue.id,
797 metadata: { source: "mcp", number },
798 });
a54e588Test User799 void fireWebhooks(gate.repoId, "issue", { action: "reopened", number });
6551045Claude800 }
801
802 return { state: "open" };
803 },
804};
805
806// ---------------------------------------------------------------------------
807// gluecron_create_pr
808// ---------------------------------------------------------------------------
809
810const createPr: McpToolHandler = {
811 tool: {
812 name: "gluecron_create_pr",
813 description:
814 "Open a new pull request. `head_branch` is required; `base_branch` defaults to the repo default branch. Requires authenticated caller with write access. Returns {number, url}.",
523ddadccanty labs815 annotations: { title: "Create pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude816 inputSchema: {
817 type: "object",
818 properties: {
819 owner: { type: "string", description: "Repo owner username" },
820 repo: { type: "string", description: "Repo name" },
821 title: { type: "string", description: "PR title" },
822 body: { type: "string", description: "PR body (Markdown). Optional." },
823 head_branch: { type: "string", description: "Branch with the changes" },
824 base_branch: {
825 type: "string",
826 description: "Target branch (default: repo default branch)",
827 },
828 },
829 required: ["owner", "repo", "title", "head_branch"],
830 },
831 },
832 async run(args, ctx) {
833 const owner = argString(args, "owner");
834 const repo = argString(args, "repo");
835 const title = argString(args, "title");
836 const body = argString(args, "body", "");
837 const headBranch = argString(args, "head_branch");
838
839 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
840 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
841
842 if (baseBranch === headBranch) {
843 throw new McpError(
844 ERR_INVALID_PARAMS,
845 "base and head branches must be different"
846 );
847 }
848
849 const [pr] = await db
850 .insert(pullRequests)
851 .values({
852 repositoryId: gate.repoId,
853 authorId: gate.userId,
854 title,
855 body: body || null,
856 baseBranch,
857 headBranch,
858 })
859 .returning();
860
861 await audit({
862 userId: gate.userId,
863 repositoryId: gate.repoId,
864 action: "pr.opened",
865 targetType: "pull_request",
866 targetId: pr.id,
867 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
868 });
869
b7b5f75ccanty labs870 void logActivity({
871 repositoryId: gate.repoId,
872 userId: gate.userId,
873 action: "pr_open",
874 targetType: "pull_request",
875 targetId: String(pr.number),
876 metadata: { source: "mcp", baseBranch, headBranch },
877 });
a54e588Test User878 void fireWebhooks(gate.repoId, "pr", { action: "opened", number: pr.number, baseBranch, headBranch });
b7b5f75ccanty labs879
79a52c8ccantynz-alt880 // PRs opened through the MCP write surface (exactly how an external AI
881 // platform or the agent-journey certification drives this) never
882 // triggered AI review or triage -- both were wired into the web
883 // /pulls/new route only. Mirror that here so MCP-created PRs get the
884 // same treatment as web-created ones.
885 if (isAiReviewEnabled()) {
886 triggerAiReview(owner, repo, pr.id, title, body || "", baseBranch, headBranch).catch(
887 (err) => console.error("[mcp] triggerAiReview failed:", err)
888 );
889 }
890 triggerPrTriage({
891 ownerName: owner,
892 repoName: repo,
893 repositoryId: gate.repoId,
894 prId: pr.id,
895 prAuthorId: gate.userId,
896 title,
897 body: body || "",
898 baseBranch,
899 headBranch,
900 }).catch((err) => console.error("[mcp] triggerPrTriage failed:", err));
901
6551045Claude902 if (gate.ownerId !== gate.userId) {
903 notify(gate.ownerId, {
904 kind: "pr_opened",
905 title: `New PR: ${title}`,
906 url: prUrl(owner, repo, pr.number),
907 repositoryId: gate.repoId,
a28cedeClaude908 }).catch((err) => {
909 console.warn(
910 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
911 err instanceof Error ? err.message : err
912 );
913 });
6551045Claude914 }
915
916 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
917 },
918};
919
920// ---------------------------------------------------------------------------
921// gluecron_get_pr
922// ---------------------------------------------------------------------------
923
924const getPr: McpToolHandler = {
925 tool: {
926 name: "gluecron_get_pr",
927 description:
928 "Fetch the full detail record of a pull request (title, body, state, branches, draft, author, timestamps). Authenticated callers only (the read tool surface still works anonymously).",
523ddadccanty labs929 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude930 inputSchema: {
931 type: "object",
932 properties: {
933 owner: { type: "string", description: "Repo owner username" },
934 repo: { type: "string", description: "Repo name" },
935 number: { type: "number", description: "PR number" },
936 },
937 required: ["owner", "repo", "number"],
938 },
939 },
940 async run(args, ctx) {
941 const owner = argString(args, "owner");
942 const repo = argString(args, "repo");
943 const number = argNumber(args, "number");
944
945 requireAuthedCtx(ctx, "gluecron_get_pr");
946 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
947
948 const pr = await loadPrByNumber(info.repoId, number);
949 if (!pr) {
950 throw new McpError(
951 ERR_METHOD_NOT_FOUND,
952 `pr not found: ${owner}/${repo}#${number}`
953 );
954 }
955
956 const [author] = await db
957 .select({ username: users.username })
958 .from(users)
959 .where(eq(users.id, pr.authorId))
960 .limit(1);
961
962 // Best-effort mergeability hint via a quick head-ref resolve.
963 let mergeable: boolean | null = null;
964 try {
965 const headSha = await resolveRef(owner, repo, pr.headBranch);
966 mergeable = headSha ? true : null;
967 } catch {
968 mergeable = null;
969 }
970
971 return {
972 number: pr.number,
973 title: pr.title,
974 body: pr.body || "",
975 state: pr.state,
976 baseBranch: pr.baseBranch,
977 headBranch: pr.headBranch,
978 isDraft: pr.isDraft,
979 mergeable,
980 author: author?.username ?? null,
981 createdAt: pr.createdAt,
982 updatedAt: pr.updatedAt,
983 mergedAt: pr.mergedAt,
984 closedAt: pr.closedAt,
985 url: prUrl(owner, repo, pr.number),
986 };
987 },
988};
989
990// ---------------------------------------------------------------------------
991// gluecron_list_prs
992// ---------------------------------------------------------------------------
993
994const listPrs: McpToolHandler = {
995 tool: {
996 name: "gluecron_list_prs",
997 description:
998 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs999 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude1000 inputSchema: {
1001 type: "object",
1002 properties: {
1003 owner: { type: "string", description: "Repo owner username" },
1004 repo: { type: "string", description: "Repo name" },
1005 state: {
1006 type: "string",
1007 description: "open | closed | merged | all (default: open)",
1008 },
1009 },
1010 required: ["owner", "repo"],
1011 },
1012 },
1013 async run(args, ctx) {
1014 const owner = argString(args, "owner");
1015 const repo = argString(args, "repo");
1016 const state = argString(args, "state", "open");
1017 if (!["open", "closed", "merged", "all"].includes(state)) {
1018 throw new McpError(
1019 ERR_INVALID_PARAMS,
1020 `state must be one of open|closed|merged|all (got "${state}")`
1021 );
1022 }
1023
1024 requireAuthedCtx(ctx, "gluecron_list_prs");
1025 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1026
1027 const whereClause =
1028 state === "all"
1029 ? eq(pullRequests.repositoryId, info.repoId)
1030 : and(
1031 eq(pullRequests.repositoryId, info.repoId),
1032 eq(pullRequests.state, state)
1033 );
1034
1035 const rows = await db
1036 .select({
1037 number: pullRequests.number,
1038 title: pullRequests.title,
1039 state: pullRequests.state,
1040 baseBranch: pullRequests.baseBranch,
1041 headBranch: pullRequests.headBranch,
1042 isDraft: pullRequests.isDraft,
1043 authorUsername: users.username,
1044 createdAt: pullRequests.createdAt,
1045 updatedAt: pullRequests.updatedAt,
1046 })
1047 .from(pullRequests)
1048 .innerJoin(users, eq(pullRequests.authorId, users.id))
1049 .where(whereClause)
1050 .orderBy(desc(pullRequests.createdAt))
1051 .limit(50);
1052
1053 return {
1054 total: rows.length,
1055 prs: rows.map((p) => ({
1056 number: p.number,
1057 title: p.title,
1058 state: p.state,
1059 baseBranch: p.baseBranch,
1060 headBranch: p.headBranch,
1061 isDraft: p.isDraft,
1062 author: p.authorUsername,
1063 createdAt: p.createdAt,
1064 updatedAt: p.updatedAt,
1065 url: prUrl(owner, repo, p.number),
1066 })),
1067 };
1068 },
1069};
1070
1071// ---------------------------------------------------------------------------
1072// gluecron_comment_pr
1073// ---------------------------------------------------------------------------
1074
1075const commentPr: McpToolHandler = {
1076 tool: {
1077 name: "gluecron_comment_pr",
1078 description:
1079 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1080 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1081 inputSchema: {
1082 type: "object",
1083 properties: {
1084 owner: { type: "string", description: "Repo owner username" },
1085 repo: { type: "string", description: "Repo name" },
1086 number: { type: "number", description: "PR number" },
1087 body: { type: "string", description: "Comment body (Markdown)" },
1088 },
1089 required: ["owner", "repo", "number", "body"],
1090 },
1091 },
1092 async run(args, ctx) {
1093 const owner = argString(args, "owner");
1094 const repo = argString(args, "repo");
1095 const number = argNumber(args, "number");
1096 const body = argString(args, "body");
1097
1098 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1099 const pr = await loadPrByNumber(gate.repoId, number);
1100 if (!pr) {
1101 throw new McpError(
1102 ERR_METHOD_NOT_FOUND,
1103 `pr not found: ${owner}/${repo}#${number}`
1104 );
1105 }
1106
1107 const [inserted] = await db
1108 .insert(prComments)
1109 .values({
1110 pullRequestId: pr.id,
1111 authorId: gate.userId,
1112 body,
1113 })
1114 .returning();
1115
1116 try {
1117 const { publish } = await import("./sse");
1118 publish(`repo:${gate.repoId}:pr:${number}`, {
1119 event: "pr-comment",
1120 data: {
1121 pullRequestId: pr.id,
1122 commentId: inserted.id,
1123 authorId: gate.userId,
1124 authorUsername: null,
1125 },
1126 });
1127 } catch {
1128 /* SSE best-effort */
1129 }
1130
1131 await audit({
1132 userId: gate.userId,
1133 repositoryId: gate.repoId,
1134 action: "pr.commented",
1135 targetType: "pull_request",
1136 targetId: pr.id,
1137 metadata: { source: "mcp", number },
1138 });
1139
b7b5f75ccanty labs1140 void logActivity({
1141 repositoryId: gate.repoId,
1142 userId: gate.userId,
1143 action: "comment",
1144 targetType: "pull_request",
1145 targetId: String(number),
1146 metadata: { source: "mcp", commentId: inserted.id },
1147 });
a54e588Test User1148 void fireWebhooks(gate.repoId, "pr", { action: "commented", number });
b7b5f75ccanty labs1149
6551045Claude1150 return { commentId: inserted.id };
1151 },
1152};
1153
1154// ---------------------------------------------------------------------------
1155// gluecron_merge_pr
1156// ---------------------------------------------------------------------------
1157
1158const mergePr: McpToolHandler = {
1159 tool: {
1160 name: "gluecron_merge_pr",
1161 description:
534f04aClaude1162 "Merge an open PR. Enforces the same checks as the HTTP merge flow: not a draft, head SHA resolves, GateTest+AI-review hard gates pass, branch-protection rules satisfied. M3: soft-blocks when the pre-merge risk score is `critical` unless `confirm_high_risk: true` is passed. Returns {merged, sha?, reason?, riskScore?}.",
523ddadccanty labs1163 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1164 inputSchema: {
1165 type: "object",
1166 properties: {
1167 owner: { type: "string", description: "Repo owner username" },
1168 repo: { type: "string", description: "Repo name" },
1169 number: { type: "number", description: "PR number" },
534f04aClaude1170 confirm_high_risk: {
1171 type: "boolean",
1172 description:
1173 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1174 },
6551045Claude1175 },
1176 required: ["owner", "repo", "number"],
1177 },
1178 },
1179 async run(args, ctx) {
1180 const owner = argString(args, "owner");
1181 const repo = argString(args, "repo");
1182 const number = argNumber(args, "number");
534f04aClaude1183 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1184
1185 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1186 const pr = await loadPrByNumber(gate.repoId, number);
1187 if (!pr) {
1188 throw new McpError(
1189 ERR_METHOD_NOT_FOUND,
1190 `pr not found: ${owner}/${repo}#${number}`
1191 );
1192 }
1193 if (pr.state !== "open") {
1194 return { merged: false, reason: `pr is ${pr.state}, not open` };
1195 }
1196 if (pr.isDraft) {
1197 return {
1198 merged: false,
1199 reason: "This PR is a draft. Mark it as ready for review before merging.",
1200 };
1201 }
1202
534f04aClaude1203 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1204 // fall back to most-recent cached row; finally compute on demand so the
1205 // MCP caller always gets a score (HTTP path is async + tolerant of a
1206 // missing score, but MCP callers want an answer in one round trip).
1207 let risk: PrRiskScore | null = null;
1208 try {
1209 risk =
1210 (await getCachedPrRisk(pr.id)) ||
1211 (await getLatestCachedPrRisk(pr.id)) ||
1212 (await computePrRiskForPullRequest(pr.id));
1213 } catch {
1214 risk = null;
1215 }
1216
1217 if (risk && risk.band === "critical" && !confirmHighRisk) {
1218 return {
1219 merged: false,
1220 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1221 riskScore: serialisePrRiskForResponse(risk),
1222 };
1223 }
1224
6551045Claude1225 const headSha = await resolveRef(owner, repo, pr.headBranch);
1226 if (!headSha) {
1227 return { merged: false, reason: "Head branch not found" };
1228 }
1229
c166384ccantynz-alt1230 const aiApproved = await isAiReviewApproved(pr.id);
6551045Claude1231
1232 const gateResult = await runAllGateChecks(
1233 owner,
1234 repo,
1235 pr.baseBranch,
1236 pr.headBranch,
1237 headSha,
1238 aiApproved
1239 );
1240
1241 const hardFailures = gateResult.checks.filter(
1242 (check) => !check.passed && check.name !== "Merge check"
1243 );
1244 if (hardFailures.length > 0) {
1245 return {
1246 merged: false,
1247 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1248 };
1249 }
1250
1251 // D5 — branch-protection enforcement
1252 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1253 if (protectionRule) {
1254 const humanApprovals = await countHumanApprovals(pr.id);
1255 const required = await listRequiredChecks(protectionRule.id);
1256 const passingNames =
1257 required.length > 0
1258 ? await passingCheckNames(gate.repoId, headSha)
1259 : [];
1260 const decision = evaluateProtection(
1261 protectionRule,
1262 {
1263 aiApproved,
1264 humanApprovalCount: humanApprovals,
1265 gateResultGreen: hardFailures.length === 0,
1266 hasFailedGates: hardFailures.length > 0,
1267 passingCheckNames: passingNames,
1268 },
1269 required.map((r) => r.checkName)
1270 );
91b054eccanty labs1271
1272 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1273 // when the rule already requires human review at all (no new DB
1274 // column for this pass). Fail-open on any internal error: a bug here
1275 // must never hard-block every merge platform-wide. Mirrors the HTTP
1276 // merge path in routes/pulls.tsx.
1277 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1278 try {
1279 const codeownersDiffProc = Bun.spawn(
1280 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1281 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1282 );
1283 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1284 await codeownersDiffProc.exited;
1285 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1286 if (changedPaths.length > 0) {
1287 const { satisfied, missingOwners } = await requiredOwnersApproved(
1288 owner,
1289 repo,
1290 gate.defaultBranch,
1291 pr.id,
1292 changedPaths
1293 );
1294 if (!satisfied) {
1295 decision.allowed = false;
1296 decision.reasons.push(
1297 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1298 );
1299 }
1300 }
1301 } catch (err) {
1302 console.warn(
1303 "[codeowners] merge enforcement failed:",
1304 err instanceof Error ? err.message : err
1305 );
1306 }
1307 }
1308
6551045Claude1309 if (!decision.allowed) {
1310 return { merged: false, reason: decision.reasons.join(" ") };
1311 }
1312 }
1313
1314 const repoDir = getRepoPath(owner, repo);
1315 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1316 const hasConflicts = mergeCheck && !mergeCheck.passed;
1317
1318 if (hasConflicts && isAiReviewEnabled()) {
1319 const mergeResult = await mergeWithAutoResolve(
1320 owner,
1321 repo,
1322 pr.baseBranch,
1323 pr.headBranch,
1324 `Merge pull request #${pr.number}: ${pr.title}`
1325 );
1326 if (!mergeResult.success) {
1327 return {
1328 merged: false,
1329 reason: mergeResult.error || "Auto-merge failed",
1330 };
1331 }
1332 if (mergeResult.resolvedFiles.length > 0) {
1333 await db.insert(prComments).values({
1334 pullRequestId: pr.id,
1335 authorId: gate.userId,
1336 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1337 .map((f) => `- \`${f}\``)
1338 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1339 isAiReview: true,
1340 });
1341 }
1342 } else {
1343 const ffProc = Bun.spawn(
1344 [
1345 "git",
1346 "update-ref",
1347 `refs/heads/${pr.baseBranch}`,
1348 `refs/heads/${pr.headBranch}`,
1349 ],
1350 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1351 );
1352 const ffExit = await ffProc.exited;
1353 if (ffExit !== 0) {
1354 return {
1355 merged: false,
1356 reason: "Merge failed — unable to update branch ref",
1357 };
1358 }
1359 }
1360
1361 await db
1362 .update(pullRequests)
1363 .set({
1364 state: "merged",
1365 mergedAt: new Date(),
1366 mergedBy: gate.userId,
1367 updatedAt: new Date(),
1368 })
1369 .where(eq(pullRequests.id, pr.id));
1370
1371 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1372 try {
1373 const { extractClosingRefsMulti } = await import("./close-keywords");
1374 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1375 for (const n of refs) {
1376 const target = await loadIssueByNumber(gate.repoId, n);
1377 if (!target || target.state !== "open") continue;
1378 await db
1379 .update(issues)
1380 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1381 .where(eq(issues.id, target.id));
1382 await db.insert(issueComments).values({
1383 issueId: target.id,
1384 authorId: gate.userId,
1385 body: `Closed by pull request #${pr.number}.`,
1386 });
1387 }
1388 } catch {
1389 /* never block merge on close-keyword failures */
1390 }
1391
1392 await audit({
1393 userId: gate.userId,
1394 repositoryId: gate.repoId,
1395 action: "pr.merged",
1396 targetType: "pull_request",
1397 targetId: pr.id,
1398 metadata: { source: "mcp", number, sha: headSha },
1399 });
1400
b7b5f75ccanty labs1401 void logActivity({
1402 repositoryId: gate.repoId,
1403 userId: gate.userId,
1404 action: "pr_merge",
1405 targetType: "pull_request",
1406 targetId: String(number),
1407 metadata: { source: "mcp", sha: headSha },
1408 });
a54e588Test User1409 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1410
6551045Claude1411 // Resolved post-merge SHA (best effort).
1412 let mergedSha: string | null = null;
1413 try {
1414 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1415 } catch {
1416 mergedSha = null;
1417 }
1418
534f04aClaude1419 // Block M3 — informational payload: when the risk score is high or
1420 // critical, include the score + summary in the response even on a
1421 // successful merge so the caller has the audit context. Low/medium
1422 // bands stay quiet to keep response noise low.
1423 const response: {
1424 merged: true;
1425 sha: string;
1426 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1427 } = {
6551045Claude1428 merged: true,
1429 sha: mergedSha ?? headSha,
1430 };
534f04aClaude1431 if (risk && (risk.band === "high" || risk.band === "critical")) {
1432 response.riskScore = serialisePrRiskForResponse(risk);
1433 }
1434 return response;
6551045Claude1435 },
1436};
1437
534f04aClaude1438/**
1439 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1440 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1441 */
1442function serialisePrRiskForResponse(risk: PrRiskScore) {
1443 return {
1444 score: risk.score,
1445 band: risk.band,
1446 aiSummary: risk.aiSummary,
1447 commitSha: risk.commitSha,
1448 signals: risk.signals,
1449 generatedAt:
1450 risk.generatedAt instanceof Date
1451 ? risk.generatedAt.toISOString()
1452 : String(risk.generatedAt),
1453 };
1454}
1455
6551045Claude1456// ---------------------------------------------------------------------------
1457// gluecron_close_pr
1458// ---------------------------------------------------------------------------
1459
1460const closePr: McpToolHandler = {
1461 tool: {
1462 name: "gluecron_close_pr",
1463 description:
1464 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1465 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1466 inputSchema: {
1467 type: "object",
1468 properties: {
1469 owner: { type: "string", description: "Repo owner username" },
1470 repo: { type: "string", description: "Repo name" },
1471 number: { type: "number", description: "PR number" },
1472 },
1473 required: ["owner", "repo", "number"],
1474 },
1475 },
1476 async run(args, ctx) {
1477 const owner = argString(args, "owner");
1478 const repo = argString(args, "repo");
1479 const number = argNumber(args, "number");
1480
1481 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1482 const pr = await loadPrByNumber(gate.repoId, number);
1483 if (!pr) {
1484 throw new McpError(
1485 ERR_METHOD_NOT_FOUND,
1486 `pr not found: ${owner}/${repo}#${number}`
1487 );
1488 }
1489
1490 if (pr.state === "open") {
1491 await db
1492 .update(pullRequests)
1493 .set({
1494 state: "closed",
1495 closedAt: new Date(),
1496 updatedAt: new Date(),
1497 })
1498 .where(eq(pullRequests.id, pr.id));
1499
1500 await audit({
1501 userId: gate.userId,
1502 repositoryId: gate.repoId,
1503 action: "pr.closed",
1504 targetType: "pull_request",
1505 targetId: pr.id,
1506 metadata: { source: "mcp", number },
1507 });
a54e588Test User1508 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1509 }
1510
1511 return { state: pr.state === "merged" ? "merged" : "closed" };
1512 },
1513};
1514
1515function sqlExpr(expr: string) {
1516 return drizzleSql.raw(expr);
1517}
1518
2c2163eClaude1519// ---------------------------------------------------------------------------
1520// Default tool registry
1521// ---------------------------------------------------------------------------
1522
1523export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1524 // The original 15-tool surface (read + K1 write) remains the source of
1525 // truth for the core gate-enforced workflows. The expanded set in
1526 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1527 // adapter over an existing lib helper — so AI agents can drive
1528 // Gluecron end-to-end.
1529 //
1530 // The expanded module imports the `mcp*` helpers re-exported above —
1531 // since those are `const` exports they're populated by the time
1532 // `defaultTools()` is called at request time, so the circular ESM
1533 // import resolves cleanly.
2c2163eClaude1534 return {
1535 [repoSearch.tool.name]: repoSearch,
1536 [repoReadFile.tool.name]: repoReadFile,
1537 [repoListIssues.tool.name]: repoListIssues,
1538 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1539 [repoHealth.tool.name]: repoHealth,
6551045Claude1540 // Block K1 — write surface
1541 [createIssue.tool.name]: createIssue,
1542 [commentIssue.tool.name]: commentIssue,
1543 [closeIssue.tool.name]: closeIssue,
1544 [reopenIssue.tool.name]: reopenIssue,
1545 [createPr.tool.name]: createPr,
1546 [getPr.tool.name]: getPr,
1547 [listPrs.tool.name]: listPrs,
1548 [commentPr.tool.name]: commentPr,
1549 [mergePr.tool.name]: mergePr,
1550 [closePr.tool.name]: closePr,
0feb720Claude1551 // Expanded surface (50+ tools total)
1552 ...expandedTools(),
2c2163eClaude1553 };
1554}
1555
0feb720Claude1556// ---------------------------------------------------------------------------
1557// Shared helpers (re-exported so the expanded tool surface in
1558// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1559// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1560// them keeps the wrapper modules tiny.
1561// ---------------------------------------------------------------------------
1562
1563export {
1564 argString as mcpArgString,
1565 argNumber as mcpArgNumber,
1566 gateWriteAccess as mcpGateWriteAccess,
1567 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1568 requireAuthedCtx as mcpRequireAuthedCtx,
1569 loadPrByNumber as mcpLoadPrByNumber,
1570 loadIssueByNumber as mcpLoadIssueByNumber,
1571 prUrl as mcpPrUrl,
1572 issueUrl as mcpIssueUrl,
1573};
1574
2c2163eClaude1575/** Test-only export of internal helpers + per-tool handlers. */
1576export const __test = {
1577 argString,
1578 argNumber,
1579 repoSearch,
1580 repoReadFile,
1581 repoListIssues,
1582 repoExplain,
f5a18f9Claude1583 repoHealth,
6551045Claude1584 // Block K1 — write surface
1585 createIssue,
1586 commentIssue,
1587 closeIssue,
1588 reopenIssue,
1589 createPr,
1590 getPr,
1591 listPrs,
1592 commentPr,
1593 mergePr,
1594 closePr,
1595 gateWriteAccess,
1596 resolveAccessibleRepo,
2c2163eClaude1597};