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.tsBlame1649 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";
0e06362ccantynz-alt29import { getBlob, repoExists, resolveRef, getRepoPath, getDefaultBranch, listBranches } 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
0e06362ccantynz-alt167/**
168 * Pick the ref to read from. Git refs are case-sensitive and a repo's default
169 * branch is not always "main" (e.g. Vapron's is "Main"). So:
170 * - no ref requested -> the repo's real default branch (git HEAD)
171 * - requested ref resolves as-is -> use it
172 * - otherwise -> a case-insensitive branch match (main -> Main), else the
173 * default branch, else the original ref (so getBlob reports the honest 404)
174 */
175async function resolveReadRef(
176 owner: string,
177 name: string,
178 requested: string
179): Promise<string> {
180 const def = (await getDefaultBranch(owner, name)) || "main";
181 if (!requested) return def;
182 if (await resolveRef(owner, name, requested)) return requested;
183 const branches = await listBranches(owner, name).catch(() => [] as string[]);
184 const ci = branches.find(
185 (b) => b.toLowerCase() === requested.toLowerCase()
186 );
187 if (ci) return ci;
188 return def;
189}
190
2c2163eClaude191// ---------------------------------------------------------------------------
192// gluecron_repo_search
193// ---------------------------------------------------------------------------
194
195const repoSearch: McpToolHandler = {
196 tool: {
197 name: "gluecron_repo_search",
198 description:
85fc0efccantynz-alt199 "Search Gluecron repositories by keyword (name + description). Returns public repos plus any private repos owned by the authenticated caller. Up to 20 results.",
523ddadccanty labs200 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
2c2163eClaude201 inputSchema: {
202 type: "object",
203 properties: {
204 query: { type: "string", description: "Search keyword (1-100 chars)" },
205 limit: { type: "number", description: "Max results, default 20" },
206 },
207 required: ["query"],
208 },
209 },
85fc0efccantynz-alt210 async run(args, ctx) {
2c2163eClaude211 const q = argString(args, "query");
212 if (q.length > 100) {
213 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
214 }
215 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
216 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
85fc0efccantynz-alt217 // Visible = public, OR private-but-owned-by-the-authenticated-caller.
218 const visibility = ctx.userId
219 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
220 : eq(repositories.isPrivate, false);
2c2163eClaude221 const rows = await db
222 .select({
223 id: repositories.id,
224 name: repositories.name,
225 description: repositories.description,
226 ownerName: users.username,
227 stars: repositories.starCount,
228 })
229 .from(repositories)
230 .innerJoin(users, eq(repositories.ownerId, users.id))
231 .where(
232 and(
85fc0efccantynz-alt233 visibility,
2c2163eClaude234 or(
235 like(repositories.name, pattern),
236 like(repositories.description, pattern)
237 )
238 )
239 )
240 .orderBy(desc(repositories.starCount))
241 .limit(limit);
242 return {
243 total: rows.length,
244 repos: rows.map((r) => ({
245 fullName: `${r.ownerName}/${r.name}`,
246 description: r.description || "",
247 stars: r.stars,
248 })),
249 };
250 },
251};
252
253// ---------------------------------------------------------------------------
254// gluecron_repo_read_file
255// ---------------------------------------------------------------------------
256
257const repoReadFile: McpToolHandler = {
258 tool: {
259 name: "gluecron_repo_read_file",
260 description:
85fc0efccantynz-alt261 "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 labs262 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
2c2163eClaude263 inputSchema: {
264 type: "object",
265 properties: {
266 owner: { type: "string", description: "Repo owner username" },
267 repo: { type: "string", description: "Repo name" },
0e06362ccantynz-alt268 ref: { type: "string", description: "Branch / tag / commit. Defaults to the repository's actual default branch (which may not be 'main' — e.g. 'Main')." },
2c2163eClaude269 path: { type: "string", description: "File path within the repo" },
270 },
271 required: ["owner", "repo", "path"],
272 },
273 },
85fc0efccantynz-alt274 async run(args, ctx) {
2c2163eClaude275 const owner = argString(args, "owner");
276 const repo = argString(args, "repo");
0e06362ccantynz-alt277 const refArg = argString(args, "ref", "");
2c2163eClaude278 const path = argString(args, "path");
279
85fc0efccantynz-alt280 // Readable if public, or private + the authenticated caller has access.
2cc8e18ccantynz-alt281 // Use the CANONICAL owner/name for the on-disk read (case-sensitive FS).
282 const canon = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude283
0e06362ccantynz-alt284 // Resolve the ref the caller actually meant. Git refs are case-sensitive
285 // and a repo's default branch is NOT always "main" (Vapron's is "Main"),
286 // so: (1) no ref given -> use the repo's real default branch from HEAD;
287 // (2) given ref doesn't resolve -> try a case-insensitive branch match
288 // before giving up. This stops "@main" 404ing on a "Main" repo.
289 const ref = await resolveReadRef(canon.owner, canon.name, refArg);
290
2cc8e18ccantynz-alt291 const blob = await getBlob(canon.owner, canon.name, ref, path);
2c2163eClaude292 if (!blob) {
293 throw new McpError(
294 ERR_METHOD_NOT_FOUND,
295 `path not found: ${owner}/${repo}@${ref}:${path}`
296 );
297 }
298 return {
299 content: [
300 {
301 type: "text",
302 text: blob.content,
303 },
304 ],
305 };
306 },
307};
308
309// ---------------------------------------------------------------------------
310// gluecron_repo_list_issues
311// ---------------------------------------------------------------------------
312
313const repoListIssues: McpToolHandler = {
314 tool: {
315 name: "gluecron_repo_list_issues",
316 description:
317 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
523ddadccanty labs318 annotations: { title: "List open issues", readOnlyHint: true, destructiveHint: false },
2c2163eClaude319 inputSchema: {
320 type: "object",
321 properties: {
322 owner: { type: "string", description: "Repo owner username" },
323 repo: { type: "string", description: "Repo name" },
324 limit: { type: "number", description: "Max results, default 25" },
325 },
326 required: ["owner", "repo"],
327 },
328 },
85fc0efccantynz-alt329 async run(args, ctx) {
2c2163eClaude330 const owner = argString(args, "owner");
331 const repo = argString(args, "repo");
332 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
333
85fc0efccantynz-alt334 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude335
336 const rows = await db
337 .select({
338 number: issues.number,
339 title: issues.title,
340 body: issues.body,
341 state: issues.state,
342 createdAt: issues.createdAt,
343 })
344 .from(issues)
345 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
346 .orderBy(desc(issues.createdAt))
347 .limit(limit);
348 return {
349 total: rows.length,
350 issues: rows.map((i) => ({
351 number: i.number,
352 title: i.title,
353 body: i.body || "",
354 state: i.state,
355 createdAt: i.createdAt,
356 })),
357 };
358 },
359};
360
361// ---------------------------------------------------------------------------
362// gluecron_repo_explain_codebase
363// ---------------------------------------------------------------------------
364
365const repoExplain: McpToolHandler = {
366 tool: {
367 name: "gluecron_repo_explain_codebase",
368 description:
369 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
523ddadccanty labs370 annotations: { title: "Explain codebase (cached)", readOnlyHint: true, destructiveHint: false },
2c2163eClaude371 inputSchema: {
372 type: "object",
373 properties: {
374 owner: { type: "string", description: "Repo owner username" },
375 repo: { type: "string", description: "Repo name" },
376 },
377 required: ["owner", "repo"],
378 },
379 },
85fc0efccantynz-alt380 async run(args, ctx) {
2c2163eClaude381 const owner = argString(args, "owner");
382 const repo = argString(args, "repo");
85fc0efccantynz-alt383 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude384 const [row] = await db
385 .select({
386 commitSha: codebaseExplanations.commitSha,
387 markdown: codebaseExplanations.markdown,
2316901Claude388 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude389 })
390 .from(codebaseExplanations)
391 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude392 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude393 .limit(1);
394 if (!row) {
395 return { explanation: null };
396 }
397 return {
398 commitSha: row.commitSha,
2316901Claude399 generatedAt: row.generatedAt,
2c2163eClaude400 markdown: row.markdown,
401 };
402 },
403};
404
f5a18f9Claude405// ---------------------------------------------------------------------------
406// gluecron_repo_health
407// ---------------------------------------------------------------------------
408
409const repoHealth: McpToolHandler = {
410 tool: {
411 name: "gluecron_repo_health",
412 description:
413 "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 labs414 annotations: { title: "Repository health report", readOnlyHint: true, destructiveHint: false },
f5a18f9Claude415 inputSchema: {
416 type: "object",
417 properties: {
418 owner: { type: "string", description: "Repo owner username" },
419 repo: { type: "string", description: "Repo name" },
420 },
421 required: ["owner", "repo"],
422 },
423 },
85fc0efccantynz-alt424 async run(args, ctx) {
f5a18f9Claude425 const owner = argString(args, "owner");
426 const repo = argString(args, "repo");
427
2cc8e18ccantynz-alt428 // Canonical owner/name for the on-disk git ops (case-sensitive FS).
429 const canon = await resolveReadableRepo(owner, repo, ctx);
430 if (!(await repoExists(canon.owner, canon.name))) {
f5a18f9Claude431 throw new McpError(
432 ERR_METHOD_NOT_FOUND,
433 `${owner}/${repo} has no on-disk git data yet`
434 );
435 }
2cc8e18ccantynz-alt436 const report = await computeHealthScore(canon.owner, canon.name);
f5a18f9Claude437 return {
438 score: report.score,
439 grade: report.grade,
440 breakdown: report.breakdown,
441 insights: report.insights,
442 generatedAt: report.generatedAt,
443 };
444 },
445};
446
6551045Claude447// ---------------------------------------------------------------------------
448// Write-surface shared helpers (Block K1)
449// ---------------------------------------------------------------------------
450
451/**
452 * Require an authenticated context — every write tool gates on this first.
453 * Throws -32602 invalid_params with a tool-specific message so the client
454 * surface knows exactly which call needs auth.
455 */
456function requireAuthedCtx(ctx: McpContext, toolName: string): string {
457 if (!ctx.userId) {
458 throw new McpError(
459 ERR_INVALID_PARAMS,
460 `authentication required for ${toolName}`
461 );
462 }
463 return ctx.userId;
464}
465
466/**
467 * Resolve an `owner/repo` pair to its full row, and confirm the caller has
468 * at least `read` access. Privacy: when the repo is missing OR the caller
469 * cannot see it, throw -32601 method_not_found with the same message —
470 * matches the read-tool privacy contract so private-repo existence does
471 * not leak.
472 */
473async function resolveAccessibleRepo(
474 owner: string,
475 repo: string,
476 userId: string | null
477): Promise<{
478 repoId: string;
479 ownerId: string;
480 isPrivate: boolean;
481 defaultBranch: string;
482 access: RepoAccessLevel;
483}> {
484 const [row] = await db
485 .select({
486 id: repositories.id,
487 ownerId: repositories.ownerId,
488 isPrivate: repositories.isPrivate,
489 defaultBranch: repositories.defaultBranch,
490 })
491 .from(repositories)
492 .innerJoin(users, eq(repositories.ownerId, users.id))
493 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
494 .limit(1);
495 if (!row) {
496 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
497 }
498 const access = await resolveRepoAccess({
499 repoId: row.id,
500 userId,
501 isPublic: !row.isPrivate,
502 });
503 if (!satisfiesAccess(access, "read")) {
504 // Hide existence of private repos from non-collaborators.
505 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
506 }
507 return {
508 repoId: row.id,
509 ownerId: row.ownerId,
510 isPrivate: row.isPrivate,
511 defaultBranch: row.defaultBranch,
512 access,
513 };
514}
515
516/**
517 * Combine `requireAuthedCtx` + `resolveAccessibleRepo` + write-access gate.
518 * Used by every write tool. Returns the repo metadata for downstream use.
519 *
520 * Per spec: reject with -32601 method_not_found (not -32603) so the
521 * existence of private repos the caller cannot see is not leaked through
522 * the error code shape.
523 */
524async function gateWriteAccess(
525 args: { owner: string; repo: string },
526 ctx: McpContext,
527 toolName: string
528): Promise<{
529 userId: string;
530 owner: string;
531 repo: string;
532 repoId: string;
533 ownerId: string;
534 isPrivate: boolean;
535 defaultBranch: string;
536}> {
537 const userId = requireAuthedCtx(ctx, toolName);
538 const info = await resolveAccessibleRepo(args.owner, args.repo, userId);
539 if (!satisfiesAccess(info.access, "write")) {
540 throw new McpError(
541 ERR_METHOD_NOT_FOUND,
542 `no write access to ${args.owner}/${args.repo}`
543 );
544 }
545 return {
546 userId,
547 owner: args.owner,
548 repo: args.repo,
549 repoId: info.repoId,
550 ownerId: info.ownerId,
551 isPrivate: info.isPrivate,
552 defaultBranch: info.defaultBranch,
553 };
554}
555
556function prUrl(owner: string, repo: string, number: number): string {
557 return `/${owner}/${repo}/pulls/${number}`;
558}
559function issueUrl(owner: string, repo: string, number: number): string {
560 return `/${owner}/${repo}/issues/${number}`;
561}
562
563async function loadPrByNumber(repoId: string, prNumber: number) {
564 const [pr] = await db
565 .select()
566 .from(pullRequests)
567 .where(
568 and(
569 eq(pullRequests.repositoryId, repoId),
570 eq(pullRequests.number, prNumber)
571 )
572 )
573 .limit(1);
574 return pr ?? null;
575}
576
577async function loadIssueByNumber(repoId: string, issueNumber: number) {
578 const [row] = await db
579 .select()
580 .from(issues)
581 .where(
582 and(
583 eq(issues.repositoryId, repoId),
584 eq(issues.number, issueNumber)
585 )
586 )
587 .limit(1);
588 return row ?? null;
589}
590
591// ---------------------------------------------------------------------------
592// gluecron_create_issue
593// ---------------------------------------------------------------------------
594
595const createIssue: McpToolHandler = {
596 tool: {
597 name: "gluecron_create_issue",
598 description:
599 "Create a new issue on a Gluecron repository. Requires authenticated caller with write access on the target repo. Returns {number, url}.",
523ddadccanty labs600 annotations: { title: "Create issue", readOnlyHint: false, destructiveHint: false },
6551045Claude601 inputSchema: {
602 type: "object",
603 properties: {
604 owner: { type: "string", description: "Repo owner username" },
605 repo: { type: "string", description: "Repo name" },
606 title: { type: "string", description: "Issue title" },
607 body: { type: "string", description: "Issue body (Markdown). Optional." },
608 },
609 required: ["owner", "repo", "title"],
610 },
611 },
612 async run(args, ctx) {
613 const owner = argString(args, "owner");
614 const repo = argString(args, "repo");
615 const title = argString(args, "title");
616 const body = argString(args, "body", "");
617
618 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_issue");
619
620 const [inserted] = await db
621 .insert(issues)
622 .values({
623 repositoryId: gate.repoId,
624 authorId: gate.userId,
625 title,
626 body: body || null,
627 })
628 .returning();
629
630 // Bump issue count (best-effort, mirrors the HTTP route)
631 try {
632 await db
633 .update(repositories)
634 .set({ issueCount: sqlExpr("issue_count + 1") })
635 .where(eq(repositories.id, gate.repoId));
636 } catch {
637 /* non-fatal */
638 }
639
640 await audit({
641 userId: gate.userId,
642 repositoryId: gate.repoId,
643 action: "issue.created",
644 targetType: "issue",
645 targetId: inserted.id,
646 metadata: { source: "mcp", number: inserted.number, title },
647 });
a54e588Test User648 void fireWebhooks(gate.repoId, "issue", { action: "opened", number: inserted.number, title });
6551045Claude649
650 // Notify repo owner if it's not the same user (mirrors typical web flow).
651 if (gate.ownerId !== gate.userId) {
652 notify(gate.ownerId, {
653 kind: "issue_opened",
654 title: `New issue: ${title}`,
655 url: issueUrl(owner, repo, inserted.number),
656 repositoryId: gate.repoId,
a28cedeClaude657 }).catch((err) => {
658 console.warn(
659 `[mcp] issue_opened notify failed for ${owner}/${repo}#${inserted.number}:`,
660 err instanceof Error ? err.message : err
661 );
662 });
6551045Claude663 }
664
665 return {
666 number: inserted.number,
667 url: issueUrl(owner, repo, inserted.number),
668 };
669 },
670};
671
672// ---------------------------------------------------------------------------
673// gluecron_comment_issue
674// ---------------------------------------------------------------------------
675
676const commentIssue: McpToolHandler = {
677 tool: {
678 name: "gluecron_comment_issue",
679 description:
680 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs681 annotations: { title: "Comment on issue", readOnlyHint: false, destructiveHint: false },
6551045Claude682 inputSchema: {
683 type: "object",
684 properties: {
685 owner: { type: "string", description: "Repo owner username" },
686 repo: { type: "string", description: "Repo name" },
687 number: { type: "number", description: "Issue number" },
688 body: { type: "string", description: "Comment body (Markdown)" },
689 },
690 required: ["owner", "repo", "number", "body"],
691 },
692 },
693 async run(args, ctx) {
694 const owner = argString(args, "owner");
695 const repo = argString(args, "repo");
696 const number = argNumber(args, "number");
697 const body = argString(args, "body");
698
699 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
700 const issue = await loadIssueByNumber(gate.repoId, number);
701 if (!issue) {
702 throw new McpError(
703 ERR_METHOD_NOT_FOUND,
704 `issue not found: ${owner}/${repo}#${number}`
705 );
706 }
707
708 const [inserted] = await db
709 .insert(issueComments)
710 .values({
711 issueId: issue.id,
712 authorId: gate.userId,
713 body,
714 })
715 .returning();
716
717 // SSE fanout — best-effort.
718 try {
719 const { publish } = await import("./sse");
720 publish(`repo:${gate.repoId}:issue:${number}`, {
721 event: "issue-comment",
722 data: {
723 issueId: issue.id,
724 commentId: inserted.id,
725 authorId: gate.userId,
726 authorUsername: null,
727 },
728 });
729 } catch {
730 /* SSE best-effort */
731 }
732
733 await audit({
734 userId: gate.userId,
735 repositoryId: gate.repoId,
736 action: "issue.commented",
737 targetType: "issue",
738 targetId: issue.id,
739 metadata: { source: "mcp", number },
740 });
a54e588Test User741 void fireWebhooks(gate.repoId, "issue", { action: "commented", number });
6551045Claude742
743 return { commentId: inserted.id };
744 },
745};
746
747// ---------------------------------------------------------------------------
748// gluecron_close_issue
749// ---------------------------------------------------------------------------
750
751const closeIssue: McpToolHandler = {
752 tool: {
753 name: "gluecron_close_issue",
754 description:
755 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
523ddadccanty labs756 annotations: { title: "Close issue", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude757 inputSchema: {
758 type: "object",
759 properties: {
760 owner: { type: "string", description: "Repo owner username" },
761 repo: { type: "string", description: "Repo name" },
762 number: { type: "number", description: "Issue number" },
763 },
764 required: ["owner", "repo", "number"],
765 },
766 },
767 async run(args, ctx) {
768 const owner = argString(args, "owner");
769 const repo = argString(args, "repo");
770 const number = argNumber(args, "number");
771
772 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
773 const issue = await loadIssueByNumber(gate.repoId, number);
774 if (!issue) {
775 throw new McpError(
776 ERR_METHOD_NOT_FOUND,
777 `issue not found: ${owner}/${repo}#${number}`
778 );
779 }
780
781 if (issue.state !== "closed") {
782 await db
783 .update(issues)
784 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
785 .where(eq(issues.id, issue.id));
786
787 await audit({
788 userId: gate.userId,
789 repositoryId: gate.repoId,
790 action: "issue.closed",
791 targetType: "issue",
792 targetId: issue.id,
793 metadata: { source: "mcp", number },
794 });
a54e588Test User795 void fireWebhooks(gate.repoId, "issue", { action: "closed", number });
6551045Claude796 }
797
798 return { state: "closed" };
799 },
800};
801
802// ---------------------------------------------------------------------------
803// gluecron_reopen_issue
804// ---------------------------------------------------------------------------
805
806const reopenIssue: McpToolHandler = {
807 tool: {
808 name: "gluecron_reopen_issue",
809 description:
810 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs811 // Reopen merely undoes a close (trivially reversible) — not destructive.
812 annotations: { title: "Reopen issue", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
6551045Claude813 inputSchema: {
814 type: "object",
815 properties: {
816 owner: { type: "string", description: "Repo owner username" },
817 repo: { type: "string", description: "Repo name" },
818 number: { type: "number", description: "Issue number" },
819 },
820 required: ["owner", "repo", "number"],
821 },
822 },
823 async run(args, ctx) {
824 const owner = argString(args, "owner");
825 const repo = argString(args, "repo");
826 const number = argNumber(args, "number");
827
828 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
829 const issue = await loadIssueByNumber(gate.repoId, number);
830 if (!issue) {
831 throw new McpError(
832 ERR_METHOD_NOT_FOUND,
833 `issue not found: ${owner}/${repo}#${number}`
834 );
835 }
836
837 if (issue.state !== "open") {
838 await db
839 .update(issues)
840 .set({ state: "open", closedAt: null, updatedAt: new Date() })
841 .where(eq(issues.id, issue.id));
842
843 await audit({
844 userId: gate.userId,
845 repositoryId: gate.repoId,
846 action: "issue.reopened",
847 targetType: "issue",
848 targetId: issue.id,
849 metadata: { source: "mcp", number },
850 });
a54e588Test User851 void fireWebhooks(gate.repoId, "issue", { action: "reopened", number });
6551045Claude852 }
853
854 return { state: "open" };
855 },
856};
857
858// ---------------------------------------------------------------------------
859// gluecron_create_pr
860// ---------------------------------------------------------------------------
861
862const createPr: McpToolHandler = {
863 tool: {
864 name: "gluecron_create_pr",
865 description:
866 "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 labs867 annotations: { title: "Create pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude868 inputSchema: {
869 type: "object",
870 properties: {
871 owner: { type: "string", description: "Repo owner username" },
872 repo: { type: "string", description: "Repo name" },
873 title: { type: "string", description: "PR title" },
874 body: { type: "string", description: "PR body (Markdown). Optional." },
875 head_branch: { type: "string", description: "Branch with the changes" },
876 base_branch: {
877 type: "string",
878 description: "Target branch (default: repo default branch)",
879 },
880 },
881 required: ["owner", "repo", "title", "head_branch"],
882 },
883 },
884 async run(args, ctx) {
885 const owner = argString(args, "owner");
886 const repo = argString(args, "repo");
887 const title = argString(args, "title");
888 const body = argString(args, "body", "");
889 const headBranch = argString(args, "head_branch");
890
891 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
892 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
893
894 if (baseBranch === headBranch) {
895 throw new McpError(
896 ERR_INVALID_PARAMS,
897 "base and head branches must be different"
898 );
899 }
900
901 const [pr] = await db
902 .insert(pullRequests)
903 .values({
904 repositoryId: gate.repoId,
905 authorId: gate.userId,
906 title,
907 body: body || null,
908 baseBranch,
909 headBranch,
910 })
911 .returning();
912
913 await audit({
914 userId: gate.userId,
915 repositoryId: gate.repoId,
916 action: "pr.opened",
917 targetType: "pull_request",
918 targetId: pr.id,
919 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
920 });
921
b7b5f75ccanty labs922 void logActivity({
923 repositoryId: gate.repoId,
924 userId: gate.userId,
925 action: "pr_open",
926 targetType: "pull_request",
927 targetId: String(pr.number),
928 metadata: { source: "mcp", baseBranch, headBranch },
929 });
a54e588Test User930 void fireWebhooks(gate.repoId, "pr", { action: "opened", number: pr.number, baseBranch, headBranch });
b7b5f75ccanty labs931
79a52c8ccantynz-alt932 // PRs opened through the MCP write surface (exactly how an external AI
933 // platform or the agent-journey certification drives this) never
934 // triggered AI review or triage -- both were wired into the web
935 // /pulls/new route only. Mirror that here so MCP-created PRs get the
936 // same treatment as web-created ones.
937 if (isAiReviewEnabled()) {
938 triggerAiReview(owner, repo, pr.id, title, body || "", baseBranch, headBranch).catch(
939 (err) => console.error("[mcp] triggerAiReview failed:", err)
940 );
941 }
942 triggerPrTriage({
943 ownerName: owner,
944 repoName: repo,
945 repositoryId: gate.repoId,
946 prId: pr.id,
947 prAuthorId: gate.userId,
948 title,
949 body: body || "",
950 baseBranch,
951 headBranch,
952 }).catch((err) => console.error("[mcp] triggerPrTriage failed:", err));
953
6551045Claude954 if (gate.ownerId !== gate.userId) {
955 notify(gate.ownerId, {
956 kind: "pr_opened",
957 title: `New PR: ${title}`,
958 url: prUrl(owner, repo, pr.number),
959 repositoryId: gate.repoId,
a28cedeClaude960 }).catch((err) => {
961 console.warn(
962 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
963 err instanceof Error ? err.message : err
964 );
965 });
6551045Claude966 }
967
968 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
969 },
970};
971
972// ---------------------------------------------------------------------------
973// gluecron_get_pr
974// ---------------------------------------------------------------------------
975
976const getPr: McpToolHandler = {
977 tool: {
978 name: "gluecron_get_pr",
979 description:
980 "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 labs981 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude982 inputSchema: {
983 type: "object",
984 properties: {
985 owner: { type: "string", description: "Repo owner username" },
986 repo: { type: "string", description: "Repo name" },
987 number: { type: "number", description: "PR number" },
988 },
989 required: ["owner", "repo", "number"],
990 },
991 },
992 async run(args, ctx) {
993 const owner = argString(args, "owner");
994 const repo = argString(args, "repo");
995 const number = argNumber(args, "number");
996
997 requireAuthedCtx(ctx, "gluecron_get_pr");
998 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
999
1000 const pr = await loadPrByNumber(info.repoId, number);
1001 if (!pr) {
1002 throw new McpError(
1003 ERR_METHOD_NOT_FOUND,
1004 `pr not found: ${owner}/${repo}#${number}`
1005 );
1006 }
1007
1008 const [author] = await db
1009 .select({ username: users.username })
1010 .from(users)
1011 .where(eq(users.id, pr.authorId))
1012 .limit(1);
1013
1014 // Best-effort mergeability hint via a quick head-ref resolve.
1015 let mergeable: boolean | null = null;
1016 try {
1017 const headSha = await resolveRef(owner, repo, pr.headBranch);
1018 mergeable = headSha ? true : null;
1019 } catch {
1020 mergeable = null;
1021 }
1022
1023 return {
1024 number: pr.number,
1025 title: pr.title,
1026 body: pr.body || "",
1027 state: pr.state,
1028 baseBranch: pr.baseBranch,
1029 headBranch: pr.headBranch,
1030 isDraft: pr.isDraft,
1031 mergeable,
1032 author: author?.username ?? null,
1033 createdAt: pr.createdAt,
1034 updatedAt: pr.updatedAt,
1035 mergedAt: pr.mergedAt,
1036 closedAt: pr.closedAt,
1037 url: prUrl(owner, repo, pr.number),
1038 };
1039 },
1040};
1041
1042// ---------------------------------------------------------------------------
1043// gluecron_list_prs
1044// ---------------------------------------------------------------------------
1045
1046const listPrs: McpToolHandler = {
1047 tool: {
1048 name: "gluecron_list_prs",
1049 description:
1050 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs1051 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude1052 inputSchema: {
1053 type: "object",
1054 properties: {
1055 owner: { type: "string", description: "Repo owner username" },
1056 repo: { type: "string", description: "Repo name" },
1057 state: {
1058 type: "string",
1059 description: "open | closed | merged | all (default: open)",
1060 },
1061 },
1062 required: ["owner", "repo"],
1063 },
1064 },
1065 async run(args, ctx) {
1066 const owner = argString(args, "owner");
1067 const repo = argString(args, "repo");
1068 const state = argString(args, "state", "open");
1069 if (!["open", "closed", "merged", "all"].includes(state)) {
1070 throw new McpError(
1071 ERR_INVALID_PARAMS,
1072 `state must be one of open|closed|merged|all (got "${state}")`
1073 );
1074 }
1075
1076 requireAuthedCtx(ctx, "gluecron_list_prs");
1077 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1078
1079 const whereClause =
1080 state === "all"
1081 ? eq(pullRequests.repositoryId, info.repoId)
1082 : and(
1083 eq(pullRequests.repositoryId, info.repoId),
1084 eq(pullRequests.state, state)
1085 );
1086
1087 const rows = await db
1088 .select({
1089 number: pullRequests.number,
1090 title: pullRequests.title,
1091 state: pullRequests.state,
1092 baseBranch: pullRequests.baseBranch,
1093 headBranch: pullRequests.headBranch,
1094 isDraft: pullRequests.isDraft,
1095 authorUsername: users.username,
1096 createdAt: pullRequests.createdAt,
1097 updatedAt: pullRequests.updatedAt,
1098 })
1099 .from(pullRequests)
1100 .innerJoin(users, eq(pullRequests.authorId, users.id))
1101 .where(whereClause)
1102 .orderBy(desc(pullRequests.createdAt))
1103 .limit(50);
1104
1105 return {
1106 total: rows.length,
1107 prs: rows.map((p) => ({
1108 number: p.number,
1109 title: p.title,
1110 state: p.state,
1111 baseBranch: p.baseBranch,
1112 headBranch: p.headBranch,
1113 isDraft: p.isDraft,
1114 author: p.authorUsername,
1115 createdAt: p.createdAt,
1116 updatedAt: p.updatedAt,
1117 url: prUrl(owner, repo, p.number),
1118 })),
1119 };
1120 },
1121};
1122
1123// ---------------------------------------------------------------------------
1124// gluecron_comment_pr
1125// ---------------------------------------------------------------------------
1126
1127const commentPr: McpToolHandler = {
1128 tool: {
1129 name: "gluecron_comment_pr",
1130 description:
1131 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1132 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1133 inputSchema: {
1134 type: "object",
1135 properties: {
1136 owner: { type: "string", description: "Repo owner username" },
1137 repo: { type: "string", description: "Repo name" },
1138 number: { type: "number", description: "PR number" },
1139 body: { type: "string", description: "Comment body (Markdown)" },
1140 },
1141 required: ["owner", "repo", "number", "body"],
1142 },
1143 },
1144 async run(args, ctx) {
1145 const owner = argString(args, "owner");
1146 const repo = argString(args, "repo");
1147 const number = argNumber(args, "number");
1148 const body = argString(args, "body");
1149
1150 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1151 const pr = await loadPrByNumber(gate.repoId, number);
1152 if (!pr) {
1153 throw new McpError(
1154 ERR_METHOD_NOT_FOUND,
1155 `pr not found: ${owner}/${repo}#${number}`
1156 );
1157 }
1158
1159 const [inserted] = await db
1160 .insert(prComments)
1161 .values({
1162 pullRequestId: pr.id,
1163 authorId: gate.userId,
1164 body,
1165 })
1166 .returning();
1167
1168 try {
1169 const { publish } = await import("./sse");
1170 publish(`repo:${gate.repoId}:pr:${number}`, {
1171 event: "pr-comment",
1172 data: {
1173 pullRequestId: pr.id,
1174 commentId: inserted.id,
1175 authorId: gate.userId,
1176 authorUsername: null,
1177 },
1178 });
1179 } catch {
1180 /* SSE best-effort */
1181 }
1182
1183 await audit({
1184 userId: gate.userId,
1185 repositoryId: gate.repoId,
1186 action: "pr.commented",
1187 targetType: "pull_request",
1188 targetId: pr.id,
1189 metadata: { source: "mcp", number },
1190 });
1191
b7b5f75ccanty labs1192 void logActivity({
1193 repositoryId: gate.repoId,
1194 userId: gate.userId,
1195 action: "comment",
1196 targetType: "pull_request",
1197 targetId: String(number),
1198 metadata: { source: "mcp", commentId: inserted.id },
1199 });
a54e588Test User1200 void fireWebhooks(gate.repoId, "pr", { action: "commented", number });
b7b5f75ccanty labs1201
6551045Claude1202 return { commentId: inserted.id };
1203 },
1204};
1205
1206// ---------------------------------------------------------------------------
1207// gluecron_merge_pr
1208// ---------------------------------------------------------------------------
1209
1210const mergePr: McpToolHandler = {
1211 tool: {
1212 name: "gluecron_merge_pr",
1213 description:
534f04aClaude1214 "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 labs1215 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1216 inputSchema: {
1217 type: "object",
1218 properties: {
1219 owner: { type: "string", description: "Repo owner username" },
1220 repo: { type: "string", description: "Repo name" },
1221 number: { type: "number", description: "PR number" },
534f04aClaude1222 confirm_high_risk: {
1223 type: "boolean",
1224 description:
1225 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1226 },
6551045Claude1227 },
1228 required: ["owner", "repo", "number"],
1229 },
1230 },
1231 async run(args, ctx) {
1232 const owner = argString(args, "owner");
1233 const repo = argString(args, "repo");
1234 const number = argNumber(args, "number");
534f04aClaude1235 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1236
1237 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1238 const pr = await loadPrByNumber(gate.repoId, number);
1239 if (!pr) {
1240 throw new McpError(
1241 ERR_METHOD_NOT_FOUND,
1242 `pr not found: ${owner}/${repo}#${number}`
1243 );
1244 }
1245 if (pr.state !== "open") {
1246 return { merged: false, reason: `pr is ${pr.state}, not open` };
1247 }
1248 if (pr.isDraft) {
1249 return {
1250 merged: false,
1251 reason: "This PR is a draft. Mark it as ready for review before merging.",
1252 };
1253 }
1254
534f04aClaude1255 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1256 // fall back to most-recent cached row; finally compute on demand so the
1257 // MCP caller always gets a score (HTTP path is async + tolerant of a
1258 // missing score, but MCP callers want an answer in one round trip).
1259 let risk: PrRiskScore | null = null;
1260 try {
1261 risk =
1262 (await getCachedPrRisk(pr.id)) ||
1263 (await getLatestCachedPrRisk(pr.id)) ||
1264 (await computePrRiskForPullRequest(pr.id));
1265 } catch {
1266 risk = null;
1267 }
1268
1269 if (risk && risk.band === "critical" && !confirmHighRisk) {
1270 return {
1271 merged: false,
1272 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1273 riskScore: serialisePrRiskForResponse(risk),
1274 };
1275 }
1276
6551045Claude1277 const headSha = await resolveRef(owner, repo, pr.headBranch);
1278 if (!headSha) {
1279 return { merged: false, reason: "Head branch not found" };
1280 }
1281
c166384ccantynz-alt1282 const aiApproved = await isAiReviewApproved(pr.id);
6551045Claude1283
1284 const gateResult = await runAllGateChecks(
1285 owner,
1286 repo,
1287 pr.baseBranch,
1288 pr.headBranch,
1289 headSha,
1290 aiApproved
1291 );
1292
1293 const hardFailures = gateResult.checks.filter(
1294 (check) => !check.passed && check.name !== "Merge check"
1295 );
1296 if (hardFailures.length > 0) {
1297 return {
1298 merged: false,
1299 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1300 };
1301 }
1302
1303 // D5 — branch-protection enforcement
1304 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1305 if (protectionRule) {
1306 const humanApprovals = await countHumanApprovals(pr.id);
1307 const required = await listRequiredChecks(protectionRule.id);
1308 const passingNames =
1309 required.length > 0
1310 ? await passingCheckNames(gate.repoId, headSha)
1311 : [];
1312 const decision = evaluateProtection(
1313 protectionRule,
1314 {
1315 aiApproved,
1316 humanApprovalCount: humanApprovals,
1317 gateResultGreen: hardFailures.length === 0,
1318 hasFailedGates: hardFailures.length > 0,
1319 passingCheckNames: passingNames,
1320 },
1321 required.map((r) => r.checkName)
1322 );
91b054eccanty labs1323
1324 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1325 // when the rule already requires human review at all (no new DB
1326 // column for this pass). Fail-open on any internal error: a bug here
1327 // must never hard-block every merge platform-wide. Mirrors the HTTP
1328 // merge path in routes/pulls.tsx.
1329 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1330 try {
1331 const codeownersDiffProc = Bun.spawn(
1332 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1333 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1334 );
1335 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1336 await codeownersDiffProc.exited;
1337 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1338 if (changedPaths.length > 0) {
1339 const { satisfied, missingOwners } = await requiredOwnersApproved(
1340 owner,
1341 repo,
1342 gate.defaultBranch,
1343 pr.id,
1344 changedPaths
1345 );
1346 if (!satisfied) {
1347 decision.allowed = false;
1348 decision.reasons.push(
1349 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1350 );
1351 }
1352 }
1353 } catch (err) {
1354 console.warn(
1355 "[codeowners] merge enforcement failed:",
1356 err instanceof Error ? err.message : err
1357 );
1358 }
1359 }
1360
6551045Claude1361 if (!decision.allowed) {
1362 return { merged: false, reason: decision.reasons.join(" ") };
1363 }
1364 }
1365
1366 const repoDir = getRepoPath(owner, repo);
1367 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1368 const hasConflicts = mergeCheck && !mergeCheck.passed;
1369
1370 if (hasConflicts && isAiReviewEnabled()) {
1371 const mergeResult = await mergeWithAutoResolve(
1372 owner,
1373 repo,
1374 pr.baseBranch,
1375 pr.headBranch,
1376 `Merge pull request #${pr.number}: ${pr.title}`
1377 );
1378 if (!mergeResult.success) {
1379 return {
1380 merged: false,
1381 reason: mergeResult.error || "Auto-merge failed",
1382 };
1383 }
1384 if (mergeResult.resolvedFiles.length > 0) {
1385 await db.insert(prComments).values({
1386 pullRequestId: pr.id,
1387 authorId: gate.userId,
1388 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1389 .map((f) => `- \`${f}\``)
1390 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1391 isAiReview: true,
1392 });
1393 }
1394 } else {
1395 const ffProc = Bun.spawn(
1396 [
1397 "git",
1398 "update-ref",
1399 `refs/heads/${pr.baseBranch}`,
1400 `refs/heads/${pr.headBranch}`,
1401 ],
1402 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1403 );
1404 const ffExit = await ffProc.exited;
1405 if (ffExit !== 0) {
1406 return {
1407 merged: false,
1408 reason: "Merge failed — unable to update branch ref",
1409 };
1410 }
1411 }
1412
1413 await db
1414 .update(pullRequests)
1415 .set({
1416 state: "merged",
1417 mergedAt: new Date(),
1418 mergedBy: gate.userId,
1419 updatedAt: new Date(),
1420 })
1421 .where(eq(pullRequests.id, pr.id));
1422
1423 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1424 try {
1425 const { extractClosingRefsMulti } = await import("./close-keywords");
1426 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1427 for (const n of refs) {
1428 const target = await loadIssueByNumber(gate.repoId, n);
1429 if (!target || target.state !== "open") continue;
1430 await db
1431 .update(issues)
1432 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1433 .where(eq(issues.id, target.id));
1434 await db.insert(issueComments).values({
1435 issueId: target.id,
1436 authorId: gate.userId,
1437 body: `Closed by pull request #${pr.number}.`,
1438 });
1439 }
1440 } catch {
1441 /* never block merge on close-keyword failures */
1442 }
1443
1444 await audit({
1445 userId: gate.userId,
1446 repositoryId: gate.repoId,
1447 action: "pr.merged",
1448 targetType: "pull_request",
1449 targetId: pr.id,
1450 metadata: { source: "mcp", number, sha: headSha },
1451 });
1452
b7b5f75ccanty labs1453 void logActivity({
1454 repositoryId: gate.repoId,
1455 userId: gate.userId,
1456 action: "pr_merge",
1457 targetType: "pull_request",
1458 targetId: String(number),
1459 metadata: { source: "mcp", sha: headSha },
1460 });
a54e588Test User1461 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1462
6551045Claude1463 // Resolved post-merge SHA (best effort).
1464 let mergedSha: string | null = null;
1465 try {
1466 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1467 } catch {
1468 mergedSha = null;
1469 }
1470
534f04aClaude1471 // Block M3 — informational payload: when the risk score is high or
1472 // critical, include the score + summary in the response even on a
1473 // successful merge so the caller has the audit context. Low/medium
1474 // bands stay quiet to keep response noise low.
1475 const response: {
1476 merged: true;
1477 sha: string;
1478 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1479 } = {
6551045Claude1480 merged: true,
1481 sha: mergedSha ?? headSha,
1482 };
534f04aClaude1483 if (risk && (risk.band === "high" || risk.band === "critical")) {
1484 response.riskScore = serialisePrRiskForResponse(risk);
1485 }
1486 return response;
6551045Claude1487 },
1488};
1489
534f04aClaude1490/**
1491 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1492 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1493 */
1494function serialisePrRiskForResponse(risk: PrRiskScore) {
1495 return {
1496 score: risk.score,
1497 band: risk.band,
1498 aiSummary: risk.aiSummary,
1499 commitSha: risk.commitSha,
1500 signals: risk.signals,
1501 generatedAt:
1502 risk.generatedAt instanceof Date
1503 ? risk.generatedAt.toISOString()
1504 : String(risk.generatedAt),
1505 };
1506}
1507
6551045Claude1508// ---------------------------------------------------------------------------
1509// gluecron_close_pr
1510// ---------------------------------------------------------------------------
1511
1512const closePr: McpToolHandler = {
1513 tool: {
1514 name: "gluecron_close_pr",
1515 description:
1516 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1517 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1518 inputSchema: {
1519 type: "object",
1520 properties: {
1521 owner: { type: "string", description: "Repo owner username" },
1522 repo: { type: "string", description: "Repo name" },
1523 number: { type: "number", description: "PR number" },
1524 },
1525 required: ["owner", "repo", "number"],
1526 },
1527 },
1528 async run(args, ctx) {
1529 const owner = argString(args, "owner");
1530 const repo = argString(args, "repo");
1531 const number = argNumber(args, "number");
1532
1533 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1534 const pr = await loadPrByNumber(gate.repoId, number);
1535 if (!pr) {
1536 throw new McpError(
1537 ERR_METHOD_NOT_FOUND,
1538 `pr not found: ${owner}/${repo}#${number}`
1539 );
1540 }
1541
1542 if (pr.state === "open") {
1543 await db
1544 .update(pullRequests)
1545 .set({
1546 state: "closed",
1547 closedAt: new Date(),
1548 updatedAt: new Date(),
1549 })
1550 .where(eq(pullRequests.id, pr.id));
1551
1552 await audit({
1553 userId: gate.userId,
1554 repositoryId: gate.repoId,
1555 action: "pr.closed",
1556 targetType: "pull_request",
1557 targetId: pr.id,
1558 metadata: { source: "mcp", number },
1559 });
a54e588Test User1560 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1561 }
1562
1563 return { state: pr.state === "merged" ? "merged" : "closed" };
1564 },
1565};
1566
1567function sqlExpr(expr: string) {
1568 return drizzleSql.raw(expr);
1569}
1570
2c2163eClaude1571// ---------------------------------------------------------------------------
1572// Default tool registry
1573// ---------------------------------------------------------------------------
1574
1575export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1576 // The original 15-tool surface (read + K1 write) remains the source of
1577 // truth for the core gate-enforced workflows. The expanded set in
1578 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1579 // adapter over an existing lib helper — so AI agents can drive
1580 // Gluecron end-to-end.
1581 //
1582 // The expanded module imports the `mcp*` helpers re-exported above —
1583 // since those are `const` exports they're populated by the time
1584 // `defaultTools()` is called at request time, so the circular ESM
1585 // import resolves cleanly.
2c2163eClaude1586 return {
1587 [repoSearch.tool.name]: repoSearch,
1588 [repoReadFile.tool.name]: repoReadFile,
1589 [repoListIssues.tool.name]: repoListIssues,
1590 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1591 [repoHealth.tool.name]: repoHealth,
6551045Claude1592 // Block K1 — write surface
1593 [createIssue.tool.name]: createIssue,
1594 [commentIssue.tool.name]: commentIssue,
1595 [closeIssue.tool.name]: closeIssue,
1596 [reopenIssue.tool.name]: reopenIssue,
1597 [createPr.tool.name]: createPr,
1598 [getPr.tool.name]: getPr,
1599 [listPrs.tool.name]: listPrs,
1600 [commentPr.tool.name]: commentPr,
1601 [mergePr.tool.name]: mergePr,
1602 [closePr.tool.name]: closePr,
0feb720Claude1603 // Expanded surface (50+ tools total)
1604 ...expandedTools(),
2c2163eClaude1605 };
1606}
1607
0feb720Claude1608// ---------------------------------------------------------------------------
1609// Shared helpers (re-exported so the expanded tool surface in
1610// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1611// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1612// them keeps the wrapper modules tiny.
1613// ---------------------------------------------------------------------------
1614
1615export {
1616 argString as mcpArgString,
1617 argNumber as mcpArgNumber,
1618 gateWriteAccess as mcpGateWriteAccess,
1619 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1620 requireAuthedCtx as mcpRequireAuthedCtx,
1621 loadPrByNumber as mcpLoadPrByNumber,
1622 loadIssueByNumber as mcpLoadIssueByNumber,
1623 prUrl as mcpPrUrl,
1624 issueUrl as mcpIssueUrl,
1625};
1626
2c2163eClaude1627/** Test-only export of internal helpers + per-tool handlers. */
1628export const __test = {
1629 argString,
1630 argNumber,
1631 repoSearch,
1632 repoReadFile,
1633 repoListIssues,
1634 repoExplain,
f5a18f9Claude1635 repoHealth,
6551045Claude1636 // Block K1 — write surface
1637 createIssue,
1638 commentIssue,
1639 closeIssue,
1640 reopenIssue,
1641 createPr,
1642 getPr,
1643 listPrs,
1644 commentPr,
1645 mergePr,
1646 closePr,
1647 gateWriteAccess,
1648 resolveAccessibleRepo,
2c2163eClaude1649};