Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame1623 lines · 4 contributors
2c2163eClaude1/**
2 * MCP tool handlers — read-only v1 set.
3 *
4 * Each handler returns either a string (auto-wrapped to text content)
5 * or the full MCP `{content: [...]}` shape. Errors throw `McpError` from
6 * `mcp.ts` so the router can surface them as JSON-RPC -32xxx codes.
7 *
8 * Tool surface (v1, all read-only):
9 * - gluecron_repo_search — search public repos by keyword
10 * - gluecron_repo_read_file — read a file from a repo at a ref
11 * - gluecron_repo_list_issues — list open issues for a repo
12 * - gluecron_repo_explain_codebase — return cached AI explanation
13 *
14 * v2 will add write tools (create_issue, post_comment, run_workflow)
15 * gated on `userId` + write-access on the target repo.
16 */
17
6551045Claude18import { and, asc, desc, eq, like, or, sql as drizzleSql } from "drizzle-orm";
2c2163eClaude19import { db } from "../db";
20import {
21 issues,
6551045Claude22 issueComments,
23 pullRequests,
24 prComments,
2c2163eClaude25 repositories,
26 users,
27 codebaseExplanations,
28} from "../db/schema";
6551045Claude29import { getBlob, repoExists, resolveRef, getRepoPath } from "../git/repository";
f5a18f9Claude30import { computeHealthScore } from "./intelligence";
2c2163eClaude31import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
32import type { McpContext } from "./mcp";
6551045Claude33import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
34import type { RepoAccessLevel } from "../middleware/repo-access";
b7b5f75ccanty labs35import { notify, audit, logActivity } from "./notify";
a54e588Test User36import { fireWebhooks } from "../routes/webhooks";
6551045Claude37import { runAllGateChecks } from "./gate";
38import {
39 matchProtection,
40 countHumanApprovals,
41 listRequiredChecks,
42 passingCheckNames,
43 evaluateProtection,
44} from "./branch-protection";
45import { mergeWithAutoResolve } from "./merge-resolver";
79a52c8ccantynz-alt46import { isAiReviewEnabled, triggerAiReview } from "./ai-review";
47import { 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
112// ---------------------------------------------------------------------------
113// gluecron_repo_search
114// ---------------------------------------------------------------------------
115
116const repoSearch: McpToolHandler = {
117 tool: {
118 name: "gluecron_repo_search",
119 description:
120 "Search public Gluecron repositories by keyword. Matches against name + description. Returns up to 20 results.",
523ddadccanty labs121 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
2c2163eClaude122 inputSchema: {
123 type: "object",
124 properties: {
125 query: { type: "string", description: "Search keyword (1-100 chars)" },
126 limit: { type: "number", description: "Max results, default 20" },
127 },
128 required: ["query"],
129 },
130 },
131 async run(args) {
132 const q = argString(args, "query");
133 if (q.length > 100) {
134 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
135 }
136 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
137 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
138 const rows = await db
139 .select({
140 id: repositories.id,
141 name: repositories.name,
142 description: repositories.description,
143 ownerName: users.username,
144 stars: repositories.starCount,
145 })
146 .from(repositories)
147 .innerJoin(users, eq(repositories.ownerId, users.id))
148 .where(
149 and(
150 eq(repositories.isPrivate, false),
151 or(
152 like(repositories.name, pattern),
153 like(repositories.description, pattern)
154 )
155 )
156 )
157 .orderBy(desc(repositories.starCount))
158 .limit(limit);
159 return {
160 total: rows.length,
161 repos: rows.map((r) => ({
162 fullName: `${r.ownerName}/${r.name}`,
163 description: r.description || "",
164 stars: r.stars,
165 })),
166 };
167 },
168};
169
170// ---------------------------------------------------------------------------
171// gluecron_repo_read_file
172// ---------------------------------------------------------------------------
173
174const repoReadFile: McpToolHandler = {
175 tool: {
176 name: "gluecron_repo_read_file",
177 description:
178 "Read a single file from a public repository at a given ref (branch / tag / commit). Returns the text content (binary files rejected).",
523ddadccanty labs179 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
2c2163eClaude180 inputSchema: {
181 type: "object",
182 properties: {
183 owner: { type: "string", description: "Repo owner username" },
184 repo: { type: "string", description: "Repo name" },
185 ref: { type: "string", description: "Branch / tag / commit (default: main)" },
186 path: { type: "string", description: "File path within the repo" },
187 },
188 required: ["owner", "repo", "path"],
189 },
190 },
191 async run(args) {
192 const owner = argString(args, "owner");
193 const repo = argString(args, "repo");
194 const ref = argString(args, "ref", "main");
195 const path = argString(args, "path");
196
197 // Visibility check — public-only for v1. (Authed users see private
198 // repos in v2 once we extend the args.)
199 const [r] = await db
200 .select({ isPrivate: repositories.isPrivate })
201 .from(repositories)
202 .innerJoin(users, eq(repositories.ownerId, users.id))
203 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
204 .limit(1);
205 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
206 if (r.isPrivate) {
207 throw new McpError(
208 ERR_METHOD_NOT_FOUND,
209 `${owner}/${repo} is private; v1 MCP read tool is public-only`
210 );
211 }
212
213 const blob = await getBlob(owner, repo, ref, path);
214 if (!blob) {
215 throw new McpError(
216 ERR_METHOD_NOT_FOUND,
217 `path not found: ${owner}/${repo}@${ref}:${path}`
218 );
219 }
220 return {
221 content: [
222 {
223 type: "text",
224 text: blob.content,
225 },
226 ],
227 };
228 },
229};
230
231// ---------------------------------------------------------------------------
232// gluecron_repo_list_issues
233// ---------------------------------------------------------------------------
234
235const repoListIssues: McpToolHandler = {
236 tool: {
237 name: "gluecron_repo_list_issues",
238 description:
239 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
523ddadccanty labs240 annotations: { title: "List open issues", readOnlyHint: true, destructiveHint: false },
2c2163eClaude241 inputSchema: {
242 type: "object",
243 properties: {
244 owner: { type: "string", description: "Repo owner username" },
245 repo: { type: "string", description: "Repo name" },
246 limit: { type: "number", description: "Max results, default 25" },
247 },
248 required: ["owner", "repo"],
249 },
250 },
251 async run(args) {
252 const owner = argString(args, "owner");
253 const repo = argString(args, "repo");
254 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
255
256 const [r] = await db
257 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
258 .from(repositories)
259 .innerJoin(users, eq(repositories.ownerId, users.id))
260 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
261 .limit(1);
262 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
263 if (r.isPrivate) {
264 throw new McpError(
265 ERR_METHOD_NOT_FOUND,
266 `${owner}/${repo} is private; v1 MCP read tool is public-only`
267 );
268 }
269
270 const rows = await db
271 .select({
272 number: issues.number,
273 title: issues.title,
274 body: issues.body,
275 state: issues.state,
276 createdAt: issues.createdAt,
277 })
278 .from(issues)
279 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
280 .orderBy(desc(issues.createdAt))
281 .limit(limit);
282 return {
283 total: rows.length,
284 issues: rows.map((i) => ({
285 number: i.number,
286 title: i.title,
287 body: i.body || "",
288 state: i.state,
289 createdAt: i.createdAt,
290 })),
291 };
292 },
293};
294
295// ---------------------------------------------------------------------------
296// gluecron_repo_explain_codebase
297// ---------------------------------------------------------------------------
298
299const repoExplain: McpToolHandler = {
300 tool: {
301 name: "gluecron_repo_explain_codebase",
302 description:
303 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
523ddadccanty labs304 annotations: { title: "Explain codebase (cached)", readOnlyHint: true, destructiveHint: false },
2c2163eClaude305 inputSchema: {
306 type: "object",
307 properties: {
308 owner: { type: "string", description: "Repo owner username" },
309 repo: { type: "string", description: "Repo name" },
310 },
311 required: ["owner", "repo"],
312 },
313 },
314 async run(args) {
315 const owner = argString(args, "owner");
316 const repo = argString(args, "repo");
317 const [r] = await db
318 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
319 .from(repositories)
320 .innerJoin(users, eq(repositories.ownerId, users.id))
321 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
322 .limit(1);
323 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
324 if (r.isPrivate) {
325 throw new McpError(
326 ERR_METHOD_NOT_FOUND,
327 `${owner}/${repo} is private; v1 MCP read tool is public-only`
328 );
329 }
330 const [row] = await db
331 .select({
332 commitSha: codebaseExplanations.commitSha,
333 markdown: codebaseExplanations.markdown,
2316901Claude334 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude335 })
336 .from(codebaseExplanations)
337 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude338 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude339 .limit(1);
340 if (!row) {
341 return { explanation: null };
342 }
343 return {
344 commitSha: row.commitSha,
2316901Claude345 generatedAt: row.generatedAt,
2c2163eClaude346 markdown: row.markdown,
347 };
348 },
349};
350
f5a18f9Claude351// ---------------------------------------------------------------------------
352// gluecron_repo_health
353// ---------------------------------------------------------------------------
354
355const repoHealth: McpToolHandler = {
356 tool: {
357 name: "gluecron_repo_health",
358 description:
359 "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 labs360 annotations: { title: "Repository health report", readOnlyHint: true, destructiveHint: false },
f5a18f9Claude361 inputSchema: {
362 type: "object",
363 properties: {
364 owner: { type: "string", description: "Repo owner username" },
365 repo: { type: "string", description: "Repo name" },
366 },
367 required: ["owner", "repo"],
368 },
369 },
370 async run(args) {
371 const owner = argString(args, "owner");
372 const repo = argString(args, "repo");
373
374 const [r] = await db
375 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
376 .from(repositories)
377 .innerJoin(users, eq(repositories.ownerId, users.id))
378 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
379 .limit(1);
380 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
381 if (r.isPrivate) {
382 throw new McpError(
383 ERR_METHOD_NOT_FOUND,
384 `${owner}/${repo} is private; v1 MCP tools are public-only`
385 );
386 }
387 if (!(await repoExists(owner, repo))) {
388 throw new McpError(
389 ERR_METHOD_NOT_FOUND,
390 `${owner}/${repo} has no on-disk git data yet`
391 );
392 }
393 const report = await computeHealthScore(owner, repo);
394 return {
395 score: report.score,
396 grade: report.grade,
397 breakdown: report.breakdown,
398 insights: report.insights,
399 generatedAt: report.generatedAt,
400 };
401 },
402};
403
6551045Claude404// ---------------------------------------------------------------------------
405// Write-surface shared helpers (Block K1)
406// ---------------------------------------------------------------------------
407
408/**
409 * Require an authenticated context — every write tool gates on this first.
410 * Throws -32602 invalid_params with a tool-specific message so the client
411 * surface knows exactly which call needs auth.
412 */
413function requireAuthedCtx(ctx: McpContext, toolName: string): string {
414 if (!ctx.userId) {
415 throw new McpError(
416 ERR_INVALID_PARAMS,
417 `authentication required for ${toolName}`
418 );
419 }
420 return ctx.userId;
421}
422
423/**
424 * Resolve an `owner/repo` pair to its full row, and confirm the caller has
425 * at least `read` access. Privacy: when the repo is missing OR the caller
426 * cannot see it, throw -32601 method_not_found with the same message —
427 * matches the read-tool privacy contract so private-repo existence does
428 * not leak.
429 */
430async function resolveAccessibleRepo(
431 owner: string,
432 repo: string,
433 userId: string | null
434): Promise<{
435 repoId: string;
436 ownerId: string;
437 isPrivate: boolean;
438 defaultBranch: string;
439 access: RepoAccessLevel;
440}> {
441 const [row] = await db
442 .select({
443 id: repositories.id,
444 ownerId: repositories.ownerId,
445 isPrivate: repositories.isPrivate,
446 defaultBranch: repositories.defaultBranch,
447 })
448 .from(repositories)
449 .innerJoin(users, eq(repositories.ownerId, users.id))
450 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
451 .limit(1);
452 if (!row) {
453 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
454 }
455 const access = await resolveRepoAccess({
456 repoId: row.id,
457 userId,
458 isPublic: !row.isPrivate,
459 });
460 if (!satisfiesAccess(access, "read")) {
461 // Hide existence of private repos from non-collaborators.
462 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
463 }
464 return {
465 repoId: row.id,
466 ownerId: row.ownerId,
467 isPrivate: row.isPrivate,
468 defaultBranch: row.defaultBranch,
469 access,
470 };
471}
472
473/**
474 * Combine `requireAuthedCtx` + `resolveAccessibleRepo` + write-access gate.
475 * Used by every write tool. Returns the repo metadata for downstream use.
476 *
477 * Per spec: reject with -32601 method_not_found (not -32603) so the
478 * existence of private repos the caller cannot see is not leaked through
479 * the error code shape.
480 */
481async function gateWriteAccess(
482 args: { owner: string; repo: string },
483 ctx: McpContext,
484 toolName: string
485): Promise<{
486 userId: string;
487 owner: string;
488 repo: string;
489 repoId: string;
490 ownerId: string;
491 isPrivate: boolean;
492 defaultBranch: string;
493}> {
494 const userId = requireAuthedCtx(ctx, toolName);
495 const info = await resolveAccessibleRepo(args.owner, args.repo, userId);
496 if (!satisfiesAccess(info.access, "write")) {
497 throw new McpError(
498 ERR_METHOD_NOT_FOUND,
499 `no write access to ${args.owner}/${args.repo}`
500 );
501 }
502 return {
503 userId,
504 owner: args.owner,
505 repo: args.repo,
506 repoId: info.repoId,
507 ownerId: info.ownerId,
508 isPrivate: info.isPrivate,
509 defaultBranch: info.defaultBranch,
510 };
511}
512
513function prUrl(owner: string, repo: string, number: number): string {
514 return `/${owner}/${repo}/pulls/${number}`;
515}
516function issueUrl(owner: string, repo: string, number: number): string {
517 return `/${owner}/${repo}/issues/${number}`;
518}
519
520async function loadPrByNumber(repoId: string, prNumber: number) {
521 const [pr] = await db
522 .select()
523 .from(pullRequests)
524 .where(
525 and(
526 eq(pullRequests.repositoryId, repoId),
527 eq(pullRequests.number, prNumber)
528 )
529 )
530 .limit(1);
531 return pr ?? null;
532}
533
534async function loadIssueByNumber(repoId: string, issueNumber: number) {
535 const [row] = await db
536 .select()
537 .from(issues)
538 .where(
539 and(
540 eq(issues.repositoryId, repoId),
541 eq(issues.number, issueNumber)
542 )
543 )
544 .limit(1);
545 return row ?? null;
546}
547
548// ---------------------------------------------------------------------------
549// gluecron_create_issue
550// ---------------------------------------------------------------------------
551
552const createIssue: McpToolHandler = {
553 tool: {
554 name: "gluecron_create_issue",
555 description:
556 "Create a new issue on a Gluecron repository. Requires authenticated caller with write access on the target repo. Returns {number, url}.",
523ddadccanty labs557 annotations: { title: "Create issue", readOnlyHint: false, destructiveHint: false },
6551045Claude558 inputSchema: {
559 type: "object",
560 properties: {
561 owner: { type: "string", description: "Repo owner username" },
562 repo: { type: "string", description: "Repo name" },
563 title: { type: "string", description: "Issue title" },
564 body: { type: "string", description: "Issue body (Markdown). Optional." },
565 },
566 required: ["owner", "repo", "title"],
567 },
568 },
569 async run(args, ctx) {
570 const owner = argString(args, "owner");
571 const repo = argString(args, "repo");
572 const title = argString(args, "title");
573 const body = argString(args, "body", "");
574
575 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_issue");
576
577 const [inserted] = await db
578 .insert(issues)
579 .values({
580 repositoryId: gate.repoId,
581 authorId: gate.userId,
582 title,
583 body: body || null,
584 })
585 .returning();
586
587 // Bump issue count (best-effort, mirrors the HTTP route)
588 try {
589 await db
590 .update(repositories)
591 .set({ issueCount: sqlExpr("issue_count + 1") })
592 .where(eq(repositories.id, gate.repoId));
593 } catch {
594 /* non-fatal */
595 }
596
597 await audit({
598 userId: gate.userId,
599 repositoryId: gate.repoId,
600 action: "issue.created",
601 targetType: "issue",
602 targetId: inserted.id,
603 metadata: { source: "mcp", number: inserted.number, title },
604 });
a54e588Test User605 void fireWebhooks(gate.repoId, "issue", { action: "opened", number: inserted.number, title });
6551045Claude606
607 // Notify repo owner if it's not the same user (mirrors typical web flow).
608 if (gate.ownerId !== gate.userId) {
609 notify(gate.ownerId, {
610 kind: "issue_opened",
611 title: `New issue: ${title}`,
612 url: issueUrl(owner, repo, inserted.number),
613 repositoryId: gate.repoId,
a28cedeClaude614 }).catch((err) => {
615 console.warn(
616 `[mcp] issue_opened notify failed for ${owner}/${repo}#${inserted.number}:`,
617 err instanceof Error ? err.message : err
618 );
619 });
6551045Claude620 }
621
622 return {
623 number: inserted.number,
624 url: issueUrl(owner, repo, inserted.number),
625 };
626 },
627};
628
629// ---------------------------------------------------------------------------
630// gluecron_comment_issue
631// ---------------------------------------------------------------------------
632
633const commentIssue: McpToolHandler = {
634 tool: {
635 name: "gluecron_comment_issue",
636 description:
637 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs638 annotations: { title: "Comment on issue", readOnlyHint: false, destructiveHint: false },
6551045Claude639 inputSchema: {
640 type: "object",
641 properties: {
642 owner: { type: "string", description: "Repo owner username" },
643 repo: { type: "string", description: "Repo name" },
644 number: { type: "number", description: "Issue number" },
645 body: { type: "string", description: "Comment body (Markdown)" },
646 },
647 required: ["owner", "repo", "number", "body"],
648 },
649 },
650 async run(args, ctx) {
651 const owner = argString(args, "owner");
652 const repo = argString(args, "repo");
653 const number = argNumber(args, "number");
654 const body = argString(args, "body");
655
656 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
657 const issue = await loadIssueByNumber(gate.repoId, number);
658 if (!issue) {
659 throw new McpError(
660 ERR_METHOD_NOT_FOUND,
661 `issue not found: ${owner}/${repo}#${number}`
662 );
663 }
664
665 const [inserted] = await db
666 .insert(issueComments)
667 .values({
668 issueId: issue.id,
669 authorId: gate.userId,
670 body,
671 })
672 .returning();
673
674 // SSE fanout — best-effort.
675 try {
676 const { publish } = await import("./sse");
677 publish(`repo:${gate.repoId}:issue:${number}`, {
678 event: "issue-comment",
679 data: {
680 issueId: issue.id,
681 commentId: inserted.id,
682 authorId: gate.userId,
683 authorUsername: null,
684 },
685 });
686 } catch {
687 /* SSE best-effort */
688 }
689
690 await audit({
691 userId: gate.userId,
692 repositoryId: gate.repoId,
693 action: "issue.commented",
694 targetType: "issue",
695 targetId: issue.id,
696 metadata: { source: "mcp", number },
697 });
a54e588Test User698 void fireWebhooks(gate.repoId, "issue", { action: "commented", number });
6551045Claude699
700 return { commentId: inserted.id };
701 },
702};
703
704// ---------------------------------------------------------------------------
705// gluecron_close_issue
706// ---------------------------------------------------------------------------
707
708const closeIssue: McpToolHandler = {
709 tool: {
710 name: "gluecron_close_issue",
711 description:
712 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
523ddadccanty labs713 annotations: { title: "Close issue", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude714 inputSchema: {
715 type: "object",
716 properties: {
717 owner: { type: "string", description: "Repo owner username" },
718 repo: { type: "string", description: "Repo name" },
719 number: { type: "number", description: "Issue number" },
720 },
721 required: ["owner", "repo", "number"],
722 },
723 },
724 async run(args, ctx) {
725 const owner = argString(args, "owner");
726 const repo = argString(args, "repo");
727 const number = argNumber(args, "number");
728
729 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
730 const issue = await loadIssueByNumber(gate.repoId, number);
731 if (!issue) {
732 throw new McpError(
733 ERR_METHOD_NOT_FOUND,
734 `issue not found: ${owner}/${repo}#${number}`
735 );
736 }
737
738 if (issue.state !== "closed") {
739 await db
740 .update(issues)
741 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
742 .where(eq(issues.id, issue.id));
743
744 await audit({
745 userId: gate.userId,
746 repositoryId: gate.repoId,
747 action: "issue.closed",
748 targetType: "issue",
749 targetId: issue.id,
750 metadata: { source: "mcp", number },
751 });
a54e588Test User752 void fireWebhooks(gate.repoId, "issue", { action: "closed", number });
6551045Claude753 }
754
755 return { state: "closed" };
756 },
757};
758
759// ---------------------------------------------------------------------------
760// gluecron_reopen_issue
761// ---------------------------------------------------------------------------
762
763const reopenIssue: McpToolHandler = {
764 tool: {
765 name: "gluecron_reopen_issue",
766 description:
767 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs768 // Reopen merely undoes a close (trivially reversible) — not destructive.
769 annotations: { title: "Reopen issue", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
6551045Claude770 inputSchema: {
771 type: "object",
772 properties: {
773 owner: { type: "string", description: "Repo owner username" },
774 repo: { type: "string", description: "Repo name" },
775 number: { type: "number", description: "Issue number" },
776 },
777 required: ["owner", "repo", "number"],
778 },
779 },
780 async run(args, ctx) {
781 const owner = argString(args, "owner");
782 const repo = argString(args, "repo");
783 const number = argNumber(args, "number");
784
785 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
786 const issue = await loadIssueByNumber(gate.repoId, number);
787 if (!issue) {
788 throw new McpError(
789 ERR_METHOD_NOT_FOUND,
790 `issue not found: ${owner}/${repo}#${number}`
791 );
792 }
793
794 if (issue.state !== "open") {
795 await db
796 .update(issues)
797 .set({ state: "open", closedAt: null, updatedAt: new Date() })
798 .where(eq(issues.id, issue.id));
799
800 await audit({
801 userId: gate.userId,
802 repositoryId: gate.repoId,
803 action: "issue.reopened",
804 targetType: "issue",
805 targetId: issue.id,
806 metadata: { source: "mcp", number },
807 });
a54e588Test User808 void fireWebhooks(gate.repoId, "issue", { action: "reopened", number });
6551045Claude809 }
810
811 return { state: "open" };
812 },
813};
814
815// ---------------------------------------------------------------------------
816// gluecron_create_pr
817// ---------------------------------------------------------------------------
818
819const createPr: McpToolHandler = {
820 tool: {
821 name: "gluecron_create_pr",
822 description:
823 "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 labs824 annotations: { title: "Create pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude825 inputSchema: {
826 type: "object",
827 properties: {
828 owner: { type: "string", description: "Repo owner username" },
829 repo: { type: "string", description: "Repo name" },
830 title: { type: "string", description: "PR title" },
831 body: { type: "string", description: "PR body (Markdown). Optional." },
832 head_branch: { type: "string", description: "Branch with the changes" },
833 base_branch: {
834 type: "string",
835 description: "Target branch (default: repo default branch)",
836 },
837 },
838 required: ["owner", "repo", "title", "head_branch"],
839 },
840 },
841 async run(args, ctx) {
842 const owner = argString(args, "owner");
843 const repo = argString(args, "repo");
844 const title = argString(args, "title");
845 const body = argString(args, "body", "");
846 const headBranch = argString(args, "head_branch");
847
848 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
849 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
850
851 if (baseBranch === headBranch) {
852 throw new McpError(
853 ERR_INVALID_PARAMS,
854 "base and head branches must be different"
855 );
856 }
857
858 const [pr] = await db
859 .insert(pullRequests)
860 .values({
861 repositoryId: gate.repoId,
862 authorId: gate.userId,
863 title,
864 body: body || null,
865 baseBranch,
866 headBranch,
867 })
868 .returning();
869
870 await audit({
871 userId: gate.userId,
872 repositoryId: gate.repoId,
873 action: "pr.opened",
874 targetType: "pull_request",
875 targetId: pr.id,
876 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
877 });
878
b7b5f75ccanty labs879 void logActivity({
880 repositoryId: gate.repoId,
881 userId: gate.userId,
882 action: "pr_open",
883 targetType: "pull_request",
884 targetId: String(pr.number),
885 metadata: { source: "mcp", baseBranch, headBranch },
886 });
a54e588Test User887 void fireWebhooks(gate.repoId, "pr", { action: "opened", number: pr.number, baseBranch, headBranch });
b7b5f75ccanty labs888
79a52c8ccantynz-alt889 // PRs opened through the MCP write surface (exactly how an external AI
890 // platform or the agent-journey certification drives this) never
891 // triggered AI review or triage -- both were wired into the web
892 // /pulls/new route only. Mirror that here so MCP-created PRs get the
893 // same treatment as web-created ones.
894 if (isAiReviewEnabled()) {
895 triggerAiReview(owner, repo, pr.id, title, body || "", baseBranch, headBranch).catch(
896 (err) => console.error("[mcp] triggerAiReview failed:", err)
897 );
898 }
899 triggerPrTriage({
900 ownerName: owner,
901 repoName: repo,
902 repositoryId: gate.repoId,
903 prId: pr.id,
904 prAuthorId: gate.userId,
905 title,
906 body: body || "",
907 baseBranch,
908 headBranch,
909 }).catch((err) => console.error("[mcp] triggerPrTriage failed:", err));
910
6551045Claude911 if (gate.ownerId !== gate.userId) {
912 notify(gate.ownerId, {
913 kind: "pr_opened",
914 title: `New PR: ${title}`,
915 url: prUrl(owner, repo, pr.number),
916 repositoryId: gate.repoId,
a28cedeClaude917 }).catch((err) => {
918 console.warn(
919 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
920 err instanceof Error ? err.message : err
921 );
922 });
6551045Claude923 }
924
925 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
926 },
927};
928
929// ---------------------------------------------------------------------------
930// gluecron_get_pr
931// ---------------------------------------------------------------------------
932
933const getPr: McpToolHandler = {
934 tool: {
935 name: "gluecron_get_pr",
936 description:
937 "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 labs938 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude939 inputSchema: {
940 type: "object",
941 properties: {
942 owner: { type: "string", description: "Repo owner username" },
943 repo: { type: "string", description: "Repo name" },
944 number: { type: "number", description: "PR number" },
945 },
946 required: ["owner", "repo", "number"],
947 },
948 },
949 async run(args, ctx) {
950 const owner = argString(args, "owner");
951 const repo = argString(args, "repo");
952 const number = argNumber(args, "number");
953
954 requireAuthedCtx(ctx, "gluecron_get_pr");
955 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
956
957 const pr = await loadPrByNumber(info.repoId, number);
958 if (!pr) {
959 throw new McpError(
960 ERR_METHOD_NOT_FOUND,
961 `pr not found: ${owner}/${repo}#${number}`
962 );
963 }
964
965 const [author] = await db
966 .select({ username: users.username })
967 .from(users)
968 .where(eq(users.id, pr.authorId))
969 .limit(1);
970
971 // Best-effort mergeability hint via a quick head-ref resolve.
972 let mergeable: boolean | null = null;
973 try {
974 const headSha = await resolveRef(owner, repo, pr.headBranch);
975 mergeable = headSha ? true : null;
976 } catch {
977 mergeable = null;
978 }
979
980 return {
981 number: pr.number,
982 title: pr.title,
983 body: pr.body || "",
984 state: pr.state,
985 baseBranch: pr.baseBranch,
986 headBranch: pr.headBranch,
987 isDraft: pr.isDraft,
988 mergeable,
989 author: author?.username ?? null,
990 createdAt: pr.createdAt,
991 updatedAt: pr.updatedAt,
992 mergedAt: pr.mergedAt,
993 closedAt: pr.closedAt,
994 url: prUrl(owner, repo, pr.number),
995 };
996 },
997};
998
999// ---------------------------------------------------------------------------
1000// gluecron_list_prs
1001// ---------------------------------------------------------------------------
1002
1003const listPrs: McpToolHandler = {
1004 tool: {
1005 name: "gluecron_list_prs",
1006 description:
1007 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs1008 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude1009 inputSchema: {
1010 type: "object",
1011 properties: {
1012 owner: { type: "string", description: "Repo owner username" },
1013 repo: { type: "string", description: "Repo name" },
1014 state: {
1015 type: "string",
1016 description: "open | closed | merged | all (default: open)",
1017 },
1018 },
1019 required: ["owner", "repo"],
1020 },
1021 },
1022 async run(args, ctx) {
1023 const owner = argString(args, "owner");
1024 const repo = argString(args, "repo");
1025 const state = argString(args, "state", "open");
1026 if (!["open", "closed", "merged", "all"].includes(state)) {
1027 throw new McpError(
1028 ERR_INVALID_PARAMS,
1029 `state must be one of open|closed|merged|all (got "${state}")`
1030 );
1031 }
1032
1033 requireAuthedCtx(ctx, "gluecron_list_prs");
1034 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1035
1036 const whereClause =
1037 state === "all"
1038 ? eq(pullRequests.repositoryId, info.repoId)
1039 : and(
1040 eq(pullRequests.repositoryId, info.repoId),
1041 eq(pullRequests.state, state)
1042 );
1043
1044 const rows = await db
1045 .select({
1046 number: pullRequests.number,
1047 title: pullRequests.title,
1048 state: pullRequests.state,
1049 baseBranch: pullRequests.baseBranch,
1050 headBranch: pullRequests.headBranch,
1051 isDraft: pullRequests.isDraft,
1052 authorUsername: users.username,
1053 createdAt: pullRequests.createdAt,
1054 updatedAt: pullRequests.updatedAt,
1055 })
1056 .from(pullRequests)
1057 .innerJoin(users, eq(pullRequests.authorId, users.id))
1058 .where(whereClause)
1059 .orderBy(desc(pullRequests.createdAt))
1060 .limit(50);
1061
1062 return {
1063 total: rows.length,
1064 prs: rows.map((p) => ({
1065 number: p.number,
1066 title: p.title,
1067 state: p.state,
1068 baseBranch: p.baseBranch,
1069 headBranch: p.headBranch,
1070 isDraft: p.isDraft,
1071 author: p.authorUsername,
1072 createdAt: p.createdAt,
1073 updatedAt: p.updatedAt,
1074 url: prUrl(owner, repo, p.number),
1075 })),
1076 };
1077 },
1078};
1079
1080// ---------------------------------------------------------------------------
1081// gluecron_comment_pr
1082// ---------------------------------------------------------------------------
1083
1084const commentPr: McpToolHandler = {
1085 tool: {
1086 name: "gluecron_comment_pr",
1087 description:
1088 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1089 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1090 inputSchema: {
1091 type: "object",
1092 properties: {
1093 owner: { type: "string", description: "Repo owner username" },
1094 repo: { type: "string", description: "Repo name" },
1095 number: { type: "number", description: "PR number" },
1096 body: { type: "string", description: "Comment body (Markdown)" },
1097 },
1098 required: ["owner", "repo", "number", "body"],
1099 },
1100 },
1101 async run(args, ctx) {
1102 const owner = argString(args, "owner");
1103 const repo = argString(args, "repo");
1104 const number = argNumber(args, "number");
1105 const body = argString(args, "body");
1106
1107 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1108 const pr = await loadPrByNumber(gate.repoId, number);
1109 if (!pr) {
1110 throw new McpError(
1111 ERR_METHOD_NOT_FOUND,
1112 `pr not found: ${owner}/${repo}#${number}`
1113 );
1114 }
1115
1116 const [inserted] = await db
1117 .insert(prComments)
1118 .values({
1119 pullRequestId: pr.id,
1120 authorId: gate.userId,
1121 body,
1122 })
1123 .returning();
1124
1125 try {
1126 const { publish } = await import("./sse");
1127 publish(`repo:${gate.repoId}:pr:${number}`, {
1128 event: "pr-comment",
1129 data: {
1130 pullRequestId: pr.id,
1131 commentId: inserted.id,
1132 authorId: gate.userId,
1133 authorUsername: null,
1134 },
1135 });
1136 } catch {
1137 /* SSE best-effort */
1138 }
1139
1140 await audit({
1141 userId: gate.userId,
1142 repositoryId: gate.repoId,
1143 action: "pr.commented",
1144 targetType: "pull_request",
1145 targetId: pr.id,
1146 metadata: { source: "mcp", number },
1147 });
1148
b7b5f75ccanty labs1149 void logActivity({
1150 repositoryId: gate.repoId,
1151 userId: gate.userId,
1152 action: "comment",
1153 targetType: "pull_request",
1154 targetId: String(number),
1155 metadata: { source: "mcp", commentId: inserted.id },
1156 });
a54e588Test User1157 void fireWebhooks(gate.repoId, "pr", { action: "commented", number });
b7b5f75ccanty labs1158
6551045Claude1159 return { commentId: inserted.id };
1160 },
1161};
1162
1163// ---------------------------------------------------------------------------
1164// gluecron_merge_pr
1165// ---------------------------------------------------------------------------
1166
1167const mergePr: McpToolHandler = {
1168 tool: {
1169 name: "gluecron_merge_pr",
1170 description:
534f04aClaude1171 "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 labs1172 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1173 inputSchema: {
1174 type: "object",
1175 properties: {
1176 owner: { type: "string", description: "Repo owner username" },
1177 repo: { type: "string", description: "Repo name" },
1178 number: { type: "number", description: "PR number" },
534f04aClaude1179 confirm_high_risk: {
1180 type: "boolean",
1181 description:
1182 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1183 },
6551045Claude1184 },
1185 required: ["owner", "repo", "number"],
1186 },
1187 },
1188 async run(args, ctx) {
1189 const owner = argString(args, "owner");
1190 const repo = argString(args, "repo");
1191 const number = argNumber(args, "number");
534f04aClaude1192 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1193
1194 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1195 const pr = await loadPrByNumber(gate.repoId, number);
1196 if (!pr) {
1197 throw new McpError(
1198 ERR_METHOD_NOT_FOUND,
1199 `pr not found: ${owner}/${repo}#${number}`
1200 );
1201 }
1202 if (pr.state !== "open") {
1203 return { merged: false, reason: `pr is ${pr.state}, not open` };
1204 }
1205 if (pr.isDraft) {
1206 return {
1207 merged: false,
1208 reason: "This PR is a draft. Mark it as ready for review before merging.",
1209 };
1210 }
1211
534f04aClaude1212 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1213 // fall back to most-recent cached row; finally compute on demand so the
1214 // MCP caller always gets a score (HTTP path is async + tolerant of a
1215 // missing score, but MCP callers want an answer in one round trip).
1216 let risk: PrRiskScore | null = null;
1217 try {
1218 risk =
1219 (await getCachedPrRisk(pr.id)) ||
1220 (await getLatestCachedPrRisk(pr.id)) ||
1221 (await computePrRiskForPullRequest(pr.id));
1222 } catch {
1223 risk = null;
1224 }
1225
1226 if (risk && risk.band === "critical" && !confirmHighRisk) {
1227 return {
1228 merged: false,
1229 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1230 riskScore: serialisePrRiskForResponse(risk),
1231 };
1232 }
1233
6551045Claude1234 const headSha = await resolveRef(owner, repo, pr.headBranch);
1235 if (!headSha) {
1236 return { merged: false, reason: "Head branch not found" };
1237 }
1238
1239 // AI review approval signal — same heuristic as routes/pulls.tsx.
1240 const aiComments = await db
1241 .select()
1242 .from(prComments)
1243 .where(
1244 and(
1245 eq(prComments.pullRequestId, pr.id),
1246 eq(prComments.isAiReview, true)
1247 )
1248 );
1249 const aiApproved =
1250 aiComments.length === 0 ||
1251 aiComments.some(
1252 (c) =>
1253 c.body.includes("**Approved**") ||
1254 c.body.includes("approved: true") ||
1255 c.body.toLowerCase().includes("lgtm")
1256 );
1257
1258 const gateResult = await runAllGateChecks(
1259 owner,
1260 repo,
1261 pr.baseBranch,
1262 pr.headBranch,
1263 headSha,
1264 aiApproved
1265 );
1266
1267 const hardFailures = gateResult.checks.filter(
1268 (check) => !check.passed && check.name !== "Merge check"
1269 );
1270 if (hardFailures.length > 0) {
1271 return {
1272 merged: false,
1273 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1274 };
1275 }
1276
1277 // D5 — branch-protection enforcement
1278 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1279 if (protectionRule) {
1280 const humanApprovals = await countHumanApprovals(pr.id);
1281 const required = await listRequiredChecks(protectionRule.id);
1282 const passingNames =
1283 required.length > 0
1284 ? await passingCheckNames(gate.repoId, headSha)
1285 : [];
1286 const decision = evaluateProtection(
1287 protectionRule,
1288 {
1289 aiApproved,
1290 humanApprovalCount: humanApprovals,
1291 gateResultGreen: hardFailures.length === 0,
1292 hasFailedGates: hardFailures.length > 0,
1293 passingCheckNames: passingNames,
1294 },
1295 required.map((r) => r.checkName)
1296 );
91b054eccanty labs1297
1298 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1299 // when the rule already requires human review at all (no new DB
1300 // column for this pass). Fail-open on any internal error: a bug here
1301 // must never hard-block every merge platform-wide. Mirrors the HTTP
1302 // merge path in routes/pulls.tsx.
1303 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1304 try {
1305 const codeownersDiffProc = Bun.spawn(
1306 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1307 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1308 );
1309 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1310 await codeownersDiffProc.exited;
1311 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1312 if (changedPaths.length > 0) {
1313 const { satisfied, missingOwners } = await requiredOwnersApproved(
1314 owner,
1315 repo,
1316 gate.defaultBranch,
1317 pr.id,
1318 changedPaths
1319 );
1320 if (!satisfied) {
1321 decision.allowed = false;
1322 decision.reasons.push(
1323 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1324 );
1325 }
1326 }
1327 } catch (err) {
1328 console.warn(
1329 "[codeowners] merge enforcement failed:",
1330 err instanceof Error ? err.message : err
1331 );
1332 }
1333 }
1334
6551045Claude1335 if (!decision.allowed) {
1336 return { merged: false, reason: decision.reasons.join(" ") };
1337 }
1338 }
1339
1340 const repoDir = getRepoPath(owner, repo);
1341 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1342 const hasConflicts = mergeCheck && !mergeCheck.passed;
1343
1344 if (hasConflicts && isAiReviewEnabled()) {
1345 const mergeResult = await mergeWithAutoResolve(
1346 owner,
1347 repo,
1348 pr.baseBranch,
1349 pr.headBranch,
1350 `Merge pull request #${pr.number}: ${pr.title}`
1351 );
1352 if (!mergeResult.success) {
1353 return {
1354 merged: false,
1355 reason: mergeResult.error || "Auto-merge failed",
1356 };
1357 }
1358 if (mergeResult.resolvedFiles.length > 0) {
1359 await db.insert(prComments).values({
1360 pullRequestId: pr.id,
1361 authorId: gate.userId,
1362 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1363 .map((f) => `- \`${f}\``)
1364 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1365 isAiReview: true,
1366 });
1367 }
1368 } else {
1369 const ffProc = Bun.spawn(
1370 [
1371 "git",
1372 "update-ref",
1373 `refs/heads/${pr.baseBranch}`,
1374 `refs/heads/${pr.headBranch}`,
1375 ],
1376 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1377 );
1378 const ffExit = await ffProc.exited;
1379 if (ffExit !== 0) {
1380 return {
1381 merged: false,
1382 reason: "Merge failed — unable to update branch ref",
1383 };
1384 }
1385 }
1386
1387 await db
1388 .update(pullRequests)
1389 .set({
1390 state: "merged",
1391 mergedAt: new Date(),
1392 mergedBy: gate.userId,
1393 updatedAt: new Date(),
1394 })
1395 .where(eq(pullRequests.id, pr.id));
1396
1397 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1398 try {
1399 const { extractClosingRefsMulti } = await import("./close-keywords");
1400 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1401 for (const n of refs) {
1402 const target = await loadIssueByNumber(gate.repoId, n);
1403 if (!target || target.state !== "open") continue;
1404 await db
1405 .update(issues)
1406 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1407 .where(eq(issues.id, target.id));
1408 await db.insert(issueComments).values({
1409 issueId: target.id,
1410 authorId: gate.userId,
1411 body: `Closed by pull request #${pr.number}.`,
1412 });
1413 }
1414 } catch {
1415 /* never block merge on close-keyword failures */
1416 }
1417
1418 await audit({
1419 userId: gate.userId,
1420 repositoryId: gate.repoId,
1421 action: "pr.merged",
1422 targetType: "pull_request",
1423 targetId: pr.id,
1424 metadata: { source: "mcp", number, sha: headSha },
1425 });
1426
b7b5f75ccanty labs1427 void logActivity({
1428 repositoryId: gate.repoId,
1429 userId: gate.userId,
1430 action: "pr_merge",
1431 targetType: "pull_request",
1432 targetId: String(number),
1433 metadata: { source: "mcp", sha: headSha },
1434 });
a54e588Test User1435 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1436
6551045Claude1437 // Resolved post-merge SHA (best effort).
1438 let mergedSha: string | null = null;
1439 try {
1440 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1441 } catch {
1442 mergedSha = null;
1443 }
1444
534f04aClaude1445 // Block M3 — informational payload: when the risk score is high or
1446 // critical, include the score + summary in the response even on a
1447 // successful merge so the caller has the audit context. Low/medium
1448 // bands stay quiet to keep response noise low.
1449 const response: {
1450 merged: true;
1451 sha: string;
1452 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1453 } = {
6551045Claude1454 merged: true,
1455 sha: mergedSha ?? headSha,
1456 };
534f04aClaude1457 if (risk && (risk.band === "high" || risk.band === "critical")) {
1458 response.riskScore = serialisePrRiskForResponse(risk);
1459 }
1460 return response;
6551045Claude1461 },
1462};
1463
534f04aClaude1464/**
1465 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1466 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1467 */
1468function serialisePrRiskForResponse(risk: PrRiskScore) {
1469 return {
1470 score: risk.score,
1471 band: risk.band,
1472 aiSummary: risk.aiSummary,
1473 commitSha: risk.commitSha,
1474 signals: risk.signals,
1475 generatedAt:
1476 risk.generatedAt instanceof Date
1477 ? risk.generatedAt.toISOString()
1478 : String(risk.generatedAt),
1479 };
1480}
1481
6551045Claude1482// ---------------------------------------------------------------------------
1483// gluecron_close_pr
1484// ---------------------------------------------------------------------------
1485
1486const closePr: McpToolHandler = {
1487 tool: {
1488 name: "gluecron_close_pr",
1489 description:
1490 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1491 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1492 inputSchema: {
1493 type: "object",
1494 properties: {
1495 owner: { type: "string", description: "Repo owner username" },
1496 repo: { type: "string", description: "Repo name" },
1497 number: { type: "number", description: "PR number" },
1498 },
1499 required: ["owner", "repo", "number"],
1500 },
1501 },
1502 async run(args, ctx) {
1503 const owner = argString(args, "owner");
1504 const repo = argString(args, "repo");
1505 const number = argNumber(args, "number");
1506
1507 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1508 const pr = await loadPrByNumber(gate.repoId, number);
1509 if (!pr) {
1510 throw new McpError(
1511 ERR_METHOD_NOT_FOUND,
1512 `pr not found: ${owner}/${repo}#${number}`
1513 );
1514 }
1515
1516 if (pr.state === "open") {
1517 await db
1518 .update(pullRequests)
1519 .set({
1520 state: "closed",
1521 closedAt: new Date(),
1522 updatedAt: new Date(),
1523 })
1524 .where(eq(pullRequests.id, pr.id));
1525
1526 await audit({
1527 userId: gate.userId,
1528 repositoryId: gate.repoId,
1529 action: "pr.closed",
1530 targetType: "pull_request",
1531 targetId: pr.id,
1532 metadata: { source: "mcp", number },
1533 });
a54e588Test User1534 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1535 }
1536
1537 return { state: pr.state === "merged" ? "merged" : "closed" };
1538 },
1539};
1540
1541function sqlExpr(expr: string) {
1542 return drizzleSql.raw(expr);
1543}
1544
2c2163eClaude1545// ---------------------------------------------------------------------------
1546// Default tool registry
1547// ---------------------------------------------------------------------------
1548
1549export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1550 // The original 15-tool surface (read + K1 write) remains the source of
1551 // truth for the core gate-enforced workflows. The expanded set in
1552 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1553 // adapter over an existing lib helper — so AI agents can drive
1554 // Gluecron end-to-end.
1555 //
1556 // The expanded module imports the `mcp*` helpers re-exported above —
1557 // since those are `const` exports they're populated by the time
1558 // `defaultTools()` is called at request time, so the circular ESM
1559 // import resolves cleanly.
2c2163eClaude1560 return {
1561 [repoSearch.tool.name]: repoSearch,
1562 [repoReadFile.tool.name]: repoReadFile,
1563 [repoListIssues.tool.name]: repoListIssues,
1564 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1565 [repoHealth.tool.name]: repoHealth,
6551045Claude1566 // Block K1 — write surface
1567 [createIssue.tool.name]: createIssue,
1568 [commentIssue.tool.name]: commentIssue,
1569 [closeIssue.tool.name]: closeIssue,
1570 [reopenIssue.tool.name]: reopenIssue,
1571 [createPr.tool.name]: createPr,
1572 [getPr.tool.name]: getPr,
1573 [listPrs.tool.name]: listPrs,
1574 [commentPr.tool.name]: commentPr,
1575 [mergePr.tool.name]: mergePr,
1576 [closePr.tool.name]: closePr,
0feb720Claude1577 // Expanded surface (50+ tools total)
1578 ...expandedTools(),
2c2163eClaude1579 };
1580}
1581
0feb720Claude1582// ---------------------------------------------------------------------------
1583// Shared helpers (re-exported so the expanded tool surface in
1584// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1585// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1586// them keeps the wrapper modules tiny.
1587// ---------------------------------------------------------------------------
1588
1589export {
1590 argString as mcpArgString,
1591 argNumber as mcpArgNumber,
1592 gateWriteAccess as mcpGateWriteAccess,
1593 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1594 requireAuthedCtx as mcpRequireAuthedCtx,
1595 loadPrByNumber as mcpLoadPrByNumber,
1596 loadIssueByNumber as mcpLoadIssueByNumber,
1597 prUrl as mcpPrUrl,
1598 issueUrl as mcpIssueUrl,
1599};
1600
2c2163eClaude1601/** Test-only export of internal helpers + per-tool handlers. */
1602export const __test = {
1603 argString,
1604 argNumber,
1605 repoSearch,
1606 repoReadFile,
1607 repoListIssues,
1608 repoExplain,
f5a18f9Claude1609 repoHealth,
6551045Claude1610 // Block K1 — write surface
1611 createIssue,
1612 commentIssue,
1613 closeIssue,
1614 reopenIssue,
1615 createPr,
1616 getPr,
1617 listPrs,
1618 commentPr,
1619 mergePr,
1620 closePr,
1621 gateWriteAccess,
1622 resolveAccessibleRepo,
2c2163eClaude1623};