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.tsBlame1721 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
3a15219ccantynz-alt191// ---------------------------------------------------------------------------
192// MCP resources — makes repos BROWSABLE in clients (Claude Desktop shows
193// resources in its attachment/resource picker). Without these, the server
194// only exposes tools, so a user sees nothing to pick and thinks the repo
195// "isn't there". resources/list returns the caller's repos (public + their
196// own private); resources/read returns a repo's README.
197// ---------------------------------------------------------------------------
198
199export type McpResource = {
200 uri: string;
201 name: string;
202 description?: string;
203 mimeType?: string;
204};
205
206export async function mcpListResources(
207 ctx: McpContext
208): Promise<{ resources: McpResource[] }> {
209 const visibility = ctx.userId
210 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
211 : eq(repositories.isPrivate, false);
212 const rows = await db
213 .select({
214 name: repositories.name,
215 description: repositories.description,
216 owner: users.username,
217 isPrivate: repositories.isPrivate,
218 })
219 .from(repositories)
220 .innerJoin(users, eq(repositories.ownerId, users.id))
221 .where(visibility)
222 .orderBy(desc(repositories.starCount), asc(repositories.name))
223 .limit(100);
224 return {
225 resources: rows.map((r) => ({
226 uri: `gluecron://repo/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`,
227 name: `${r.owner}/${r.name}`,
228 description:
229 (r.isPrivate ? "[private] " : "") +
230 (r.description || "Gluecron repository — README"),
231 mimeType: "text/markdown",
232 })),
233 };
234}
235
236export async function mcpReadResource(
237 uri: string,
238 ctx: McpContext
239): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> {
240 const m = /^gluecron:\/\/repo\/([^/]+)\/(.+)$/.exec(uri || "");
241 if (!m) {
242 throw new McpError(ERR_INVALID_PARAMS, `unsupported resource uri: ${uri}`);
243 }
244 const owner = decodeURIComponent(m[1]);
245 const repo = decodeURIComponent(m[2]);
246 // Access-checked + canonical casing (same gate the read tools use).
247 const canon = await resolveReadableRepo(owner, repo, ctx);
248 const ref = await resolveReadRef(canon.owner, canon.name, "");
249 let text = "";
250 for (const p of ["README.md", "readme.md", "README", "README.markdown", "readme"]) {
251 const blob = await getBlob(canon.owner, canon.name, ref, p);
252 if (blob) {
253 text = blob.content;
254 break;
255 }
256 }
257 if (!text) {
258 text = `# ${canon.owner}/${canon.name}\n\n_(No README found in this repository.)_`;
259 }
260 return { contents: [{ uri, mimeType: "text/markdown", text }] };
261}
262
2c2163eClaude263// ---------------------------------------------------------------------------
264// gluecron_repo_search
265// ---------------------------------------------------------------------------
266
267const repoSearch: McpToolHandler = {
268 tool: {
269 name: "gluecron_repo_search",
270 description:
85fc0efccantynz-alt271 "Search Gluecron repositories by keyword (name + description). Returns public repos plus any private repos owned by the authenticated caller. Up to 20 results.",
523ddadccanty labs272 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
2c2163eClaude273 inputSchema: {
274 type: "object",
275 properties: {
276 query: { type: "string", description: "Search keyword (1-100 chars)" },
277 limit: { type: "number", description: "Max results, default 20" },
278 },
279 required: ["query"],
280 },
281 },
85fc0efccantynz-alt282 async run(args, ctx) {
2c2163eClaude283 const q = argString(args, "query");
284 if (q.length > 100) {
285 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
286 }
287 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
288 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
85fc0efccantynz-alt289 // Visible = public, OR private-but-owned-by-the-authenticated-caller.
290 const visibility = ctx.userId
291 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
292 : eq(repositories.isPrivate, false);
2c2163eClaude293 const rows = await db
294 .select({
295 id: repositories.id,
296 name: repositories.name,
297 description: repositories.description,
298 ownerName: users.username,
299 stars: repositories.starCount,
300 })
301 .from(repositories)
302 .innerJoin(users, eq(repositories.ownerId, users.id))
303 .where(
304 and(
85fc0efccantynz-alt305 visibility,
2c2163eClaude306 or(
307 like(repositories.name, pattern),
308 like(repositories.description, pattern)
309 )
310 )
311 )
312 .orderBy(desc(repositories.starCount))
313 .limit(limit);
314 return {
315 total: rows.length,
316 repos: rows.map((r) => ({
317 fullName: `${r.ownerName}/${r.name}`,
318 description: r.description || "",
319 stars: r.stars,
320 })),
321 };
322 },
323};
324
325// ---------------------------------------------------------------------------
326// gluecron_repo_read_file
327// ---------------------------------------------------------------------------
328
329const repoReadFile: McpToolHandler = {
330 tool: {
331 name: "gluecron_repo_read_file",
332 description:
85fc0efccantynz-alt333 "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 labs334 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
2c2163eClaude335 inputSchema: {
336 type: "object",
337 properties: {
338 owner: { type: "string", description: "Repo owner username" },
339 repo: { type: "string", description: "Repo name" },
0e06362ccantynz-alt340 ref: { type: "string", description: "Branch / tag / commit. Defaults to the repository's actual default branch (which may not be 'main' — e.g. 'Main')." },
2c2163eClaude341 path: { type: "string", description: "File path within the repo" },
342 },
343 required: ["owner", "repo", "path"],
344 },
345 },
85fc0efccantynz-alt346 async run(args, ctx) {
2c2163eClaude347 const owner = argString(args, "owner");
348 const repo = argString(args, "repo");
0e06362ccantynz-alt349 const refArg = argString(args, "ref", "");
2c2163eClaude350 const path = argString(args, "path");
351
85fc0efccantynz-alt352 // Readable if public, or private + the authenticated caller has access.
2cc8e18ccantynz-alt353 // Use the CANONICAL owner/name for the on-disk read (case-sensitive FS).
354 const canon = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude355
0e06362ccantynz-alt356 // Resolve the ref the caller actually meant. Git refs are case-sensitive
357 // and a repo's default branch is NOT always "main" (Vapron's is "Main"),
358 // so: (1) no ref given -> use the repo's real default branch from HEAD;
359 // (2) given ref doesn't resolve -> try a case-insensitive branch match
360 // before giving up. This stops "@main" 404ing on a "Main" repo.
361 const ref = await resolveReadRef(canon.owner, canon.name, refArg);
362
2cc8e18ccantynz-alt363 const blob = await getBlob(canon.owner, canon.name, ref, path);
2c2163eClaude364 if (!blob) {
365 throw new McpError(
366 ERR_METHOD_NOT_FOUND,
367 `path not found: ${owner}/${repo}@${ref}:${path}`
368 );
369 }
370 return {
371 content: [
372 {
373 type: "text",
374 text: blob.content,
375 },
376 ],
377 };
378 },
379};
380
381// ---------------------------------------------------------------------------
382// gluecron_repo_list_issues
383// ---------------------------------------------------------------------------
384
385const repoListIssues: McpToolHandler = {
386 tool: {
387 name: "gluecron_repo_list_issues",
388 description:
389 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
523ddadccanty labs390 annotations: { title: "List open issues", readOnlyHint: true, destructiveHint: false },
2c2163eClaude391 inputSchema: {
392 type: "object",
393 properties: {
394 owner: { type: "string", description: "Repo owner username" },
395 repo: { type: "string", description: "Repo name" },
396 limit: { type: "number", description: "Max results, default 25" },
397 },
398 required: ["owner", "repo"],
399 },
400 },
85fc0efccantynz-alt401 async run(args, ctx) {
2c2163eClaude402 const owner = argString(args, "owner");
403 const repo = argString(args, "repo");
404 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
405
85fc0efccantynz-alt406 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude407
408 const rows = await db
409 .select({
410 number: issues.number,
411 title: issues.title,
412 body: issues.body,
413 state: issues.state,
414 createdAt: issues.createdAt,
415 })
416 .from(issues)
417 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
418 .orderBy(desc(issues.createdAt))
419 .limit(limit);
420 return {
421 total: rows.length,
422 issues: rows.map((i) => ({
423 number: i.number,
424 title: i.title,
425 body: i.body || "",
426 state: i.state,
427 createdAt: i.createdAt,
428 })),
429 };
430 },
431};
432
433// ---------------------------------------------------------------------------
434// gluecron_repo_explain_codebase
435// ---------------------------------------------------------------------------
436
437const repoExplain: McpToolHandler = {
438 tool: {
439 name: "gluecron_repo_explain_codebase",
440 description:
441 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
523ddadccanty labs442 annotations: { title: "Explain codebase (cached)", readOnlyHint: true, destructiveHint: false },
2c2163eClaude443 inputSchema: {
444 type: "object",
445 properties: {
446 owner: { type: "string", description: "Repo owner username" },
447 repo: { type: "string", description: "Repo name" },
448 },
449 required: ["owner", "repo"],
450 },
451 },
85fc0efccantynz-alt452 async run(args, ctx) {
2c2163eClaude453 const owner = argString(args, "owner");
454 const repo = argString(args, "repo");
85fc0efccantynz-alt455 const r = await resolveReadableRepo(owner, repo, ctx);
2c2163eClaude456 const [row] = await db
457 .select({
458 commitSha: codebaseExplanations.commitSha,
459 markdown: codebaseExplanations.markdown,
2316901Claude460 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude461 })
462 .from(codebaseExplanations)
463 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude464 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude465 .limit(1);
466 if (!row) {
467 return { explanation: null };
468 }
469 return {
470 commitSha: row.commitSha,
2316901Claude471 generatedAt: row.generatedAt,
2c2163eClaude472 markdown: row.markdown,
473 };
474 },
475};
476
f5a18f9Claude477// ---------------------------------------------------------------------------
478// gluecron_repo_health
479// ---------------------------------------------------------------------------
480
481const repoHealth: McpToolHandler = {
482 tool: {
483 name: "gluecron_repo_health",
484 description:
485 "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 labs486 annotations: { title: "Repository health report", readOnlyHint: true, destructiveHint: false },
f5a18f9Claude487 inputSchema: {
488 type: "object",
489 properties: {
490 owner: { type: "string", description: "Repo owner username" },
491 repo: { type: "string", description: "Repo name" },
492 },
493 required: ["owner", "repo"],
494 },
495 },
85fc0efccantynz-alt496 async run(args, ctx) {
f5a18f9Claude497 const owner = argString(args, "owner");
498 const repo = argString(args, "repo");
499
2cc8e18ccantynz-alt500 // Canonical owner/name for the on-disk git ops (case-sensitive FS).
501 const canon = await resolveReadableRepo(owner, repo, ctx);
502 if (!(await repoExists(canon.owner, canon.name))) {
f5a18f9Claude503 throw new McpError(
504 ERR_METHOD_NOT_FOUND,
505 `${owner}/${repo} has no on-disk git data yet`
506 );
507 }
2cc8e18ccantynz-alt508 const report = await computeHealthScore(canon.owner, canon.name);
f5a18f9Claude509 return {
510 score: report.score,
511 grade: report.grade,
512 breakdown: report.breakdown,
513 insights: report.insights,
514 generatedAt: report.generatedAt,
515 };
516 },
517};
518
6551045Claude519// ---------------------------------------------------------------------------
520// Write-surface shared helpers (Block K1)
521// ---------------------------------------------------------------------------
522
523/**
524 * Require an authenticated context — every write tool gates on this first.
525 * Throws -32602 invalid_params with a tool-specific message so the client
526 * surface knows exactly which call needs auth.
527 */
528function requireAuthedCtx(ctx: McpContext, toolName: string): string {
529 if (!ctx.userId) {
530 throw new McpError(
531 ERR_INVALID_PARAMS,
532 `authentication required for ${toolName}`
533 );
534 }
535 return ctx.userId;
536}
537
538/**
539 * Resolve an `owner/repo` pair to its full row, and confirm the caller has
540 * at least `read` access. Privacy: when the repo is missing OR the caller
541 * cannot see it, throw -32601 method_not_found with the same message —
542 * matches the read-tool privacy contract so private-repo existence does
543 * not leak.
544 */
545async function resolveAccessibleRepo(
546 owner: string,
547 repo: string,
548 userId: string | null
549): Promise<{
550 repoId: string;
551 ownerId: string;
552 isPrivate: boolean;
553 defaultBranch: string;
554 access: RepoAccessLevel;
555}> {
556 const [row] = await db
557 .select({
558 id: repositories.id,
559 ownerId: repositories.ownerId,
560 isPrivate: repositories.isPrivate,
561 defaultBranch: repositories.defaultBranch,
562 })
563 .from(repositories)
564 .innerJoin(users, eq(repositories.ownerId, users.id))
565 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
566 .limit(1);
567 if (!row) {
568 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
569 }
570 const access = await resolveRepoAccess({
571 repoId: row.id,
572 userId,
573 isPublic: !row.isPrivate,
574 });
575 if (!satisfiesAccess(access, "read")) {
576 // Hide existence of private repos from non-collaborators.
577 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
578 }
579 return {
580 repoId: row.id,
581 ownerId: row.ownerId,
582 isPrivate: row.isPrivate,
583 defaultBranch: row.defaultBranch,
584 access,
585 };
586}
587
588/**
589 * Combine `requireAuthedCtx` + `resolveAccessibleRepo` + write-access gate.
590 * Used by every write tool. Returns the repo metadata for downstream use.
591 *
592 * Per spec: reject with -32601 method_not_found (not -32603) so the
593 * existence of private repos the caller cannot see is not leaked through
594 * the error code shape.
595 */
596async function gateWriteAccess(
597 args: { owner: string; repo: string },
598 ctx: McpContext,
599 toolName: string
600): Promise<{
601 userId: string;
602 owner: string;
603 repo: string;
604 repoId: string;
605 ownerId: string;
606 isPrivate: boolean;
607 defaultBranch: string;
608}> {
609 const userId = requireAuthedCtx(ctx, toolName);
610 const info = await resolveAccessibleRepo(args.owner, args.repo, userId);
611 if (!satisfiesAccess(info.access, "write")) {
612 throw new McpError(
613 ERR_METHOD_NOT_FOUND,
614 `no write access to ${args.owner}/${args.repo}`
615 );
616 }
617 return {
618 userId,
619 owner: args.owner,
620 repo: args.repo,
621 repoId: info.repoId,
622 ownerId: info.ownerId,
623 isPrivate: info.isPrivate,
624 defaultBranch: info.defaultBranch,
625 };
626}
627
628function prUrl(owner: string, repo: string, number: number): string {
629 return `/${owner}/${repo}/pulls/${number}`;
630}
631function issueUrl(owner: string, repo: string, number: number): string {
632 return `/${owner}/${repo}/issues/${number}`;
633}
634
635async function loadPrByNumber(repoId: string, prNumber: number) {
636 const [pr] = await db
637 .select()
638 .from(pullRequests)
639 .where(
640 and(
641 eq(pullRequests.repositoryId, repoId),
642 eq(pullRequests.number, prNumber)
643 )
644 )
645 .limit(1);
646 return pr ?? null;
647}
648
649async function loadIssueByNumber(repoId: string, issueNumber: number) {
650 const [row] = await db
651 .select()
652 .from(issues)
653 .where(
654 and(
655 eq(issues.repositoryId, repoId),
656 eq(issues.number, issueNumber)
657 )
658 )
659 .limit(1);
660 return row ?? null;
661}
662
663// ---------------------------------------------------------------------------
664// gluecron_create_issue
665// ---------------------------------------------------------------------------
666
667const createIssue: McpToolHandler = {
668 tool: {
669 name: "gluecron_create_issue",
670 description:
671 "Create a new issue on a Gluecron repository. Requires authenticated caller with write access on the target repo. Returns {number, url}.",
523ddadccanty labs672 annotations: { title: "Create issue", readOnlyHint: false, destructiveHint: false },
6551045Claude673 inputSchema: {
674 type: "object",
675 properties: {
676 owner: { type: "string", description: "Repo owner username" },
677 repo: { type: "string", description: "Repo name" },
678 title: { type: "string", description: "Issue title" },
679 body: { type: "string", description: "Issue body (Markdown). Optional." },
680 },
681 required: ["owner", "repo", "title"],
682 },
683 },
684 async run(args, ctx) {
685 const owner = argString(args, "owner");
686 const repo = argString(args, "repo");
687 const title = argString(args, "title");
688 const body = argString(args, "body", "");
689
690 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_issue");
691
692 const [inserted] = await db
693 .insert(issues)
694 .values({
695 repositoryId: gate.repoId,
696 authorId: gate.userId,
697 title,
698 body: body || null,
699 })
700 .returning();
701
702 // Bump issue count (best-effort, mirrors the HTTP route)
703 try {
704 await db
705 .update(repositories)
706 .set({ issueCount: sqlExpr("issue_count + 1") })
707 .where(eq(repositories.id, gate.repoId));
708 } catch {
709 /* non-fatal */
710 }
711
712 await audit({
713 userId: gate.userId,
714 repositoryId: gate.repoId,
715 action: "issue.created",
716 targetType: "issue",
717 targetId: inserted.id,
718 metadata: { source: "mcp", number: inserted.number, title },
719 });
a54e588Test User720 void fireWebhooks(gate.repoId, "issue", { action: "opened", number: inserted.number, title });
6551045Claude721
722 // Notify repo owner if it's not the same user (mirrors typical web flow).
723 if (gate.ownerId !== gate.userId) {
724 notify(gate.ownerId, {
725 kind: "issue_opened",
726 title: `New issue: ${title}`,
727 url: issueUrl(owner, repo, inserted.number),
728 repositoryId: gate.repoId,
a28cedeClaude729 }).catch((err) => {
730 console.warn(
731 `[mcp] issue_opened notify failed for ${owner}/${repo}#${inserted.number}:`,
732 err instanceof Error ? err.message : err
733 );
734 });
6551045Claude735 }
736
737 return {
738 number: inserted.number,
739 url: issueUrl(owner, repo, inserted.number),
740 };
741 },
742};
743
744// ---------------------------------------------------------------------------
745// gluecron_comment_issue
746// ---------------------------------------------------------------------------
747
748const commentIssue: McpToolHandler = {
749 tool: {
750 name: "gluecron_comment_issue",
751 description:
752 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs753 annotations: { title: "Comment on issue", readOnlyHint: false, destructiveHint: false },
6551045Claude754 inputSchema: {
755 type: "object",
756 properties: {
757 owner: { type: "string", description: "Repo owner username" },
758 repo: { type: "string", description: "Repo name" },
759 number: { type: "number", description: "Issue number" },
760 body: { type: "string", description: "Comment body (Markdown)" },
761 },
762 required: ["owner", "repo", "number", "body"],
763 },
764 },
765 async run(args, ctx) {
766 const owner = argString(args, "owner");
767 const repo = argString(args, "repo");
768 const number = argNumber(args, "number");
769 const body = argString(args, "body");
770
771 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
772 const issue = await loadIssueByNumber(gate.repoId, number);
773 if (!issue) {
774 throw new McpError(
775 ERR_METHOD_NOT_FOUND,
776 `issue not found: ${owner}/${repo}#${number}`
777 );
778 }
779
780 const [inserted] = await db
781 .insert(issueComments)
782 .values({
783 issueId: issue.id,
784 authorId: gate.userId,
785 body,
786 })
787 .returning();
788
789 // SSE fanout — best-effort.
790 try {
791 const { publish } = await import("./sse");
792 publish(`repo:${gate.repoId}:issue:${number}`, {
793 event: "issue-comment",
794 data: {
795 issueId: issue.id,
796 commentId: inserted.id,
797 authorId: gate.userId,
798 authorUsername: null,
799 },
800 });
801 } catch {
802 /* SSE best-effort */
803 }
804
805 await audit({
806 userId: gate.userId,
807 repositoryId: gate.repoId,
808 action: "issue.commented",
809 targetType: "issue",
810 targetId: issue.id,
811 metadata: { source: "mcp", number },
812 });
a54e588Test User813 void fireWebhooks(gate.repoId, "issue", { action: "commented", number });
6551045Claude814
815 return { commentId: inserted.id };
816 },
817};
818
819// ---------------------------------------------------------------------------
820// gluecron_close_issue
821// ---------------------------------------------------------------------------
822
823const closeIssue: McpToolHandler = {
824 tool: {
825 name: "gluecron_close_issue",
826 description:
827 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
523ddadccanty labs828 annotations: { title: "Close issue", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude829 inputSchema: {
830 type: "object",
831 properties: {
832 owner: { type: "string", description: "Repo owner username" },
833 repo: { type: "string", description: "Repo name" },
834 number: { type: "number", description: "Issue number" },
835 },
836 required: ["owner", "repo", "number"],
837 },
838 },
839 async run(args, ctx) {
840 const owner = argString(args, "owner");
841 const repo = argString(args, "repo");
842 const number = argNumber(args, "number");
843
844 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
845 const issue = await loadIssueByNumber(gate.repoId, number);
846 if (!issue) {
847 throw new McpError(
848 ERR_METHOD_NOT_FOUND,
849 `issue not found: ${owner}/${repo}#${number}`
850 );
851 }
852
853 if (issue.state !== "closed") {
854 await db
855 .update(issues)
856 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
857 .where(eq(issues.id, issue.id));
858
859 await audit({
860 userId: gate.userId,
861 repositoryId: gate.repoId,
862 action: "issue.closed",
863 targetType: "issue",
864 targetId: issue.id,
865 metadata: { source: "mcp", number },
866 });
a54e588Test User867 void fireWebhooks(gate.repoId, "issue", { action: "closed", number });
6551045Claude868 }
869
870 return { state: "closed" };
871 },
872};
873
874// ---------------------------------------------------------------------------
875// gluecron_reopen_issue
876// ---------------------------------------------------------------------------
877
878const reopenIssue: McpToolHandler = {
879 tool: {
880 name: "gluecron_reopen_issue",
881 description:
882 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs883 // Reopen merely undoes a close (trivially reversible) — not destructive.
884 annotations: { title: "Reopen issue", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
6551045Claude885 inputSchema: {
886 type: "object",
887 properties: {
888 owner: { type: "string", description: "Repo owner username" },
889 repo: { type: "string", description: "Repo name" },
890 number: { type: "number", description: "Issue number" },
891 },
892 required: ["owner", "repo", "number"],
893 },
894 },
895 async run(args, ctx) {
896 const owner = argString(args, "owner");
897 const repo = argString(args, "repo");
898 const number = argNumber(args, "number");
899
900 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
901 const issue = await loadIssueByNumber(gate.repoId, number);
902 if (!issue) {
903 throw new McpError(
904 ERR_METHOD_NOT_FOUND,
905 `issue not found: ${owner}/${repo}#${number}`
906 );
907 }
908
909 if (issue.state !== "open") {
910 await db
911 .update(issues)
912 .set({ state: "open", closedAt: null, updatedAt: new Date() })
913 .where(eq(issues.id, issue.id));
914
915 await audit({
916 userId: gate.userId,
917 repositoryId: gate.repoId,
918 action: "issue.reopened",
919 targetType: "issue",
920 targetId: issue.id,
921 metadata: { source: "mcp", number },
922 });
a54e588Test User923 void fireWebhooks(gate.repoId, "issue", { action: "reopened", number });
6551045Claude924 }
925
926 return { state: "open" };
927 },
928};
929
930// ---------------------------------------------------------------------------
931// gluecron_create_pr
932// ---------------------------------------------------------------------------
933
934const createPr: McpToolHandler = {
935 tool: {
936 name: "gluecron_create_pr",
937 description:
938 "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 labs939 annotations: { title: "Create pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude940 inputSchema: {
941 type: "object",
942 properties: {
943 owner: { type: "string", description: "Repo owner username" },
944 repo: { type: "string", description: "Repo name" },
945 title: { type: "string", description: "PR title" },
946 body: { type: "string", description: "PR body (Markdown). Optional." },
947 head_branch: { type: "string", description: "Branch with the changes" },
948 base_branch: {
949 type: "string",
950 description: "Target branch (default: repo default branch)",
951 },
952 },
953 required: ["owner", "repo", "title", "head_branch"],
954 },
955 },
956 async run(args, ctx) {
957 const owner = argString(args, "owner");
958 const repo = argString(args, "repo");
959 const title = argString(args, "title");
960 const body = argString(args, "body", "");
961 const headBranch = argString(args, "head_branch");
962
963 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
964 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
965
966 if (baseBranch === headBranch) {
967 throw new McpError(
968 ERR_INVALID_PARAMS,
969 "base and head branches must be different"
970 );
971 }
972
973 const [pr] = await db
974 .insert(pullRequests)
975 .values({
976 repositoryId: gate.repoId,
977 authorId: gate.userId,
978 title,
979 body: body || null,
980 baseBranch,
981 headBranch,
982 })
983 .returning();
984
985 await audit({
986 userId: gate.userId,
987 repositoryId: gate.repoId,
988 action: "pr.opened",
989 targetType: "pull_request",
990 targetId: pr.id,
991 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
992 });
993
b7b5f75ccanty labs994 void logActivity({
995 repositoryId: gate.repoId,
996 userId: gate.userId,
997 action: "pr_open",
998 targetType: "pull_request",
999 targetId: String(pr.number),
1000 metadata: { source: "mcp", baseBranch, headBranch },
1001 });
a54e588Test User1002 void fireWebhooks(gate.repoId, "pr", { action: "opened", number: pr.number, baseBranch, headBranch });
b7b5f75ccanty labs1003
79a52c8ccantynz-alt1004 // PRs opened through the MCP write surface (exactly how an external AI
1005 // platform or the agent-journey certification drives this) never
1006 // triggered AI review or triage -- both were wired into the web
1007 // /pulls/new route only. Mirror that here so MCP-created PRs get the
1008 // same treatment as web-created ones.
1009 if (isAiReviewEnabled()) {
1010 triggerAiReview(owner, repo, pr.id, title, body || "", baseBranch, headBranch).catch(
1011 (err) => console.error("[mcp] triggerAiReview failed:", err)
1012 );
1013 }
1014 triggerPrTriage({
1015 ownerName: owner,
1016 repoName: repo,
1017 repositoryId: gate.repoId,
1018 prId: pr.id,
1019 prAuthorId: gate.userId,
1020 title,
1021 body: body || "",
1022 baseBranch,
1023 headBranch,
1024 }).catch((err) => console.error("[mcp] triggerPrTriage failed:", err));
1025
6551045Claude1026 if (gate.ownerId !== gate.userId) {
1027 notify(gate.ownerId, {
1028 kind: "pr_opened",
1029 title: `New PR: ${title}`,
1030 url: prUrl(owner, repo, pr.number),
1031 repositoryId: gate.repoId,
a28cedeClaude1032 }).catch((err) => {
1033 console.warn(
1034 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
1035 err instanceof Error ? err.message : err
1036 );
1037 });
6551045Claude1038 }
1039
1040 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
1041 },
1042};
1043
1044// ---------------------------------------------------------------------------
1045// gluecron_get_pr
1046// ---------------------------------------------------------------------------
1047
1048const getPr: McpToolHandler = {
1049 tool: {
1050 name: "gluecron_get_pr",
1051 description:
1052 "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 labs1053 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude1054 inputSchema: {
1055 type: "object",
1056 properties: {
1057 owner: { type: "string", description: "Repo owner username" },
1058 repo: { type: "string", description: "Repo name" },
1059 number: { type: "number", description: "PR number" },
1060 },
1061 required: ["owner", "repo", "number"],
1062 },
1063 },
1064 async run(args, ctx) {
1065 const owner = argString(args, "owner");
1066 const repo = argString(args, "repo");
1067 const number = argNumber(args, "number");
1068
1069 requireAuthedCtx(ctx, "gluecron_get_pr");
1070 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1071
1072 const pr = await loadPrByNumber(info.repoId, number);
1073 if (!pr) {
1074 throw new McpError(
1075 ERR_METHOD_NOT_FOUND,
1076 `pr not found: ${owner}/${repo}#${number}`
1077 );
1078 }
1079
1080 const [author] = await db
1081 .select({ username: users.username })
1082 .from(users)
1083 .where(eq(users.id, pr.authorId))
1084 .limit(1);
1085
1086 // Best-effort mergeability hint via a quick head-ref resolve.
1087 let mergeable: boolean | null = null;
1088 try {
1089 const headSha = await resolveRef(owner, repo, pr.headBranch);
1090 mergeable = headSha ? true : null;
1091 } catch {
1092 mergeable = null;
1093 }
1094
1095 return {
1096 number: pr.number,
1097 title: pr.title,
1098 body: pr.body || "",
1099 state: pr.state,
1100 baseBranch: pr.baseBranch,
1101 headBranch: pr.headBranch,
1102 isDraft: pr.isDraft,
1103 mergeable,
1104 author: author?.username ?? null,
1105 createdAt: pr.createdAt,
1106 updatedAt: pr.updatedAt,
1107 mergedAt: pr.mergedAt,
1108 closedAt: pr.closedAt,
1109 url: prUrl(owner, repo, pr.number),
1110 };
1111 },
1112};
1113
1114// ---------------------------------------------------------------------------
1115// gluecron_list_prs
1116// ---------------------------------------------------------------------------
1117
1118const listPrs: McpToolHandler = {
1119 tool: {
1120 name: "gluecron_list_prs",
1121 description:
1122 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs1123 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude1124 inputSchema: {
1125 type: "object",
1126 properties: {
1127 owner: { type: "string", description: "Repo owner username" },
1128 repo: { type: "string", description: "Repo name" },
1129 state: {
1130 type: "string",
1131 description: "open | closed | merged | all (default: open)",
1132 },
1133 },
1134 required: ["owner", "repo"],
1135 },
1136 },
1137 async run(args, ctx) {
1138 const owner = argString(args, "owner");
1139 const repo = argString(args, "repo");
1140 const state = argString(args, "state", "open");
1141 if (!["open", "closed", "merged", "all"].includes(state)) {
1142 throw new McpError(
1143 ERR_INVALID_PARAMS,
1144 `state must be one of open|closed|merged|all (got "${state}")`
1145 );
1146 }
1147
1148 requireAuthedCtx(ctx, "gluecron_list_prs");
1149 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1150
1151 const whereClause =
1152 state === "all"
1153 ? eq(pullRequests.repositoryId, info.repoId)
1154 : and(
1155 eq(pullRequests.repositoryId, info.repoId),
1156 eq(pullRequests.state, state)
1157 );
1158
1159 const rows = await db
1160 .select({
1161 number: pullRequests.number,
1162 title: pullRequests.title,
1163 state: pullRequests.state,
1164 baseBranch: pullRequests.baseBranch,
1165 headBranch: pullRequests.headBranch,
1166 isDraft: pullRequests.isDraft,
1167 authorUsername: users.username,
1168 createdAt: pullRequests.createdAt,
1169 updatedAt: pullRequests.updatedAt,
1170 })
1171 .from(pullRequests)
1172 .innerJoin(users, eq(pullRequests.authorId, users.id))
1173 .where(whereClause)
1174 .orderBy(desc(pullRequests.createdAt))
1175 .limit(50);
1176
1177 return {
1178 total: rows.length,
1179 prs: rows.map((p) => ({
1180 number: p.number,
1181 title: p.title,
1182 state: p.state,
1183 baseBranch: p.baseBranch,
1184 headBranch: p.headBranch,
1185 isDraft: p.isDraft,
1186 author: p.authorUsername,
1187 createdAt: p.createdAt,
1188 updatedAt: p.updatedAt,
1189 url: prUrl(owner, repo, p.number),
1190 })),
1191 };
1192 },
1193};
1194
1195// ---------------------------------------------------------------------------
1196// gluecron_comment_pr
1197// ---------------------------------------------------------------------------
1198
1199const commentPr: McpToolHandler = {
1200 tool: {
1201 name: "gluecron_comment_pr",
1202 description:
1203 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1204 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1205 inputSchema: {
1206 type: "object",
1207 properties: {
1208 owner: { type: "string", description: "Repo owner username" },
1209 repo: { type: "string", description: "Repo name" },
1210 number: { type: "number", description: "PR number" },
1211 body: { type: "string", description: "Comment body (Markdown)" },
1212 },
1213 required: ["owner", "repo", "number", "body"],
1214 },
1215 },
1216 async run(args, ctx) {
1217 const owner = argString(args, "owner");
1218 const repo = argString(args, "repo");
1219 const number = argNumber(args, "number");
1220 const body = argString(args, "body");
1221
1222 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1223 const pr = await loadPrByNumber(gate.repoId, number);
1224 if (!pr) {
1225 throw new McpError(
1226 ERR_METHOD_NOT_FOUND,
1227 `pr not found: ${owner}/${repo}#${number}`
1228 );
1229 }
1230
1231 const [inserted] = await db
1232 .insert(prComments)
1233 .values({
1234 pullRequestId: pr.id,
1235 authorId: gate.userId,
1236 body,
1237 })
1238 .returning();
1239
1240 try {
1241 const { publish } = await import("./sse");
1242 publish(`repo:${gate.repoId}:pr:${number}`, {
1243 event: "pr-comment",
1244 data: {
1245 pullRequestId: pr.id,
1246 commentId: inserted.id,
1247 authorId: gate.userId,
1248 authorUsername: null,
1249 },
1250 });
1251 } catch {
1252 /* SSE best-effort */
1253 }
1254
1255 await audit({
1256 userId: gate.userId,
1257 repositoryId: gate.repoId,
1258 action: "pr.commented",
1259 targetType: "pull_request",
1260 targetId: pr.id,
1261 metadata: { source: "mcp", number },
1262 });
1263
b7b5f75ccanty labs1264 void logActivity({
1265 repositoryId: gate.repoId,
1266 userId: gate.userId,
1267 action: "comment",
1268 targetType: "pull_request",
1269 targetId: String(number),
1270 metadata: { source: "mcp", commentId: inserted.id },
1271 });
a54e588Test User1272 void fireWebhooks(gate.repoId, "pr", { action: "commented", number });
b7b5f75ccanty labs1273
6551045Claude1274 return { commentId: inserted.id };
1275 },
1276};
1277
1278// ---------------------------------------------------------------------------
1279// gluecron_merge_pr
1280// ---------------------------------------------------------------------------
1281
1282const mergePr: McpToolHandler = {
1283 tool: {
1284 name: "gluecron_merge_pr",
1285 description:
534f04aClaude1286 "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 labs1287 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1288 inputSchema: {
1289 type: "object",
1290 properties: {
1291 owner: { type: "string", description: "Repo owner username" },
1292 repo: { type: "string", description: "Repo name" },
1293 number: { type: "number", description: "PR number" },
534f04aClaude1294 confirm_high_risk: {
1295 type: "boolean",
1296 description:
1297 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1298 },
6551045Claude1299 },
1300 required: ["owner", "repo", "number"],
1301 },
1302 },
1303 async run(args, ctx) {
1304 const owner = argString(args, "owner");
1305 const repo = argString(args, "repo");
1306 const number = argNumber(args, "number");
534f04aClaude1307 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1308
1309 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1310 const pr = await loadPrByNumber(gate.repoId, number);
1311 if (!pr) {
1312 throw new McpError(
1313 ERR_METHOD_NOT_FOUND,
1314 `pr not found: ${owner}/${repo}#${number}`
1315 );
1316 }
1317 if (pr.state !== "open") {
1318 return { merged: false, reason: `pr is ${pr.state}, not open` };
1319 }
1320 if (pr.isDraft) {
1321 return {
1322 merged: false,
1323 reason: "This PR is a draft. Mark it as ready for review before merging.",
1324 };
1325 }
1326
534f04aClaude1327 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1328 // fall back to most-recent cached row; finally compute on demand so the
1329 // MCP caller always gets a score (HTTP path is async + tolerant of a
1330 // missing score, but MCP callers want an answer in one round trip).
1331 let risk: PrRiskScore | null = null;
1332 try {
1333 risk =
1334 (await getCachedPrRisk(pr.id)) ||
1335 (await getLatestCachedPrRisk(pr.id)) ||
1336 (await computePrRiskForPullRequest(pr.id));
1337 } catch {
1338 risk = null;
1339 }
1340
1341 if (risk && risk.band === "critical" && !confirmHighRisk) {
1342 return {
1343 merged: false,
1344 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1345 riskScore: serialisePrRiskForResponse(risk),
1346 };
1347 }
1348
6551045Claude1349 const headSha = await resolveRef(owner, repo, pr.headBranch);
1350 if (!headSha) {
1351 return { merged: false, reason: "Head branch not found" };
1352 }
1353
c166384ccantynz-alt1354 const aiApproved = await isAiReviewApproved(pr.id);
6551045Claude1355
1356 const gateResult = await runAllGateChecks(
1357 owner,
1358 repo,
1359 pr.baseBranch,
1360 pr.headBranch,
1361 headSha,
1362 aiApproved
1363 );
1364
1365 const hardFailures = gateResult.checks.filter(
1366 (check) => !check.passed && check.name !== "Merge check"
1367 );
1368 if (hardFailures.length > 0) {
1369 return {
1370 merged: false,
1371 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1372 };
1373 }
1374
1375 // D5 — branch-protection enforcement
1376 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1377 if (protectionRule) {
1378 const humanApprovals = await countHumanApprovals(pr.id);
1379 const required = await listRequiredChecks(protectionRule.id);
1380 const passingNames =
1381 required.length > 0
1382 ? await passingCheckNames(gate.repoId, headSha)
1383 : [];
1384 const decision = evaluateProtection(
1385 protectionRule,
1386 {
1387 aiApproved,
1388 humanApprovalCount: humanApprovals,
1389 gateResultGreen: hardFailures.length === 0,
1390 hasFailedGates: hardFailures.length > 0,
1391 passingCheckNames: passingNames,
1392 },
1393 required.map((r) => r.checkName)
1394 );
91b054eccanty labs1395
1396 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1397 // when the rule already requires human review at all (no new DB
1398 // column for this pass). Fail-open on any internal error: a bug here
1399 // must never hard-block every merge platform-wide. Mirrors the HTTP
1400 // merge path in routes/pulls.tsx.
1401 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1402 try {
1403 const codeownersDiffProc = Bun.spawn(
1404 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1405 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1406 );
1407 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1408 await codeownersDiffProc.exited;
1409 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1410 if (changedPaths.length > 0) {
1411 const { satisfied, missingOwners } = await requiredOwnersApproved(
1412 owner,
1413 repo,
1414 gate.defaultBranch,
1415 pr.id,
1416 changedPaths
1417 );
1418 if (!satisfied) {
1419 decision.allowed = false;
1420 decision.reasons.push(
1421 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1422 );
1423 }
1424 }
1425 } catch (err) {
1426 console.warn(
1427 "[codeowners] merge enforcement failed:",
1428 err instanceof Error ? err.message : err
1429 );
1430 }
1431 }
1432
6551045Claude1433 if (!decision.allowed) {
1434 return { merged: false, reason: decision.reasons.join(" ") };
1435 }
1436 }
1437
1438 const repoDir = getRepoPath(owner, repo);
1439 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1440 const hasConflicts = mergeCheck && !mergeCheck.passed;
1441
1442 if (hasConflicts && isAiReviewEnabled()) {
1443 const mergeResult = await mergeWithAutoResolve(
1444 owner,
1445 repo,
1446 pr.baseBranch,
1447 pr.headBranch,
1448 `Merge pull request #${pr.number}: ${pr.title}`
1449 );
1450 if (!mergeResult.success) {
1451 return {
1452 merged: false,
1453 reason: mergeResult.error || "Auto-merge failed",
1454 };
1455 }
1456 if (mergeResult.resolvedFiles.length > 0) {
1457 await db.insert(prComments).values({
1458 pullRequestId: pr.id,
1459 authorId: gate.userId,
1460 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1461 .map((f) => `- \`${f}\``)
1462 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1463 isAiReview: true,
1464 });
1465 }
1466 } else {
1467 const ffProc = Bun.spawn(
1468 [
1469 "git",
1470 "update-ref",
1471 `refs/heads/${pr.baseBranch}`,
1472 `refs/heads/${pr.headBranch}`,
1473 ],
1474 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1475 );
1476 const ffExit = await ffProc.exited;
1477 if (ffExit !== 0) {
1478 return {
1479 merged: false,
1480 reason: "Merge failed — unable to update branch ref",
1481 };
1482 }
1483 }
1484
1485 await db
1486 .update(pullRequests)
1487 .set({
1488 state: "merged",
1489 mergedAt: new Date(),
1490 mergedBy: gate.userId,
1491 updatedAt: new Date(),
1492 })
1493 .where(eq(pullRequests.id, pr.id));
1494
1495 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1496 try {
1497 const { extractClosingRefsMulti } = await import("./close-keywords");
1498 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1499 for (const n of refs) {
1500 const target = await loadIssueByNumber(gate.repoId, n);
1501 if (!target || target.state !== "open") continue;
1502 await db
1503 .update(issues)
1504 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1505 .where(eq(issues.id, target.id));
1506 await db.insert(issueComments).values({
1507 issueId: target.id,
1508 authorId: gate.userId,
1509 body: `Closed by pull request #${pr.number}.`,
1510 });
1511 }
1512 } catch {
1513 /* never block merge on close-keyword failures */
1514 }
1515
1516 await audit({
1517 userId: gate.userId,
1518 repositoryId: gate.repoId,
1519 action: "pr.merged",
1520 targetType: "pull_request",
1521 targetId: pr.id,
1522 metadata: { source: "mcp", number, sha: headSha },
1523 });
1524
b7b5f75ccanty labs1525 void logActivity({
1526 repositoryId: gate.repoId,
1527 userId: gate.userId,
1528 action: "pr_merge",
1529 targetType: "pull_request",
1530 targetId: String(number),
1531 metadata: { source: "mcp", sha: headSha },
1532 });
a54e588Test User1533 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1534
6551045Claude1535 // Resolved post-merge SHA (best effort).
1536 let mergedSha: string | null = null;
1537 try {
1538 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1539 } catch {
1540 mergedSha = null;
1541 }
1542
534f04aClaude1543 // Block M3 — informational payload: when the risk score is high or
1544 // critical, include the score + summary in the response even on a
1545 // successful merge so the caller has the audit context. Low/medium
1546 // bands stay quiet to keep response noise low.
1547 const response: {
1548 merged: true;
1549 sha: string;
1550 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1551 } = {
6551045Claude1552 merged: true,
1553 sha: mergedSha ?? headSha,
1554 };
534f04aClaude1555 if (risk && (risk.band === "high" || risk.band === "critical")) {
1556 response.riskScore = serialisePrRiskForResponse(risk);
1557 }
1558 return response;
6551045Claude1559 },
1560};
1561
534f04aClaude1562/**
1563 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1564 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1565 */
1566function serialisePrRiskForResponse(risk: PrRiskScore) {
1567 return {
1568 score: risk.score,
1569 band: risk.band,
1570 aiSummary: risk.aiSummary,
1571 commitSha: risk.commitSha,
1572 signals: risk.signals,
1573 generatedAt:
1574 risk.generatedAt instanceof Date
1575 ? risk.generatedAt.toISOString()
1576 : String(risk.generatedAt),
1577 };
1578}
1579
6551045Claude1580// ---------------------------------------------------------------------------
1581// gluecron_close_pr
1582// ---------------------------------------------------------------------------
1583
1584const closePr: McpToolHandler = {
1585 tool: {
1586 name: "gluecron_close_pr",
1587 description:
1588 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1589 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1590 inputSchema: {
1591 type: "object",
1592 properties: {
1593 owner: { type: "string", description: "Repo owner username" },
1594 repo: { type: "string", description: "Repo name" },
1595 number: { type: "number", description: "PR number" },
1596 },
1597 required: ["owner", "repo", "number"],
1598 },
1599 },
1600 async run(args, ctx) {
1601 const owner = argString(args, "owner");
1602 const repo = argString(args, "repo");
1603 const number = argNumber(args, "number");
1604
1605 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1606 const pr = await loadPrByNumber(gate.repoId, number);
1607 if (!pr) {
1608 throw new McpError(
1609 ERR_METHOD_NOT_FOUND,
1610 `pr not found: ${owner}/${repo}#${number}`
1611 );
1612 }
1613
1614 if (pr.state === "open") {
1615 await db
1616 .update(pullRequests)
1617 .set({
1618 state: "closed",
1619 closedAt: new Date(),
1620 updatedAt: new Date(),
1621 })
1622 .where(eq(pullRequests.id, pr.id));
1623
1624 await audit({
1625 userId: gate.userId,
1626 repositoryId: gate.repoId,
1627 action: "pr.closed",
1628 targetType: "pull_request",
1629 targetId: pr.id,
1630 metadata: { source: "mcp", number },
1631 });
a54e588Test User1632 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1633 }
1634
1635 return { state: pr.state === "merged" ? "merged" : "closed" };
1636 },
1637};
1638
1639function sqlExpr(expr: string) {
1640 return drizzleSql.raw(expr);
1641}
1642
2c2163eClaude1643// ---------------------------------------------------------------------------
1644// Default tool registry
1645// ---------------------------------------------------------------------------
1646
1647export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1648 // The original 15-tool surface (read + K1 write) remains the source of
1649 // truth for the core gate-enforced workflows. The expanded set in
1650 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1651 // adapter over an existing lib helper — so AI agents can drive
1652 // Gluecron end-to-end.
1653 //
1654 // The expanded module imports the `mcp*` helpers re-exported above —
1655 // since those are `const` exports they're populated by the time
1656 // `defaultTools()` is called at request time, so the circular ESM
1657 // import resolves cleanly.
2c2163eClaude1658 return {
1659 [repoSearch.tool.name]: repoSearch,
1660 [repoReadFile.tool.name]: repoReadFile,
1661 [repoListIssues.tool.name]: repoListIssues,
1662 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1663 [repoHealth.tool.name]: repoHealth,
6551045Claude1664 // Block K1 — write surface
1665 [createIssue.tool.name]: createIssue,
1666 [commentIssue.tool.name]: commentIssue,
1667 [closeIssue.tool.name]: closeIssue,
1668 [reopenIssue.tool.name]: reopenIssue,
1669 [createPr.tool.name]: createPr,
1670 [getPr.tool.name]: getPr,
1671 [listPrs.tool.name]: listPrs,
1672 [commentPr.tool.name]: commentPr,
1673 [mergePr.tool.name]: mergePr,
1674 [closePr.tool.name]: closePr,
0feb720Claude1675 // Expanded surface (50+ tools total)
1676 ...expandedTools(),
2c2163eClaude1677 };
1678}
1679
0feb720Claude1680// ---------------------------------------------------------------------------
1681// Shared helpers (re-exported so the expanded tool surface in
1682// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1683// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1684// them keeps the wrapper modules tiny.
1685// ---------------------------------------------------------------------------
1686
1687export {
1688 argString as mcpArgString,
1689 argNumber as mcpArgNumber,
1690 gateWriteAccess as mcpGateWriteAccess,
1691 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1692 requireAuthedCtx as mcpRequireAuthedCtx,
1693 loadPrByNumber as mcpLoadPrByNumber,
1694 loadIssueByNumber as mcpLoadIssueByNumber,
1695 prUrl as mcpPrUrl,
1696 issueUrl as mcpIssueUrl,
1697};
1698
2c2163eClaude1699/** Test-only export of internal helpers + per-tool handlers. */
1700export const __test = {
1701 argString,
1702 argNumber,
1703 repoSearch,
1704 repoReadFile,
1705 repoListIssues,
1706 repoExplain,
f5a18f9Claude1707 repoHealth,
6551045Claude1708 // Block K1 — write surface
1709 createIssue,
1710 commentIssue,
1711 closeIssue,
1712 reopenIssue,
1713 createPr,
1714 getPr,
1715 listPrs,
1716 commentPr,
1717 mergePr,
1718 closePr,
1719 gateWriteAccess,
1720 resolveAccessibleRepo,
2c2163eClaude1721};