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