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.tsBlame1606 lines · 4 contributors
2c2163eClaude1/**
2 * MCP tool handlers — read-only v1 set.
3 *
4 * Each handler returns either a string (auto-wrapped to text content)
5 * or the full MCP `{content: [...]}` shape. Errors throw `McpError` from
6 * `mcp.ts` so the router can surface them as JSON-RPC -32xxx codes.
7 *
8 * Tool surface (v1, all read-only):
9 * - gluecron_repo_search — search public repos by keyword
10 * - gluecron_repo_read_file — read a file from a repo at a ref
11 * - gluecron_repo_list_issues — list open issues for a repo
12 * - gluecron_repo_explain_codebase — return cached AI explanation
13 *
14 * v2 will add write tools (create_issue, post_comment, run_workflow)
15 * gated on `userId` + write-access on the target repo.
16 */
17
6551045Claude18import { and, asc, desc, eq, like, or, sql as drizzleSql } from "drizzle-orm";
2c2163eClaude19import { db } from "../db";
20import {
21 issues,
6551045Claude22 issueComments,
23 pullRequests,
24 prComments,
2c2163eClaude25 repositories,
26 users,
27 codebaseExplanations,
28} from "../db/schema";
6551045Claude29import { getBlob, repoExists, resolveRef, getRepoPath } from "../git/repository";
f5a18f9Claude30import { computeHealthScore } from "./intelligence";
2c2163eClaude31import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
32import type { McpContext } from "./mcp";
6551045Claude33import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
34import type { RepoAccessLevel } from "../middleware/repo-access";
b7b5f75ccanty labs35import { notify, audit, logActivity } from "./notify";
a54e588Test User36import { fireWebhooks } from "../routes/webhooks";
6551045Claude37import { runAllGateChecks } from "./gate";
38import {
39 matchProtection,
40 countHumanApprovals,
41 listRequiredChecks,
42 passingCheckNames,
43 evaluateProtection,
44} from "./branch-protection";
45import { mergeWithAutoResolve } from "./merge-resolver";
c166384ccantynz-alt46import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } from "./ai-review";
79a52c8ccantynz-alt47import { triggerPrTriage } from "./pr-triage";
91b054eccanty labs48import { requiredOwnersApproved } from "./codeowners";
534f04aClaude49import {
50 computePrRiskForPullRequest,
51 getCachedPrRisk,
52 getLatestCachedPrRisk,
53 type PrRiskScore,
54} from "./pr-risk";
0feb720Claude55import { expandedTools } from "./mcp-tools-expanded";
2c2163eClaude56
523ddadccanty labs57/**
58 * MCP spec ToolAnnotations — behavioural hints surfaced to clients via
59 * tools/list. Required for Anthropic connector-directory review. These are
60 * hints only; they never gate execution server-side.
61 */
62export type McpToolAnnotations = {
63 title?: string;
64 readOnlyHint?: boolean;
65 destructiveHint?: boolean;
66 idempotentHint?: boolean;
67 openWorldHint?: boolean;
68};
69
2c2163eClaude70export type McpTool = {
71 name: string;
72 description: string;
523ddadccanty labs73 annotations?: McpToolAnnotations;
2c2163eClaude74 inputSchema: {
75 type: "object";
76 properties: Record<string, { type: string; description?: string }>;
77 required?: string[];
78 };
79};
80
81export type McpToolHandler = {
82 tool: McpTool;
83 run: (
84 args: Record<string, unknown>,
85 ctx: McpContext
86 ) => Promise<unknown>;
87};
88
89const argString = (
90 args: Record<string, unknown>,
91 key: string,
92 fallback?: string
93): string => {
94 const v = args[key];
95 if (typeof v === "string" && v.trim().length > 0) return v.trim();
96 if (fallback !== undefined) return fallback;
97 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' is required`);
98};
99
100const argNumber = (
101 args: Record<string, unknown>,
102 key: string,
103 fallback?: number
104): number => {
105 const v = args[key];
106 if (typeof v === "number" && Number.isFinite(v)) return v;
107 if (typeof v === "string" && /^\d+$/.test(v)) return Number.parseInt(v, 10);
108 if (fallback !== undefined) return fallback;
109 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`);
110};
111
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
c166384ccantynz-alt1239 const aiApproved = await isAiReviewApproved(pr.id);
6551045Claude1240
1241 const gateResult = await runAllGateChecks(
1242 owner,
1243 repo,
1244 pr.baseBranch,
1245 pr.headBranch,
1246 headSha,
1247 aiApproved
1248 );
1249
1250 const hardFailures = gateResult.checks.filter(
1251 (check) => !check.passed && check.name !== "Merge check"
1252 );
1253 if (hardFailures.length > 0) {
1254 return {
1255 merged: false,
1256 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1257 };
1258 }
1259
1260 // D5 — branch-protection enforcement
1261 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1262 if (protectionRule) {
1263 const humanApprovals = await countHumanApprovals(pr.id);
1264 const required = await listRequiredChecks(protectionRule.id);
1265 const passingNames =
1266 required.length > 0
1267 ? await passingCheckNames(gate.repoId, headSha)
1268 : [];
1269 const decision = evaluateProtection(
1270 protectionRule,
1271 {
1272 aiApproved,
1273 humanApprovalCount: humanApprovals,
1274 gateResultGreen: hardFailures.length === 0,
1275 hasFailedGates: hardFailures.length > 0,
1276 passingCheckNames: passingNames,
1277 },
1278 required.map((r) => r.checkName)
1279 );
91b054eccanty labs1280
1281 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1282 // when the rule already requires human review at all (no new DB
1283 // column for this pass). Fail-open on any internal error: a bug here
1284 // must never hard-block every merge platform-wide. Mirrors the HTTP
1285 // merge path in routes/pulls.tsx.
1286 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1287 try {
1288 const codeownersDiffProc = Bun.spawn(
1289 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1290 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1291 );
1292 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1293 await codeownersDiffProc.exited;
1294 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1295 if (changedPaths.length > 0) {
1296 const { satisfied, missingOwners } = await requiredOwnersApproved(
1297 owner,
1298 repo,
1299 gate.defaultBranch,
1300 pr.id,
1301 changedPaths
1302 );
1303 if (!satisfied) {
1304 decision.allowed = false;
1305 decision.reasons.push(
1306 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1307 );
1308 }
1309 }
1310 } catch (err) {
1311 console.warn(
1312 "[codeowners] merge enforcement failed:",
1313 err instanceof Error ? err.message : err
1314 );
1315 }
1316 }
1317
6551045Claude1318 if (!decision.allowed) {
1319 return { merged: false, reason: decision.reasons.join(" ") };
1320 }
1321 }
1322
1323 const repoDir = getRepoPath(owner, repo);
1324 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1325 const hasConflicts = mergeCheck && !mergeCheck.passed;
1326
1327 if (hasConflicts && isAiReviewEnabled()) {
1328 const mergeResult = await mergeWithAutoResolve(
1329 owner,
1330 repo,
1331 pr.baseBranch,
1332 pr.headBranch,
1333 `Merge pull request #${pr.number}: ${pr.title}`
1334 );
1335 if (!mergeResult.success) {
1336 return {
1337 merged: false,
1338 reason: mergeResult.error || "Auto-merge failed",
1339 };
1340 }
1341 if (mergeResult.resolvedFiles.length > 0) {
1342 await db.insert(prComments).values({
1343 pullRequestId: pr.id,
1344 authorId: gate.userId,
1345 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1346 .map((f) => `- \`${f}\``)
1347 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1348 isAiReview: true,
1349 });
1350 }
1351 } else {
1352 const ffProc = Bun.spawn(
1353 [
1354 "git",
1355 "update-ref",
1356 `refs/heads/${pr.baseBranch}`,
1357 `refs/heads/${pr.headBranch}`,
1358 ],
1359 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1360 );
1361 const ffExit = await ffProc.exited;
1362 if (ffExit !== 0) {
1363 return {
1364 merged: false,
1365 reason: "Merge failed — unable to update branch ref",
1366 };
1367 }
1368 }
1369
1370 await db
1371 .update(pullRequests)
1372 .set({
1373 state: "merged",
1374 mergedAt: new Date(),
1375 mergedBy: gate.userId,
1376 updatedAt: new Date(),
1377 })
1378 .where(eq(pullRequests.id, pr.id));
1379
1380 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1381 try {
1382 const { extractClosingRefsMulti } = await import("./close-keywords");
1383 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1384 for (const n of refs) {
1385 const target = await loadIssueByNumber(gate.repoId, n);
1386 if (!target || target.state !== "open") continue;
1387 await db
1388 .update(issues)
1389 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1390 .where(eq(issues.id, target.id));
1391 await db.insert(issueComments).values({
1392 issueId: target.id,
1393 authorId: gate.userId,
1394 body: `Closed by pull request #${pr.number}.`,
1395 });
1396 }
1397 } catch {
1398 /* never block merge on close-keyword failures */
1399 }
1400
1401 await audit({
1402 userId: gate.userId,
1403 repositoryId: gate.repoId,
1404 action: "pr.merged",
1405 targetType: "pull_request",
1406 targetId: pr.id,
1407 metadata: { source: "mcp", number, sha: headSha },
1408 });
1409
b7b5f75ccanty labs1410 void logActivity({
1411 repositoryId: gate.repoId,
1412 userId: gate.userId,
1413 action: "pr_merge",
1414 targetType: "pull_request",
1415 targetId: String(number),
1416 metadata: { source: "mcp", sha: headSha },
1417 });
a54e588Test User1418 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1419
6551045Claude1420 // Resolved post-merge SHA (best effort).
1421 let mergedSha: string | null = null;
1422 try {
1423 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1424 } catch {
1425 mergedSha = null;
1426 }
1427
534f04aClaude1428 // Block M3 — informational payload: when the risk score is high or
1429 // critical, include the score + summary in the response even on a
1430 // successful merge so the caller has the audit context. Low/medium
1431 // bands stay quiet to keep response noise low.
1432 const response: {
1433 merged: true;
1434 sha: string;
1435 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1436 } = {
6551045Claude1437 merged: true,
1438 sha: mergedSha ?? headSha,
1439 };
534f04aClaude1440 if (risk && (risk.band === "high" || risk.band === "critical")) {
1441 response.riskScore = serialisePrRiskForResponse(risk);
1442 }
1443 return response;
6551045Claude1444 },
1445};
1446
534f04aClaude1447/**
1448 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1449 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1450 */
1451function serialisePrRiskForResponse(risk: PrRiskScore) {
1452 return {
1453 score: risk.score,
1454 band: risk.band,
1455 aiSummary: risk.aiSummary,
1456 commitSha: risk.commitSha,
1457 signals: risk.signals,
1458 generatedAt:
1459 risk.generatedAt instanceof Date
1460 ? risk.generatedAt.toISOString()
1461 : String(risk.generatedAt),
1462 };
1463}
1464
6551045Claude1465// ---------------------------------------------------------------------------
1466// gluecron_close_pr
1467// ---------------------------------------------------------------------------
1468
1469const closePr: McpToolHandler = {
1470 tool: {
1471 name: "gluecron_close_pr",
1472 description:
1473 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1474 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1475 inputSchema: {
1476 type: "object",
1477 properties: {
1478 owner: { type: "string", description: "Repo owner username" },
1479 repo: { type: "string", description: "Repo name" },
1480 number: { type: "number", description: "PR number" },
1481 },
1482 required: ["owner", "repo", "number"],
1483 },
1484 },
1485 async run(args, ctx) {
1486 const owner = argString(args, "owner");
1487 const repo = argString(args, "repo");
1488 const number = argNumber(args, "number");
1489
1490 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1491 const pr = await loadPrByNumber(gate.repoId, number);
1492 if (!pr) {
1493 throw new McpError(
1494 ERR_METHOD_NOT_FOUND,
1495 `pr not found: ${owner}/${repo}#${number}`
1496 );
1497 }
1498
1499 if (pr.state === "open") {
1500 await db
1501 .update(pullRequests)
1502 .set({
1503 state: "closed",
1504 closedAt: new Date(),
1505 updatedAt: new Date(),
1506 })
1507 .where(eq(pullRequests.id, pr.id));
1508
1509 await audit({
1510 userId: gate.userId,
1511 repositoryId: gate.repoId,
1512 action: "pr.closed",
1513 targetType: "pull_request",
1514 targetId: pr.id,
1515 metadata: { source: "mcp", number },
1516 });
a54e588Test User1517 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1518 }
1519
1520 return { state: pr.state === "merged" ? "merged" : "closed" };
1521 },
1522};
1523
1524function sqlExpr(expr: string) {
1525 return drizzleSql.raw(expr);
1526}
1527
2c2163eClaude1528// ---------------------------------------------------------------------------
1529// Default tool registry
1530// ---------------------------------------------------------------------------
1531
1532export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1533 // The original 15-tool surface (read + K1 write) remains the source of
1534 // truth for the core gate-enforced workflows. The expanded set in
1535 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1536 // adapter over an existing lib helper — so AI agents can drive
1537 // Gluecron end-to-end.
1538 //
1539 // The expanded module imports the `mcp*` helpers re-exported above —
1540 // since those are `const` exports they're populated by the time
1541 // `defaultTools()` is called at request time, so the circular ESM
1542 // import resolves cleanly.
2c2163eClaude1543 return {
1544 [repoSearch.tool.name]: repoSearch,
1545 [repoReadFile.tool.name]: repoReadFile,
1546 [repoListIssues.tool.name]: repoListIssues,
1547 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1548 [repoHealth.tool.name]: repoHealth,
6551045Claude1549 // Block K1 — write surface
1550 [createIssue.tool.name]: createIssue,
1551 [commentIssue.tool.name]: commentIssue,
1552 [closeIssue.tool.name]: closeIssue,
1553 [reopenIssue.tool.name]: reopenIssue,
1554 [createPr.tool.name]: createPr,
1555 [getPr.tool.name]: getPr,
1556 [listPrs.tool.name]: listPrs,
1557 [commentPr.tool.name]: commentPr,
1558 [mergePr.tool.name]: mergePr,
1559 [closePr.tool.name]: closePr,
0feb720Claude1560 // Expanded surface (50+ tools total)
1561 ...expandedTools(),
2c2163eClaude1562 };
1563}
1564
0feb720Claude1565// ---------------------------------------------------------------------------
1566// Shared helpers (re-exported so the expanded tool surface in
1567// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1568// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1569// them keeps the wrapper modules tiny.
1570// ---------------------------------------------------------------------------
1571
1572export {
1573 argString as mcpArgString,
1574 argNumber as mcpArgNumber,
1575 gateWriteAccess as mcpGateWriteAccess,
1576 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1577 requireAuthedCtx as mcpRequireAuthedCtx,
1578 loadPrByNumber as mcpLoadPrByNumber,
1579 loadIssueByNumber as mcpLoadIssueByNumber,
1580 prUrl as mcpPrUrl,
1581 issueUrl as mcpIssueUrl,
1582};
1583
2c2163eClaude1584/** Test-only export of internal helpers + per-tool handlers. */
1585export const __test = {
1586 argString,
1587 argNumber,
1588 repoSearch,
1589 repoReadFile,
1590 repoListIssues,
1591 repoExplain,
f5a18f9Claude1592 repoHealth,
6551045Claude1593 // Block K1 — write surface
1594 createIssue,
1595 commentIssue,
1596 closeIssue,
1597 reopenIssue,
1598 createPr,
1599 getPr,
1600 listPrs,
1601 commentPr,
1602 mergePr,
1603 closePr,
1604 gateWriteAccess,
1605 resolveAccessibleRepo,
2c2163eClaude1606};