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.tsBlame1605 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 }> {
58bb447ccantynz-alt128 // Case-insensitive owner/repo match — GitHub-style. Repo slugs are meant to
129 // read as lowercase, but a caller typing "Vapron", "vapron", or "VAPRON"
130 // must all resolve to the same repo instead of a confusing "not found".
85fc0efccantynz-alt131 const [r] = await db
132 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
133 .from(repositories)
134 .innerJoin(users, eq(repositories.ownerId, users.id))
58bb447ccantynz-alt135 .where(
136 and(
137 drizzleSql`lower(${users.username}) = lower(${owner})`,
138 drizzleSql`lower(${repositories.name}) = lower(${repo})`
139 )
140 )
85fc0efccantynz-alt141 .limit(1);
142 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
143 if (r.isPrivate) {
144 const access = await resolveRepoAccess({
145 repoId: r.id,
146 userId: ctx.userId ?? null,
147 isPublic: false,
148 });
149 if (!satisfiesAccess(access, "read")) {
150 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
151 }
152 }
153 return r;
154}
155
2c2163eClaude156// ---------------------------------------------------------------------------
157// gluecron_repo_search
158// ---------------------------------------------------------------------------
159
160const repoSearch: McpToolHandler = {
161 tool: {
162 name: "gluecron_repo_search",
163 description:
85fc0efccantynz-alt164 "Search Gluecron repositories by keyword (name + description). Returns public repos plus any private repos owned by the authenticated caller. Up to 20 results.",
523ddadccanty labs165 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
2c2163eClaude166 inputSchema: {
167 type: "object",
168 properties: {
169 query: { type: "string", description: "Search keyword (1-100 chars)" },
170 limit: { type: "number", description: "Max results, default 20" },
171 },
172 required: ["query"],
173 },
174 },
85fc0efccantynz-alt175 async run(args, ctx) {
2c2163eClaude176 const q = argString(args, "query");
177 if (q.length > 100) {
178 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
179 }
180 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
181 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
85fc0efccantynz-alt182 // Visible = public, OR private-but-owned-by-the-authenticated-caller.
183 const visibility = ctx.userId
184 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
185 : eq(repositories.isPrivate, false);
2c2163eClaude186 const rows = await db
187 .select({
188 id: repositories.id,
189 name: repositories.name,
190 description: repositories.description,
191 ownerName: users.username,
192 stars: repositories.starCount,
193 })
194 .from(repositories)
195 .innerJoin(users, eq(repositories.ownerId, users.id))
196 .where(
197 and(
85fc0efccantynz-alt198 visibility,
2c2163eClaude199 or(
200 like(repositories.name, pattern),
201 like(repositories.description, pattern)
202 )
203 )
204 )
205 .orderBy(desc(repositories.starCount))
206 .limit(limit);
207 return {
208 total: rows.length,
209 repos: rows.map((r) => ({
210 fullName: `${r.ownerName}/${r.name}`,
211 description: r.description || "",
212 stars: r.stars,
213 })),
214 };
215 },
216};
217
218// ---------------------------------------------------------------------------
219// gluecron_repo_read_file
220// ---------------------------------------------------------------------------
221
222const repoReadFile: McpToolHandler = {
223 tool: {
224 name: "gluecron_repo_read_file",
225 description:
85fc0efccantynz-alt226 "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 labs227 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
2c2163eClaude228 inputSchema: {
229 type: "object",
230 properties: {
231 owner: { type: "string", description: "Repo owner username" },
232 repo: { type: "string", description: "Repo name" },
233 ref: { type: "string", description: "Branch / tag / commit (default: main)" },
234 path: { type: "string", description: "File path within the repo" },
235 },
236 required: ["owner", "repo", "path"],
237 },
238 },
85fc0efccantynz-alt239 async run(args, ctx) {
2c2163eClaude240 const owner = argString(args, "owner");
241 const repo = argString(args, "repo");
242 const ref = argString(args, "ref", "main");
243 const path = argString(args, "path");
244
85fc0efccantynz-alt245 // Readable if public, or private + the authenticated caller has access.
246 await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude247
248 const blob = await getBlob(owner, repo, ref, path);
249 if (!blob) {
250 throw new McpError(
251 ERR_METHOD_NOT_FOUND,
252 `path not found: ${owner}/${repo}@${ref}:${path}`
253 );
254 }
255 return {
256 content: [
257 {
258 type: "text",
259 text: blob.content,
260 },
261 ],
262 };
263 },
264};
265
266// ---------------------------------------------------------------------------
267// gluecron_repo_list_issues
268// ---------------------------------------------------------------------------
269
270const repoListIssues: McpToolHandler = {
271 tool: {
272 name: "gluecron_repo_list_issues",
273 description:
274 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
523ddadccanty labs275 annotations: { title: "List open issues", readOnlyHint: true, destructiveHint: false },
2c2163eClaude276 inputSchema: {
277 type: "object",
278 properties: {
279 owner: { type: "string", description: "Repo owner username" },
280 repo: { type: "string", description: "Repo name" },
281 limit: { type: "number", description: "Max results, default 25" },
282 },
283 required: ["owner", "repo"],
284 },
285 },
85fc0efccantynz-alt286 async run(args, ctx) {
2c2163eClaude287 const owner = argString(args, "owner");
288 const repo = argString(args, "repo");
289 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
290
85fc0efccantynz-alt291 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude292
293 const rows = await db
294 .select({
295 number: issues.number,
296 title: issues.title,
297 body: issues.body,
298 state: issues.state,
299 createdAt: issues.createdAt,
300 })
301 .from(issues)
302 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
303 .orderBy(desc(issues.createdAt))
304 .limit(limit);
305 return {
306 total: rows.length,
307 issues: rows.map((i) => ({
308 number: i.number,
309 title: i.title,
310 body: i.body || "",
311 state: i.state,
312 createdAt: i.createdAt,
313 })),
314 };
315 },
316};
317
318// ---------------------------------------------------------------------------
319// gluecron_repo_explain_codebase
320// ---------------------------------------------------------------------------
321
322const repoExplain: McpToolHandler = {
323 tool: {
324 name: "gluecron_repo_explain_codebase",
325 description:
326 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
523ddadccanty labs327 annotations: { title: "Explain codebase (cached)", readOnlyHint: true, destructiveHint: false },
2c2163eClaude328 inputSchema: {
329 type: "object",
330 properties: {
331 owner: { type: "string", description: "Repo owner username" },
332 repo: { type: "string", description: "Repo name" },
333 },
334 required: ["owner", "repo"],
335 },
336 },
85fc0efccantynz-alt337 async run(args, ctx) {
2c2163eClaude338 const owner = argString(args, "owner");
339 const repo = argString(args, "repo");
85fc0efccantynz-alt340 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude341 const [row] = await db
342 .select({
343 commitSha: codebaseExplanations.commitSha,
344 markdown: codebaseExplanations.markdown,
2316901Claude345 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude346 })
347 .from(codebaseExplanations)
348 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude349 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude350 .limit(1);
351 if (!row) {
352 return { explanation: null };
353 }
354 return {
355 commitSha: row.commitSha,
2316901Claude356 generatedAt: row.generatedAt,
2c2163eClaude357 markdown: row.markdown,
358 };
359 },
360};
361
f5a18f9Claude362// ---------------------------------------------------------------------------
363// gluecron_repo_health
364// ---------------------------------------------------------------------------
365
366const repoHealth: McpToolHandler = {
367 tool: {
368 name: "gluecron_repo_health",
369 description:
370 "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 labs371 annotations: { title: "Repository health report", readOnlyHint: true, destructiveHint: false },
f5a18f9Claude372 inputSchema: {
373 type: "object",
374 properties: {
375 owner: { type: "string", description: "Repo owner username" },
376 repo: { type: "string", description: "Repo name" },
377 },
378 required: ["owner", "repo"],
379 },
380 },
85fc0efccantynz-alt381 async run(args, ctx) {
f5a18f9Claude382 const owner = argString(args, "owner");
383 const repo = argString(args, "repo");
384
85fc0efccantynz-alt385 await resolveReadableRepo(owner, repo, ctx);
f5a18f9Claude386 if (!(await repoExists(owner, repo))) {
387 throw new McpError(
388 ERR_METHOD_NOT_FOUND,
389 `${owner}/${repo} has no on-disk git data yet`
390 );
391 }
392 const report = await computeHealthScore(owner, repo);
393 return {
394 score: report.score,
395 grade: report.grade,
396 breakdown: report.breakdown,
397 insights: report.insights,
398 generatedAt: report.generatedAt,
399 };
400 },
401};
402
6551045Claude403// ---------------------------------------------------------------------------
404// Write-surface shared helpers (Block K1)
405// ---------------------------------------------------------------------------
406
407/**
408 * Require an authenticated context — every write tool gates on this first.
409 * Throws -32602 invalid_params with a tool-specific message so the client
410 * surface knows exactly which call needs auth.
411 */
412function requireAuthedCtx(ctx: McpContext, toolName: string): string {
413 if (!ctx.userId) {
414 throw new McpError(
415 ERR_INVALID_PARAMS,
416 `authentication required for ${toolName}`
417 );
418 }
419 return ctx.userId;
420}
421
422/**
423 * Resolve an `owner/repo` pair to its full row, and confirm the caller has
424 * at least `read` access. Privacy: when the repo is missing OR the caller
425 * cannot see it, throw -32601 method_not_found with the same message —
426 * matches the read-tool privacy contract so private-repo existence does
427 * not leak.
428 */
429async function resolveAccessibleRepo(
430 owner: string,
431 repo: string,
432 userId: string | null
433): Promise<{
434 repoId: string;
435 ownerId: string;
436 isPrivate: boolean;
437 defaultBranch: string;
438 access: RepoAccessLevel;
439}> {
440 const [row] = await db
441 .select({
442 id: repositories.id,
443 ownerId: repositories.ownerId,
444 isPrivate: repositories.isPrivate,
445 defaultBranch: repositories.defaultBranch,
446 })
447 .from(repositories)
448 .innerJoin(users, eq(repositories.ownerId, users.id))
449 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
450 .limit(1);
451 if (!row) {
452 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
453 }
454 const access = await resolveRepoAccess({
455 repoId: row.id,
456 userId,
457 isPublic: !row.isPrivate,
458 });
459 if (!satisfiesAccess(access, "read")) {
460 // Hide existence of private repos from non-collaborators.
461 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
462 }
463 return {
464 repoId: row.id,
465 ownerId: row.ownerId,
466 isPrivate: row.isPrivate,
467 defaultBranch: row.defaultBranch,
468 access,
469 };
470}
471
472/**
473 * Combine `requireAuthedCtx` + `resolveAccessibleRepo` + write-access gate.
474 * Used by every write tool. Returns the repo metadata for downstream use.
475 *
476 * Per spec: reject with -32601 method_not_found (not -32603) so the
477 * existence of private repos the caller cannot see is not leaked through
478 * the error code shape.
479 */
480async function gateWriteAccess(
481 args: { owner: string; repo: string },
482 ctx: McpContext,
483 toolName: string
484): Promise<{
485 userId: string;
486 owner: string;
487 repo: string;
488 repoId: string;
489 ownerId: string;
490 isPrivate: boolean;
491 defaultBranch: string;
492}> {
493 const userId = requireAuthedCtx(ctx, toolName);
494 const info = await resolveAccessibleRepo(args.owner, args.repo, userId);
495 if (!satisfiesAccess(info.access, "write")) {
496 throw new McpError(
497 ERR_METHOD_NOT_FOUND,
498 `no write access to ${args.owner}/${args.repo}`
499 );
500 }
501 return {
502 userId,
503 owner: args.owner,
504 repo: args.repo,
505 repoId: info.repoId,
506 ownerId: info.ownerId,
507 isPrivate: info.isPrivate,
508 defaultBranch: info.defaultBranch,
509 };
510}
511
512function prUrl(owner: string, repo: string, number: number): string {
513 return `/${owner}/${repo}/pulls/${number}`;
514}
515function issueUrl(owner: string, repo: string, number: number): string {
516 return `/${owner}/${repo}/issues/${number}`;
517}
518
519async function loadPrByNumber(repoId: string, prNumber: number) {
520 const [pr] = await db
521 .select()
522 .from(pullRequests)
523 .where(
524 and(
525 eq(pullRequests.repositoryId, repoId),
526 eq(pullRequests.number, prNumber)
527 )
528 )
529 .limit(1);
530 return pr ?? null;
531}
532
533async function loadIssueByNumber(repoId: string, issueNumber: number) {
534 const [row] = await db
535 .select()
536 .from(issues)
537 .where(
538 and(
539 eq(issues.repositoryId, repoId),
540 eq(issues.number, issueNumber)
541 )
542 )
543 .limit(1);
544 return row ?? null;
545}
546
547// ---------------------------------------------------------------------------
548// gluecron_create_issue
549// ---------------------------------------------------------------------------
550
551const createIssue: McpToolHandler = {
552 tool: {
553 name: "gluecron_create_issue",
554 description:
555 "Create a new issue on a Gluecron repository. Requires authenticated caller with write access on the target repo. Returns {number, url}.",
523ddadccanty labs556 annotations: { title: "Create issue", readOnlyHint: false, destructiveHint: false },
6551045Claude557 inputSchema: {
558 type: "object",
559 properties: {
560 owner: { type: "string", description: "Repo owner username" },
561 repo: { type: "string", description: "Repo name" },
562 title: { type: "string", description: "Issue title" },
563 body: { type: "string", description: "Issue body (Markdown). Optional." },
564 },
565 required: ["owner", "repo", "title"],
566 },
567 },
568 async run(args, ctx) {
569 const owner = argString(args, "owner");
570 const repo = argString(args, "repo");
571 const title = argString(args, "title");
572 const body = argString(args, "body", "");
573
574 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_issue");
575
576 const [inserted] = await db
577 .insert(issues)
578 .values({
579 repositoryId: gate.repoId,
580 authorId: gate.userId,
581 title,
582 body: body || null,
583 })
584 .returning();
585
586 // Bump issue count (best-effort, mirrors the HTTP route)
587 try {
588 await db
589 .update(repositories)
590 .set({ issueCount: sqlExpr("issue_count + 1") })
591 .where(eq(repositories.id, gate.repoId));
592 } catch {
593 /* non-fatal */
594 }
595
596 await audit({
597 userId: gate.userId,
598 repositoryId: gate.repoId,
599 action: "issue.created",
600 targetType: "issue",
601 targetId: inserted.id,
602 metadata: { source: "mcp", number: inserted.number, title },
603 });
a54e588Test User604 void fireWebhooks(gate.repoId, "issue", { action: "opened", number: inserted.number, title });
6551045Claude605
606 // Notify repo owner if it's not the same user (mirrors typical web flow).
607 if (gate.ownerId !== gate.userId) {
608 notify(gate.ownerId, {
609 kind: "issue_opened",
610 title: `New issue: ${title}`,
611 url: issueUrl(owner, repo, inserted.number),
612 repositoryId: gate.repoId,
a28cedeClaude613 }).catch((err) => {
614 console.warn(
615 `[mcp] issue_opened notify failed for ${owner}/${repo}#${inserted.number}:`,
616 err instanceof Error ? err.message : err
617 );
618 });
6551045Claude619 }
620
621 return {
622 number: inserted.number,
623 url: issueUrl(owner, repo, inserted.number),
624 };
625 },
626};
627
628// ---------------------------------------------------------------------------
629// gluecron_comment_issue
630// ---------------------------------------------------------------------------
631
632const commentIssue: McpToolHandler = {
633 tool: {
634 name: "gluecron_comment_issue",
635 description:
636 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs637 annotations: { title: "Comment on issue", readOnlyHint: false, destructiveHint: false },
6551045Claude638 inputSchema: {
639 type: "object",
640 properties: {
641 owner: { type: "string", description: "Repo owner username" },
642 repo: { type: "string", description: "Repo name" },
643 number: { type: "number", description: "Issue number" },
644 body: { type: "string", description: "Comment body (Markdown)" },
645 },
646 required: ["owner", "repo", "number", "body"],
647 },
648 },
649 async run(args, ctx) {
650 const owner = argString(args, "owner");
651 const repo = argString(args, "repo");
652 const number = argNumber(args, "number");
653 const body = argString(args, "body");
654
655 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
656 const issue = await loadIssueByNumber(gate.repoId, number);
657 if (!issue) {
658 throw new McpError(
659 ERR_METHOD_NOT_FOUND,
660 `issue not found: ${owner}/${repo}#${number}`
661 );
662 }
663
664 const [inserted] = await db
665 .insert(issueComments)
666 .values({
667 issueId: issue.id,
668 authorId: gate.userId,
669 body,
670 })
671 .returning();
672
673 // SSE fanout — best-effort.
674 try {
675 const { publish } = await import("./sse");
676 publish(`repo:${gate.repoId}:issue:${number}`, {
677 event: "issue-comment",
678 data: {
679 issueId: issue.id,
680 commentId: inserted.id,
681 authorId: gate.userId,
682 authorUsername: null,
683 },
684 });
685 } catch {
686 /* SSE best-effort */
687 }
688
689 await audit({
690 userId: gate.userId,
691 repositoryId: gate.repoId,
692 action: "issue.commented",
693 targetType: "issue",
694 targetId: issue.id,
695 metadata: { source: "mcp", number },
696 });
a54e588Test User697 void fireWebhooks(gate.repoId, "issue", { action: "commented", number });
6551045Claude698
699 return { commentId: inserted.id };
700 },
701};
702
703// ---------------------------------------------------------------------------
704// gluecron_close_issue
705// ---------------------------------------------------------------------------
706
707const closeIssue: McpToolHandler = {
708 tool: {
709 name: "gluecron_close_issue",
710 description:
711 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
523ddadccanty labs712 annotations: { title: "Close issue", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude713 inputSchema: {
714 type: "object",
715 properties: {
716 owner: { type: "string", description: "Repo owner username" },
717 repo: { type: "string", description: "Repo name" },
718 number: { type: "number", description: "Issue number" },
719 },
720 required: ["owner", "repo", "number"],
721 },
722 },
723 async run(args, ctx) {
724 const owner = argString(args, "owner");
725 const repo = argString(args, "repo");
726 const number = argNumber(args, "number");
727
728 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
729 const issue = await loadIssueByNumber(gate.repoId, number);
730 if (!issue) {
731 throw new McpError(
732 ERR_METHOD_NOT_FOUND,
733 `issue not found: ${owner}/${repo}#${number}`
734 );
735 }
736
737 if (issue.state !== "closed") {
738 await db
739 .update(issues)
740 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
741 .where(eq(issues.id, issue.id));
742
743 await audit({
744 userId: gate.userId,
745 repositoryId: gate.repoId,
746 action: "issue.closed",
747 targetType: "issue",
748 targetId: issue.id,
749 metadata: { source: "mcp", number },
750 });
a54e588Test User751 void fireWebhooks(gate.repoId, "issue", { action: "closed", number });
6551045Claude752 }
753
754 return { state: "closed" };
755 },
756};
757
758// ---------------------------------------------------------------------------
759// gluecron_reopen_issue
760// ---------------------------------------------------------------------------
761
762const reopenIssue: McpToolHandler = {
763 tool: {
764 name: "gluecron_reopen_issue",
765 description:
766 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs767 // Reopen merely undoes a close (trivially reversible) — not destructive.
768 annotations: { title: "Reopen issue", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
6551045Claude769 inputSchema: {
770 type: "object",
771 properties: {
772 owner: { type: "string", description: "Repo owner username" },
773 repo: { type: "string", description: "Repo name" },
774 number: { type: "number", description: "Issue number" },
775 },
776 required: ["owner", "repo", "number"],
777 },
778 },
779 async run(args, ctx) {
780 const owner = argString(args, "owner");
781 const repo = argString(args, "repo");
782 const number = argNumber(args, "number");
783
784 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
785 const issue = await loadIssueByNumber(gate.repoId, number);
786 if (!issue) {
787 throw new McpError(
788 ERR_METHOD_NOT_FOUND,
789 `issue not found: ${owner}/${repo}#${number}`
790 );
791 }
792
793 if (issue.state !== "open") {
794 await db
795 .update(issues)
796 .set({ state: "open", closedAt: null, updatedAt: new Date() })
797 .where(eq(issues.id, issue.id));
798
799 await audit({
800 userId: gate.userId,
801 repositoryId: gate.repoId,
802 action: "issue.reopened",
803 targetType: "issue",
804 targetId: issue.id,
805 metadata: { source: "mcp", number },
806 });
a54e588Test User807 void fireWebhooks(gate.repoId, "issue", { action: "reopened", number });
6551045Claude808 }
809
810 return { state: "open" };
811 },
812};
813
814// ---------------------------------------------------------------------------
815// gluecron_create_pr
816// ---------------------------------------------------------------------------
817
818const createPr: McpToolHandler = {
819 tool: {
820 name: "gluecron_create_pr",
821 description:
822 "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 labs823 annotations: { title: "Create pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude824 inputSchema: {
825 type: "object",
826 properties: {
827 owner: { type: "string", description: "Repo owner username" },
828 repo: { type: "string", description: "Repo name" },
829 title: { type: "string", description: "PR title" },
830 body: { type: "string", description: "PR body (Markdown). Optional." },
831 head_branch: { type: "string", description: "Branch with the changes" },
832 base_branch: {
833 type: "string",
834 description: "Target branch (default: repo default branch)",
835 },
836 },
837 required: ["owner", "repo", "title", "head_branch"],
838 },
839 },
840 async run(args, ctx) {
841 const owner = argString(args, "owner");
842 const repo = argString(args, "repo");
843 const title = argString(args, "title");
844 const body = argString(args, "body", "");
845 const headBranch = argString(args, "head_branch");
846
847 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
848 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
849
850 if (baseBranch === headBranch) {
851 throw new McpError(
852 ERR_INVALID_PARAMS,
853 "base and head branches must be different"
854 );
855 }
856
857 const [pr] = await db
858 .insert(pullRequests)
859 .values({
860 repositoryId: gate.repoId,
861 authorId: gate.userId,
862 title,
863 body: body || null,
864 baseBranch,
865 headBranch,
866 })
867 .returning();
868
869 await audit({
870 userId: gate.userId,
871 repositoryId: gate.repoId,
872 action: "pr.opened",
873 targetType: "pull_request",
874 targetId: pr.id,
875 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
876 });
877
b7b5f75ccanty labs878 void logActivity({
879 repositoryId: gate.repoId,
880 userId: gate.userId,
881 action: "pr_open",
882 targetType: "pull_request",
883 targetId: String(pr.number),
884 metadata: { source: "mcp", baseBranch, headBranch },
885 });
a54e588Test User886 void fireWebhooks(gate.repoId, "pr", { action: "opened", number: pr.number, baseBranch, headBranch });
b7b5f75ccanty labs887
79a52c8ccantynz-alt888 // PRs opened through the MCP write surface (exactly how an external AI
889 // platform or the agent-journey certification drives this) never
890 // triggered AI review or triage -- both were wired into the web
891 // /pulls/new route only. Mirror that here so MCP-created PRs get the
892 // same treatment as web-created ones.
893 if (isAiReviewEnabled()) {
894 triggerAiReview(owner, repo, pr.id, title, body || "", baseBranch, headBranch).catch(
895 (err) => console.error("[mcp] triggerAiReview failed:", err)
896 );
897 }
898 triggerPrTriage({
899 ownerName: owner,
900 repoName: repo,
901 repositoryId: gate.repoId,
902 prId: pr.id,
903 prAuthorId: gate.userId,
904 title,
905 body: body || "",
906 baseBranch,
907 headBranch,
908 }).catch((err) => console.error("[mcp] triggerPrTriage failed:", err));
909
6551045Claude910 if (gate.ownerId !== gate.userId) {
911 notify(gate.ownerId, {
912 kind: "pr_opened",
913 title: `New PR: ${title}`,
914 url: prUrl(owner, repo, pr.number),
915 repositoryId: gate.repoId,
a28cedeClaude916 }).catch((err) => {
917 console.warn(
918 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
919 err instanceof Error ? err.message : err
920 );
921 });
6551045Claude922 }
923
924 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
925 },
926};
927
928// ---------------------------------------------------------------------------
929// gluecron_get_pr
930// ---------------------------------------------------------------------------
931
932const getPr: McpToolHandler = {
933 tool: {
934 name: "gluecron_get_pr",
935 description:
936 "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 labs937 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude938 inputSchema: {
939 type: "object",
940 properties: {
941 owner: { type: "string", description: "Repo owner username" },
942 repo: { type: "string", description: "Repo name" },
943 number: { type: "number", description: "PR number" },
944 },
945 required: ["owner", "repo", "number"],
946 },
947 },
948 async run(args, ctx) {
949 const owner = argString(args, "owner");
950 const repo = argString(args, "repo");
951 const number = argNumber(args, "number");
952
953 requireAuthedCtx(ctx, "gluecron_get_pr");
954 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
955
956 const pr = await loadPrByNumber(info.repoId, number);
957 if (!pr) {
958 throw new McpError(
959 ERR_METHOD_NOT_FOUND,
960 `pr not found: ${owner}/${repo}#${number}`
961 );
962 }
963
964 const [author] = await db
965 .select({ username: users.username })
966 .from(users)
967 .where(eq(users.id, pr.authorId))
968 .limit(1);
969
970 // Best-effort mergeability hint via a quick head-ref resolve.
971 let mergeable: boolean | null = null;
972 try {
973 const headSha = await resolveRef(owner, repo, pr.headBranch);
974 mergeable = headSha ? true : null;
975 } catch {
976 mergeable = null;
977 }
978
979 return {
980 number: pr.number,
981 title: pr.title,
982 body: pr.body || "",
983 state: pr.state,
984 baseBranch: pr.baseBranch,
985 headBranch: pr.headBranch,
986 isDraft: pr.isDraft,
987 mergeable,
988 author: author?.username ?? null,
989 createdAt: pr.createdAt,
990 updatedAt: pr.updatedAt,
991 mergedAt: pr.mergedAt,
992 closedAt: pr.closedAt,
993 url: prUrl(owner, repo, pr.number),
994 };
995 },
996};
997
998// ---------------------------------------------------------------------------
999// gluecron_list_prs
1000// ---------------------------------------------------------------------------
1001
1002const listPrs: McpToolHandler = {
1003 tool: {
1004 name: "gluecron_list_prs",
1005 description:
1006 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs1007 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude1008 inputSchema: {
1009 type: "object",
1010 properties: {
1011 owner: { type: "string", description: "Repo owner username" },
1012 repo: { type: "string", description: "Repo name" },
1013 state: {
1014 type: "string",
1015 description: "open | closed | merged | all (default: open)",
1016 },
1017 },
1018 required: ["owner", "repo"],
1019 },
1020 },
1021 async run(args, ctx) {
1022 const owner = argString(args, "owner");
1023 const repo = argString(args, "repo");
1024 const state = argString(args, "state", "open");
1025 if (!["open", "closed", "merged", "all"].includes(state)) {
1026 throw new McpError(
1027 ERR_INVALID_PARAMS,
1028 `state must be one of open|closed|merged|all (got "${state}")`
1029 );
1030 }
1031
1032 requireAuthedCtx(ctx, "gluecron_list_prs");
1033 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1034
1035 const whereClause =
1036 state === "all"
1037 ? eq(pullRequests.repositoryId, info.repoId)
1038 : and(
1039 eq(pullRequests.repositoryId, info.repoId),
1040 eq(pullRequests.state, state)
1041 );
1042
1043 const rows = await db
1044 .select({
1045 number: pullRequests.number,
1046 title: pullRequests.title,
1047 state: pullRequests.state,
1048 baseBranch: pullRequests.baseBranch,
1049 headBranch: pullRequests.headBranch,
1050 isDraft: pullRequests.isDraft,
1051 authorUsername: users.username,
1052 createdAt: pullRequests.createdAt,
1053 updatedAt: pullRequests.updatedAt,
1054 })
1055 .from(pullRequests)
1056 .innerJoin(users, eq(pullRequests.authorId, users.id))
1057 .where(whereClause)
1058 .orderBy(desc(pullRequests.createdAt))
1059 .limit(50);
1060
1061 return {
1062 total: rows.length,
1063 prs: rows.map((p) => ({
1064 number: p.number,
1065 title: p.title,
1066 state: p.state,
1067 baseBranch: p.baseBranch,
1068 headBranch: p.headBranch,
1069 isDraft: p.isDraft,
1070 author: p.authorUsername,
1071 createdAt: p.createdAt,
1072 updatedAt: p.updatedAt,
1073 url: prUrl(owner, repo, p.number),
1074 })),
1075 };
1076 },
1077};
1078
1079// ---------------------------------------------------------------------------
1080// gluecron_comment_pr
1081// ---------------------------------------------------------------------------
1082
1083const commentPr: McpToolHandler = {
1084 tool: {
1085 name: "gluecron_comment_pr",
1086 description:
1087 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1088 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1089 inputSchema: {
1090 type: "object",
1091 properties: {
1092 owner: { type: "string", description: "Repo owner username" },
1093 repo: { type: "string", description: "Repo name" },
1094 number: { type: "number", description: "PR number" },
1095 body: { type: "string", description: "Comment body (Markdown)" },
1096 },
1097 required: ["owner", "repo", "number", "body"],
1098 },
1099 },
1100 async run(args, ctx) {
1101 const owner = argString(args, "owner");
1102 const repo = argString(args, "repo");
1103 const number = argNumber(args, "number");
1104 const body = argString(args, "body");
1105
1106 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1107 const pr = await loadPrByNumber(gate.repoId, number);
1108 if (!pr) {
1109 throw new McpError(
1110 ERR_METHOD_NOT_FOUND,
1111 `pr not found: ${owner}/${repo}#${number}`
1112 );
1113 }
1114
1115 const [inserted] = await db
1116 .insert(prComments)
1117 .values({
1118 pullRequestId: pr.id,
1119 authorId: gate.userId,
1120 body,
1121 })
1122 .returning();
1123
1124 try {
1125 const { publish } = await import("./sse");
1126 publish(`repo:${gate.repoId}:pr:${number}`, {
1127 event: "pr-comment",
1128 data: {
1129 pullRequestId: pr.id,
1130 commentId: inserted.id,
1131 authorId: gate.userId,
1132 authorUsername: null,
1133 },
1134 });
1135 } catch {
1136 /* SSE best-effort */
1137 }
1138
1139 await audit({
1140 userId: gate.userId,
1141 repositoryId: gate.repoId,
1142 action: "pr.commented",
1143 targetType: "pull_request",
1144 targetId: pr.id,
1145 metadata: { source: "mcp", number },
1146 });
1147
b7b5f75ccanty labs1148 void logActivity({
1149 repositoryId: gate.repoId,
1150 userId: gate.userId,
1151 action: "comment",
1152 targetType: "pull_request",
1153 targetId: String(number),
1154 metadata: { source: "mcp", commentId: inserted.id },
1155 });
a54e588Test User1156 void fireWebhooks(gate.repoId, "pr", { action: "commented", number });
b7b5f75ccanty labs1157
6551045Claude1158 return { commentId: inserted.id };
1159 },
1160};
1161
1162// ---------------------------------------------------------------------------
1163// gluecron_merge_pr
1164// ---------------------------------------------------------------------------
1165
1166const mergePr: McpToolHandler = {
1167 tool: {
1168 name: "gluecron_merge_pr",
1169 description:
534f04aClaude1170 "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 labs1171 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1172 inputSchema: {
1173 type: "object",
1174 properties: {
1175 owner: { type: "string", description: "Repo owner username" },
1176 repo: { type: "string", description: "Repo name" },
1177 number: { type: "number", description: "PR number" },
534f04aClaude1178 confirm_high_risk: {
1179 type: "boolean",
1180 description:
1181 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1182 },
6551045Claude1183 },
1184 required: ["owner", "repo", "number"],
1185 },
1186 },
1187 async run(args, ctx) {
1188 const owner = argString(args, "owner");
1189 const repo = argString(args, "repo");
1190 const number = argNumber(args, "number");
534f04aClaude1191 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1192
1193 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1194 const pr = await loadPrByNumber(gate.repoId, number);
1195 if (!pr) {
1196 throw new McpError(
1197 ERR_METHOD_NOT_FOUND,
1198 `pr not found: ${owner}/${repo}#${number}`
1199 );
1200 }
1201 if (pr.state !== "open") {
1202 return { merged: false, reason: `pr is ${pr.state}, not open` };
1203 }
1204 if (pr.isDraft) {
1205 return {
1206 merged: false,
1207 reason: "This PR is a draft. Mark it as ready for review before merging.",
1208 };
1209 }
1210
534f04aClaude1211 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1212 // fall back to most-recent cached row; finally compute on demand so the
1213 // MCP caller always gets a score (HTTP path is async + tolerant of a
1214 // missing score, but MCP callers want an answer in one round trip).
1215 let risk: PrRiskScore | null = null;
1216 try {
1217 risk =
1218 (await getCachedPrRisk(pr.id)) ||
1219 (await getLatestCachedPrRisk(pr.id)) ||
1220 (await computePrRiskForPullRequest(pr.id));
1221 } catch {
1222 risk = null;
1223 }
1224
1225 if (risk && risk.band === "critical" && !confirmHighRisk) {
1226 return {
1227 merged: false,
1228 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1229 riskScore: serialisePrRiskForResponse(risk),
1230 };
1231 }
1232
6551045Claude1233 const headSha = await resolveRef(owner, repo, pr.headBranch);
1234 if (!headSha) {
1235 return { merged: false, reason: "Head branch not found" };
1236 }
1237
c166384ccantynz-alt1238 const aiApproved = await isAiReviewApproved(pr.id);
6551045Claude1239
1240 const gateResult = await runAllGateChecks(
1241 owner,
1242 repo,
1243 pr.baseBranch,
1244 pr.headBranch,
1245 headSha,
1246 aiApproved
1247 );
1248
1249 const hardFailures = gateResult.checks.filter(
1250 (check) => !check.passed && check.name !== "Merge check"
1251 );
1252 if (hardFailures.length > 0) {
1253 return {
1254 merged: false,
1255 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1256 };
1257 }
1258
1259 // D5 — branch-protection enforcement
1260 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1261 if (protectionRule) {
1262 const humanApprovals = await countHumanApprovals(pr.id);
1263 const required = await listRequiredChecks(protectionRule.id);
1264 const passingNames =
1265 required.length > 0
1266 ? await passingCheckNames(gate.repoId, headSha)
1267 : [];
1268 const decision = evaluateProtection(
1269 protectionRule,
1270 {
1271 aiApproved,
1272 humanApprovalCount: humanApprovals,
1273 gateResultGreen: hardFailures.length === 0,
1274 hasFailedGates: hardFailures.length > 0,
1275 passingCheckNames: passingNames,
1276 },
1277 required.map((r) => r.checkName)
1278 );
91b054eccanty labs1279
1280 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1281 // when the rule already requires human review at all (no new DB
1282 // column for this pass). Fail-open on any internal error: a bug here
1283 // must never hard-block every merge platform-wide. Mirrors the HTTP
1284 // merge path in routes/pulls.tsx.
1285 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1286 try {
1287 const codeownersDiffProc = Bun.spawn(
1288 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1289 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1290 );
1291 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1292 await codeownersDiffProc.exited;
1293 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1294 if (changedPaths.length > 0) {
1295 const { satisfied, missingOwners } = await requiredOwnersApproved(
1296 owner,
1297 repo,
1298 gate.defaultBranch,
1299 pr.id,
1300 changedPaths
1301 );
1302 if (!satisfied) {
1303 decision.allowed = false;
1304 decision.reasons.push(
1305 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1306 );
1307 }
1308 }
1309 } catch (err) {
1310 console.warn(
1311 "[codeowners] merge enforcement failed:",
1312 err instanceof Error ? err.message : err
1313 );
1314 }
1315 }
1316
6551045Claude1317 if (!decision.allowed) {
1318 return { merged: false, reason: decision.reasons.join(" ") };
1319 }
1320 }
1321
1322 const repoDir = getRepoPath(owner, repo);
1323 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1324 const hasConflicts = mergeCheck && !mergeCheck.passed;
1325
1326 if (hasConflicts && isAiReviewEnabled()) {
1327 const mergeResult = await mergeWithAutoResolve(
1328 owner,
1329 repo,
1330 pr.baseBranch,
1331 pr.headBranch,
1332 `Merge pull request #${pr.number}: ${pr.title}`
1333 );
1334 if (!mergeResult.success) {
1335 return {
1336 merged: false,
1337 reason: mergeResult.error || "Auto-merge failed",
1338 };
1339 }
1340 if (mergeResult.resolvedFiles.length > 0) {
1341 await db.insert(prComments).values({
1342 pullRequestId: pr.id,
1343 authorId: gate.userId,
1344 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1345 .map((f) => `- \`${f}\``)
1346 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1347 isAiReview: true,
1348 });
1349 }
1350 } else {
1351 const ffProc = Bun.spawn(
1352 [
1353 "git",
1354 "update-ref",
1355 `refs/heads/${pr.baseBranch}`,
1356 `refs/heads/${pr.headBranch}`,
1357 ],
1358 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1359 );
1360 const ffExit = await ffProc.exited;
1361 if (ffExit !== 0) {
1362 return {
1363 merged: false,
1364 reason: "Merge failed — unable to update branch ref",
1365 };
1366 }
1367 }
1368
1369 await db
1370 .update(pullRequests)
1371 .set({
1372 state: "merged",
1373 mergedAt: new Date(),
1374 mergedBy: gate.userId,
1375 updatedAt: new Date(),
1376 })
1377 .where(eq(pullRequests.id, pr.id));
1378
1379 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1380 try {
1381 const { extractClosingRefsMulti } = await import("./close-keywords");
1382 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1383 for (const n of refs) {
1384 const target = await loadIssueByNumber(gate.repoId, n);
1385 if (!target || target.state !== "open") continue;
1386 await db
1387 .update(issues)
1388 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1389 .where(eq(issues.id, target.id));
1390 await db.insert(issueComments).values({
1391 issueId: target.id,
1392 authorId: gate.userId,
1393 body: `Closed by pull request #${pr.number}.`,
1394 });
1395 }
1396 } catch {
1397 /* never block merge on close-keyword failures */
1398 }
1399
1400 await audit({
1401 userId: gate.userId,
1402 repositoryId: gate.repoId,
1403 action: "pr.merged",
1404 targetType: "pull_request",
1405 targetId: pr.id,
1406 metadata: { source: "mcp", number, sha: headSha },
1407 });
1408
b7b5f75ccanty labs1409 void logActivity({
1410 repositoryId: gate.repoId,
1411 userId: gate.userId,
1412 action: "pr_merge",
1413 targetType: "pull_request",
1414 targetId: String(number),
1415 metadata: { source: "mcp", sha: headSha },
1416 });
a54e588Test User1417 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1418
6551045Claude1419 // Resolved post-merge SHA (best effort).
1420 let mergedSha: string | null = null;
1421 try {
1422 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1423 } catch {
1424 mergedSha = null;
1425 }
1426
534f04aClaude1427 // Block M3 — informational payload: when the risk score is high or
1428 // critical, include the score + summary in the response even on a
1429 // successful merge so the caller has the audit context. Low/medium
1430 // bands stay quiet to keep response noise low.
1431 const response: {
1432 merged: true;
1433 sha: string;
1434 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1435 } = {
6551045Claude1436 merged: true,
1437 sha: mergedSha ?? headSha,
1438 };
534f04aClaude1439 if (risk && (risk.band === "high" || risk.band === "critical")) {
1440 response.riskScore = serialisePrRiskForResponse(risk);
1441 }
1442 return response;
6551045Claude1443 },
1444};
1445
534f04aClaude1446/**
1447 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1448 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1449 */
1450function serialisePrRiskForResponse(risk: PrRiskScore) {
1451 return {
1452 score: risk.score,
1453 band: risk.band,
1454 aiSummary: risk.aiSummary,
1455 commitSha: risk.commitSha,
1456 signals: risk.signals,
1457 generatedAt:
1458 risk.generatedAt instanceof Date
1459 ? risk.generatedAt.toISOString()
1460 : String(risk.generatedAt),
1461 };
1462}
1463
6551045Claude1464// ---------------------------------------------------------------------------
1465// gluecron_close_pr
1466// ---------------------------------------------------------------------------
1467
1468const closePr: McpToolHandler = {
1469 tool: {
1470 name: "gluecron_close_pr",
1471 description:
1472 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1473 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1474 inputSchema: {
1475 type: "object",
1476 properties: {
1477 owner: { type: "string", description: "Repo owner username" },
1478 repo: { type: "string", description: "Repo name" },
1479 number: { type: "number", description: "PR number" },
1480 },
1481 required: ["owner", "repo", "number"],
1482 },
1483 },
1484 async run(args, ctx) {
1485 const owner = argString(args, "owner");
1486 const repo = argString(args, "repo");
1487 const number = argNumber(args, "number");
1488
1489 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1490 const pr = await loadPrByNumber(gate.repoId, number);
1491 if (!pr) {
1492 throw new McpError(
1493 ERR_METHOD_NOT_FOUND,
1494 `pr not found: ${owner}/${repo}#${number}`
1495 );
1496 }
1497
1498 if (pr.state === "open") {
1499 await db
1500 .update(pullRequests)
1501 .set({
1502 state: "closed",
1503 closedAt: new Date(),
1504 updatedAt: new Date(),
1505 })
1506 .where(eq(pullRequests.id, pr.id));
1507
1508 await audit({
1509 userId: gate.userId,
1510 repositoryId: gate.repoId,
1511 action: "pr.closed",
1512 targetType: "pull_request",
1513 targetId: pr.id,
1514 metadata: { source: "mcp", number },
1515 });
a54e588Test User1516 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1517 }
1518
1519 return { state: pr.state === "merged" ? "merged" : "closed" };
1520 },
1521};
1522
1523function sqlExpr(expr: string) {
1524 return drizzleSql.raw(expr);
1525}
1526
2c2163eClaude1527// ---------------------------------------------------------------------------
1528// Default tool registry
1529// ---------------------------------------------------------------------------
1530
1531export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1532 // The original 15-tool surface (read + K1 write) remains the source of
1533 // truth for the core gate-enforced workflows. The expanded set in
1534 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1535 // adapter over an existing lib helper — so AI agents can drive
1536 // Gluecron end-to-end.
1537 //
1538 // The expanded module imports the `mcp*` helpers re-exported above —
1539 // since those are `const` exports they're populated by the time
1540 // `defaultTools()` is called at request time, so the circular ESM
1541 // import resolves cleanly.
2c2163eClaude1542 return {
1543 [repoSearch.tool.name]: repoSearch,
1544 [repoReadFile.tool.name]: repoReadFile,
1545 [repoListIssues.tool.name]: repoListIssues,
1546 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1547 [repoHealth.tool.name]: repoHealth,
6551045Claude1548 // Block K1 — write surface
1549 [createIssue.tool.name]: createIssue,
1550 [commentIssue.tool.name]: commentIssue,
1551 [closeIssue.tool.name]: closeIssue,
1552 [reopenIssue.tool.name]: reopenIssue,
1553 [createPr.tool.name]: createPr,
1554 [getPr.tool.name]: getPr,
1555 [listPrs.tool.name]: listPrs,
1556 [commentPr.tool.name]: commentPr,
1557 [mergePr.tool.name]: mergePr,
1558 [closePr.tool.name]: closePr,
0feb720Claude1559 // Expanded surface (50+ tools total)
1560 ...expandedTools(),
2c2163eClaude1561 };
1562}
1563
0feb720Claude1564// ---------------------------------------------------------------------------
1565// Shared helpers (re-exported so the expanded tool surface in
1566// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1567// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1568// them keeps the wrapper modules tiny.
1569// ---------------------------------------------------------------------------
1570
1571export {
1572 argString as mcpArgString,
1573 argNumber as mcpArgNumber,
1574 gateWriteAccess as mcpGateWriteAccess,
1575 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1576 requireAuthedCtx as mcpRequireAuthedCtx,
1577 loadPrByNumber as mcpLoadPrByNumber,
1578 loadIssueByNumber as mcpLoadIssueByNumber,
1579 prUrl as mcpPrUrl,
1580 issueUrl as mcpIssueUrl,
1581};
1582
2c2163eClaude1583/** Test-only export of internal helpers + per-tool handlers. */
1584export const __test = {
1585 argString,
1586 argNumber,
1587 repoSearch,
1588 repoReadFile,
1589 repoListIssues,
1590 repoExplain,
f5a18f9Claude1591 repoHealth,
6551045Claude1592 // Block K1 — write surface
1593 createIssue,
1594 commentIssue,
1595 closeIssue,
1596 reopenIssue,
1597 createPr,
1598 getPr,
1599 listPrs,
1600 commentPr,
1601 mergePr,
1602 closePr,
1603 gateWriteAccess,
1604 resolveAccessibleRepo,
2c2163eClaude1605};