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.tsBlame1600 lines · 3 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";
46import { isAiReviewEnabled } from "./ai-review";
91b054eccanty labs47import { requiredOwnersApproved } from "./codeowners";
534f04aClaude48import {
49 computePrRiskForPullRequest,
50 getCachedPrRisk,
51 getLatestCachedPrRisk,
52 type PrRiskScore,
53} from "./pr-risk";
0feb720Claude54import { expandedTools } from "./mcp-tools-expanded";
2c2163eClaude55
523ddadccanty labs56/**
57 * MCP spec ToolAnnotations — behavioural hints surfaced to clients via
58 * tools/list. Required for Anthropic connector-directory review. These are
59 * hints only; they never gate execution server-side.
60 */
61export type McpToolAnnotations = {
62 title?: string;
63 readOnlyHint?: boolean;
64 destructiveHint?: boolean;
65 idempotentHint?: boolean;
66 openWorldHint?: boolean;
67};
68
2c2163eClaude69export type McpTool = {
70 name: string;
71 description: string;
523ddadccanty labs72 annotations?: McpToolAnnotations;
2c2163eClaude73 inputSchema: {
74 type: "object";
75 properties: Record<string, { type: string; description?: string }>;
76 required?: string[];
77 };
78};
79
80export type McpToolHandler = {
81 tool: McpTool;
82 run: (
83 args: Record<string, unknown>,
84 ctx: McpContext
85 ) => Promise<unknown>;
86};
87
88const argString = (
89 args: Record<string, unknown>,
90 key: string,
91 fallback?: string
92): string => {
93 const v = args[key];
94 if (typeof v === "string" && v.trim().length > 0) return v.trim();
95 if (fallback !== undefined) return fallback;
96 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' is required`);
97};
98
99const argNumber = (
100 args: Record<string, unknown>,
101 key: string,
102 fallback?: number
103): number => {
104 const v = args[key];
105 if (typeof v === "number" && Number.isFinite(v)) return v;
106 if (typeof v === "string" && /^\d+$/.test(v)) return Number.parseInt(v, 10);
107 if (fallback !== undefined) return fallback;
108 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`);
109};
110
111// ---------------------------------------------------------------------------
112// gluecron_repo_search
113// ---------------------------------------------------------------------------
114
115const repoSearch: McpToolHandler = {
116 tool: {
117 name: "gluecron_repo_search",
118 description:
119 "Search public Gluecron repositories by keyword. Matches against name + description. Returns up to 20 results.",
523ddadccanty labs120 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
2c2163eClaude121 inputSchema: {
122 type: "object",
123 properties: {
124 query: { type: "string", description: "Search keyword (1-100 chars)" },
125 limit: { type: "number", description: "Max results, default 20" },
126 },
127 required: ["query"],
128 },
129 },
130 async run(args) {
131 const q = argString(args, "query");
132 if (q.length > 100) {
133 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
134 }
135 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
136 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
137 const rows = await db
138 .select({
139 id: repositories.id,
140 name: repositories.name,
141 description: repositories.description,
142 ownerName: users.username,
143 stars: repositories.starCount,
144 })
145 .from(repositories)
146 .innerJoin(users, eq(repositories.ownerId, users.id))
147 .where(
148 and(
149 eq(repositories.isPrivate, false),
150 or(
151 like(repositories.name, pattern),
152 like(repositories.description, pattern)
153 )
154 )
155 )
156 .orderBy(desc(repositories.starCount))
157 .limit(limit);
158 return {
159 total: rows.length,
160 repos: rows.map((r) => ({
161 fullName: `${r.ownerName}/${r.name}`,
162 description: r.description || "",
163 stars: r.stars,
164 })),
165 };
166 },
167};
168
169// ---------------------------------------------------------------------------
170// gluecron_repo_read_file
171// ---------------------------------------------------------------------------
172
173const repoReadFile: McpToolHandler = {
174 tool: {
175 name: "gluecron_repo_read_file",
176 description:
177 "Read a single file from a public repository at a given ref (branch / tag / commit). Returns the text content (binary files rejected).",
523ddadccanty labs178 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
2c2163eClaude179 inputSchema: {
180 type: "object",
181 properties: {
182 owner: { type: "string", description: "Repo owner username" },
183 repo: { type: "string", description: "Repo name" },
184 ref: { type: "string", description: "Branch / tag / commit (default: main)" },
185 path: { type: "string", description: "File path within the repo" },
186 },
187 required: ["owner", "repo", "path"],
188 },
189 },
190 async run(args) {
191 const owner = argString(args, "owner");
192 const repo = argString(args, "repo");
193 const ref = argString(args, "ref", "main");
194 const path = argString(args, "path");
195
196 // Visibility check — public-only for v1. (Authed users see private
197 // repos in v2 once we extend the args.)
198 const [r] = await db
199 .select({ isPrivate: repositories.isPrivate })
200 .from(repositories)
201 .innerJoin(users, eq(repositories.ownerId, users.id))
202 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
203 .limit(1);
204 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
205 if (r.isPrivate) {
206 throw new McpError(
207 ERR_METHOD_NOT_FOUND,
208 `${owner}/${repo} is private; v1 MCP read tool is public-only`
209 );
210 }
211
212 const blob = await getBlob(owner, repo, ref, path);
213 if (!blob) {
214 throw new McpError(
215 ERR_METHOD_NOT_FOUND,
216 `path not found: ${owner}/${repo}@${ref}:${path}`
217 );
218 }
219 return {
220 content: [
221 {
222 type: "text",
223 text: blob.content,
224 },
225 ],
226 };
227 },
228};
229
230// ---------------------------------------------------------------------------
231// gluecron_repo_list_issues
232// ---------------------------------------------------------------------------
233
234const repoListIssues: McpToolHandler = {
235 tool: {
236 name: "gluecron_repo_list_issues",
237 description:
238 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
523ddadccanty labs239 annotations: { title: "List open issues", readOnlyHint: true, destructiveHint: false },
2c2163eClaude240 inputSchema: {
241 type: "object",
242 properties: {
243 owner: { type: "string", description: "Repo owner username" },
244 repo: { type: "string", description: "Repo name" },
245 limit: { type: "number", description: "Max results, default 25" },
246 },
247 required: ["owner", "repo"],
248 },
249 },
250 async run(args) {
251 const owner = argString(args, "owner");
252 const repo = argString(args, "repo");
253 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
254
255 const [r] = await db
256 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
257 .from(repositories)
258 .innerJoin(users, eq(repositories.ownerId, users.id))
259 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
260 .limit(1);
261 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
262 if (r.isPrivate) {
263 throw new McpError(
264 ERR_METHOD_NOT_FOUND,
265 `${owner}/${repo} is private; v1 MCP read tool is public-only`
266 );
267 }
268
269 const rows = await db
270 .select({
271 number: issues.number,
272 title: issues.title,
273 body: issues.body,
274 state: issues.state,
275 createdAt: issues.createdAt,
276 })
277 .from(issues)
278 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
279 .orderBy(desc(issues.createdAt))
280 .limit(limit);
281 return {
282 total: rows.length,
283 issues: rows.map((i) => ({
284 number: i.number,
285 title: i.title,
286 body: i.body || "",
287 state: i.state,
288 createdAt: i.createdAt,
289 })),
290 };
291 },
292};
293
294// ---------------------------------------------------------------------------
295// gluecron_repo_explain_codebase
296// ---------------------------------------------------------------------------
297
298const repoExplain: McpToolHandler = {
299 tool: {
300 name: "gluecron_repo_explain_codebase",
301 description:
302 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
523ddadccanty labs303 annotations: { title: "Explain codebase (cached)", readOnlyHint: true, destructiveHint: false },
2c2163eClaude304 inputSchema: {
305 type: "object",
306 properties: {
307 owner: { type: "string", description: "Repo owner username" },
308 repo: { type: "string", description: "Repo name" },
309 },
310 required: ["owner", "repo"],
311 },
312 },
313 async run(args) {
314 const owner = argString(args, "owner");
315 const repo = argString(args, "repo");
316 const [r] = await db
317 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
318 .from(repositories)
319 .innerJoin(users, eq(repositories.ownerId, users.id))
320 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
321 .limit(1);
322 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
323 if (r.isPrivate) {
324 throw new McpError(
325 ERR_METHOD_NOT_FOUND,
326 `${owner}/${repo} is private; v1 MCP read tool is public-only`
327 );
328 }
329 const [row] = await db
330 .select({
331 commitSha: codebaseExplanations.commitSha,
332 markdown: codebaseExplanations.markdown,
2316901Claude333 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude334 })
335 .from(codebaseExplanations)
336 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude337 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude338 .limit(1);
339 if (!row) {
340 return { explanation: null };
341 }
342 return {
343 commitSha: row.commitSha,
2316901Claude344 generatedAt: row.generatedAt,
2c2163eClaude345 markdown: row.markdown,
346 };
347 },
348};
349
f5a18f9Claude350// ---------------------------------------------------------------------------
351// gluecron_repo_health
352// ---------------------------------------------------------------------------
353
354const repoHealth: McpToolHandler = {
355 tool: {
356 name: "gluecron_repo_health",
357 description:
358 "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 labs359 annotations: { title: "Repository health report", readOnlyHint: true, destructiveHint: false },
f5a18f9Claude360 inputSchema: {
361 type: "object",
362 properties: {
363 owner: { type: "string", description: "Repo owner username" },
364 repo: { type: "string", description: "Repo name" },
365 },
366 required: ["owner", "repo"],
367 },
368 },
369 async run(args) {
370 const owner = argString(args, "owner");
371 const repo = argString(args, "repo");
372
373 const [r] = await db
374 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
375 .from(repositories)
376 .innerJoin(users, eq(repositories.ownerId, users.id))
377 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
378 .limit(1);
379 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
380 if (r.isPrivate) {
381 throw new McpError(
382 ERR_METHOD_NOT_FOUND,
383 `${owner}/${repo} is private; v1 MCP tools are public-only`
384 );
385 }
386 if (!(await repoExists(owner, repo))) {
387 throw new McpError(
388 ERR_METHOD_NOT_FOUND,
389 `${owner}/${repo} has no on-disk git data yet`
390 );
391 }
392 const report = await computeHealthScore(owner, repo);
393 return {
394 score: report.score,
395 grade: report.grade,
396 breakdown: report.breakdown,
397 insights: report.insights,
398 generatedAt: report.generatedAt,
399 };
400 },
401};
402
6551045Claude403// ---------------------------------------------------------------------------
404// Write-surface shared helpers (Block K1)
405// ---------------------------------------------------------------------------
406
407/**
408 * Require an authenticated context — every write tool gates on this first.
409 * Throws -32602 invalid_params with a tool-specific message so the client
410 * surface knows exactly which call needs auth.
411 */
412function requireAuthedCtx(ctx: McpContext, toolName: string): string {
413 if (!ctx.userId) {
414 throw new McpError(
415 ERR_INVALID_PARAMS,
416 `authentication required for ${toolName}`
417 );
418 }
419 return ctx.userId;
420}
421
422/**
423 * Resolve an `owner/repo` pair to its full row, and confirm the caller has
424 * at least `read` access. Privacy: when the repo is missing OR the caller
425 * cannot see it, throw -32601 method_not_found with the same message —
426 * matches the read-tool privacy contract so private-repo existence does
427 * not leak.
428 */
429async function resolveAccessibleRepo(
430 owner: string,
431 repo: string,
432 userId: string | null
433): Promise<{
434 repoId: string;
435 ownerId: string;
436 isPrivate: boolean;
437 defaultBranch: string;
438 access: RepoAccessLevel;
439}> {
440 const [row] = await db
441 .select({
442 id: repositories.id,
443 ownerId: repositories.ownerId,
444 isPrivate: repositories.isPrivate,
445 defaultBranch: repositories.defaultBranch,
446 })
447 .from(repositories)
448 .innerJoin(users, eq(repositories.ownerId, users.id))
449 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
450 .limit(1);
451 if (!row) {
452 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
453 }
454 const access = await resolveRepoAccess({
455 repoId: row.id,
456 userId,
457 isPublic: !row.isPrivate,
458 });
459 if (!satisfiesAccess(access, "read")) {
460 // Hide existence of private repos from non-collaborators.
461 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
462 }
463 return {
464 repoId: row.id,
465 ownerId: row.ownerId,
466 isPrivate: row.isPrivate,
467 defaultBranch: row.defaultBranch,
468 access,
469 };
470}
471
472/**
473 * Combine `requireAuthedCtx` + `resolveAccessibleRepo` + write-access gate.
474 * Used by every write tool. Returns the repo metadata for downstream use.
475 *
476 * Per spec: reject with -32601 method_not_found (not -32603) so the
477 * existence of private repos the caller cannot see is not leaked through
478 * the error code shape.
479 */
480async function gateWriteAccess(
481 args: { owner: string; repo: string },
482 ctx: McpContext,
483 toolName: string
484): Promise<{
485 userId: string;
486 owner: string;
487 repo: string;
488 repoId: string;
489 ownerId: string;
490 isPrivate: boolean;
491 defaultBranch: string;
492}> {
493 const userId = requireAuthedCtx(ctx, toolName);
494 const info = await resolveAccessibleRepo(args.owner, args.repo, userId);
495 if (!satisfiesAccess(info.access, "write")) {
496 throw new McpError(
497 ERR_METHOD_NOT_FOUND,
498 `no write access to ${args.owner}/${args.repo}`
499 );
500 }
501 return {
502 userId,
503 owner: args.owner,
504 repo: args.repo,
505 repoId: info.repoId,
506 ownerId: info.ownerId,
507 isPrivate: info.isPrivate,
508 defaultBranch: info.defaultBranch,
509 };
510}
511
512function prUrl(owner: string, repo: string, number: number): string {
513 return `/${owner}/${repo}/pulls/${number}`;
514}
515function issueUrl(owner: string, repo: string, number: number): string {
516 return `/${owner}/${repo}/issues/${number}`;
517}
518
519async function loadPrByNumber(repoId: string, prNumber: number) {
520 const [pr] = await db
521 .select()
522 .from(pullRequests)
523 .where(
524 and(
525 eq(pullRequests.repositoryId, repoId),
526 eq(pullRequests.number, prNumber)
527 )
528 )
529 .limit(1);
530 return pr ?? null;
531}
532
533async function loadIssueByNumber(repoId: string, issueNumber: number) {
534 const [row] = await db
535 .select()
536 .from(issues)
537 .where(
538 and(
539 eq(issues.repositoryId, repoId),
540 eq(issues.number, issueNumber)
541 )
542 )
543 .limit(1);
544 return row ?? null;
545}
546
547// ---------------------------------------------------------------------------
548// gluecron_create_issue
549// ---------------------------------------------------------------------------
550
551const createIssue: McpToolHandler = {
552 tool: {
553 name: "gluecron_create_issue",
554 description:
555 "Create a new issue on a Gluecron repository. Requires authenticated caller with write access on the target repo. Returns {number, url}.",
523ddadccanty labs556 annotations: { title: "Create issue", readOnlyHint: false, destructiveHint: false },
6551045Claude557 inputSchema: {
558 type: "object",
559 properties: {
560 owner: { type: "string", description: "Repo owner username" },
561 repo: { type: "string", description: "Repo name" },
562 title: { type: "string", description: "Issue title" },
563 body: { type: "string", description: "Issue body (Markdown). Optional." },
564 },
565 required: ["owner", "repo", "title"],
566 },
567 },
568 async run(args, ctx) {
569 const owner = argString(args, "owner");
570 const repo = argString(args, "repo");
571 const title = argString(args, "title");
572 const body = argString(args, "body", "");
573
574 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_issue");
575
576 const [inserted] = await db
577 .insert(issues)
578 .values({
579 repositoryId: gate.repoId,
580 authorId: gate.userId,
581 title,
582 body: body || null,
583 })
584 .returning();
585
586 // Bump issue count (best-effort, mirrors the HTTP route)
587 try {
588 await db
589 .update(repositories)
590 .set({ issueCount: sqlExpr("issue_count + 1") })
591 .where(eq(repositories.id, gate.repoId));
592 } catch {
593 /* non-fatal */
594 }
595
596 await audit({
597 userId: gate.userId,
598 repositoryId: gate.repoId,
599 action: "issue.created",
600 targetType: "issue",
601 targetId: inserted.id,
602 metadata: { source: "mcp", number: inserted.number, title },
603 });
a54e588Test User604 void fireWebhooks(gate.repoId, "issue", { action: "opened", number: inserted.number, title });
6551045Claude605
606 // Notify repo owner if it's not the same user (mirrors typical web flow).
607 if (gate.ownerId !== gate.userId) {
608 notify(gate.ownerId, {
609 kind: "issue_opened",
610 title: `New issue: ${title}`,
611 url: issueUrl(owner, repo, inserted.number),
612 repositoryId: gate.repoId,
a28cedeClaude613 }).catch((err) => {
614 console.warn(
615 `[mcp] issue_opened notify failed for ${owner}/${repo}#${inserted.number}:`,
616 err instanceof Error ? err.message : err
617 );
618 });
6551045Claude619 }
620
621 return {
622 number: inserted.number,
623 url: issueUrl(owner, repo, inserted.number),
624 };
625 },
626};
627
628// ---------------------------------------------------------------------------
629// gluecron_comment_issue
630// ---------------------------------------------------------------------------
631
632const commentIssue: McpToolHandler = {
633 tool: {
634 name: "gluecron_comment_issue",
635 description:
636 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs637 annotations: { title: "Comment on issue", readOnlyHint: false, destructiveHint: false },
6551045Claude638 inputSchema: {
639 type: "object",
640 properties: {
641 owner: { type: "string", description: "Repo owner username" },
642 repo: { type: "string", description: "Repo name" },
643 number: { type: "number", description: "Issue number" },
644 body: { type: "string", description: "Comment body (Markdown)" },
645 },
646 required: ["owner", "repo", "number", "body"],
647 },
648 },
649 async run(args, ctx) {
650 const owner = argString(args, "owner");
651 const repo = argString(args, "repo");
652 const number = argNumber(args, "number");
653 const body = argString(args, "body");
654
655 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
656 const issue = await loadIssueByNumber(gate.repoId, number);
657 if (!issue) {
658 throw new McpError(
659 ERR_METHOD_NOT_FOUND,
660 `issue not found: ${owner}/${repo}#${number}`
661 );
662 }
663
664 const [inserted] = await db
665 .insert(issueComments)
666 .values({
667 issueId: issue.id,
668 authorId: gate.userId,
669 body,
670 })
671 .returning();
672
673 // SSE fanout — best-effort.
674 try {
675 const { publish } = await import("./sse");
676 publish(`repo:${gate.repoId}:issue:${number}`, {
677 event: "issue-comment",
678 data: {
679 issueId: issue.id,
680 commentId: inserted.id,
681 authorId: gate.userId,
682 authorUsername: null,
683 },
684 });
685 } catch {
686 /* SSE best-effort */
687 }
688
689 await audit({
690 userId: gate.userId,
691 repositoryId: gate.repoId,
692 action: "issue.commented",
693 targetType: "issue",
694 targetId: issue.id,
695 metadata: { source: "mcp", number },
696 });
a54e588Test User697 void fireWebhooks(gate.repoId, "issue", { action: "commented", number });
6551045Claude698
699 return { commentId: inserted.id };
700 },
701};
702
703// ---------------------------------------------------------------------------
704// gluecron_close_issue
705// ---------------------------------------------------------------------------
706
707const closeIssue: McpToolHandler = {
708 tool: {
709 name: "gluecron_close_issue",
710 description:
711 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
523ddadccanty labs712 annotations: { title: "Close issue", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude713 inputSchema: {
714 type: "object",
715 properties: {
716 owner: { type: "string", description: "Repo owner username" },
717 repo: { type: "string", description: "Repo name" },
718 number: { type: "number", description: "Issue number" },
719 },
720 required: ["owner", "repo", "number"],
721 },
722 },
723 async run(args, ctx) {
724 const owner = argString(args, "owner");
725 const repo = argString(args, "repo");
726 const number = argNumber(args, "number");
727
728 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
729 const issue = await loadIssueByNumber(gate.repoId, number);
730 if (!issue) {
731 throw new McpError(
732 ERR_METHOD_NOT_FOUND,
733 `issue not found: ${owner}/${repo}#${number}`
734 );
735 }
736
737 if (issue.state !== "closed") {
738 await db
739 .update(issues)
740 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
741 .where(eq(issues.id, issue.id));
742
743 await audit({
744 userId: gate.userId,
745 repositoryId: gate.repoId,
746 action: "issue.closed",
747 targetType: "issue",
748 targetId: issue.id,
749 metadata: { source: "mcp", number },
750 });
a54e588Test User751 void fireWebhooks(gate.repoId, "issue", { action: "closed", number });
6551045Claude752 }
753
754 return { state: "closed" };
755 },
756};
757
758// ---------------------------------------------------------------------------
759// gluecron_reopen_issue
760// ---------------------------------------------------------------------------
761
762const reopenIssue: McpToolHandler = {
763 tool: {
764 name: "gluecron_reopen_issue",
765 description:
766 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs767 // Reopen merely undoes a close (trivially reversible) — not destructive.
768 annotations: { title: "Reopen issue", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
6551045Claude769 inputSchema: {
770 type: "object",
771 properties: {
772 owner: { type: "string", description: "Repo owner username" },
773 repo: { type: "string", description: "Repo name" },
774 number: { type: "number", description: "Issue number" },
775 },
776 required: ["owner", "repo", "number"],
777 },
778 },
779 async run(args, ctx) {
780 const owner = argString(args, "owner");
781 const repo = argString(args, "repo");
782 const number = argNumber(args, "number");
783
784 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
785 const issue = await loadIssueByNumber(gate.repoId, number);
786 if (!issue) {
787 throw new McpError(
788 ERR_METHOD_NOT_FOUND,
789 `issue not found: ${owner}/${repo}#${number}`
790 );
791 }
792
793 if (issue.state !== "open") {
794 await db
795 .update(issues)
796 .set({ state: "open", closedAt: null, updatedAt: new Date() })
797 .where(eq(issues.id, issue.id));
798
799 await audit({
800 userId: gate.userId,
801 repositoryId: gate.repoId,
802 action: "issue.reopened",
803 targetType: "issue",
804 targetId: issue.id,
805 metadata: { source: "mcp", number },
806 });
a54e588Test User807 void fireWebhooks(gate.repoId, "issue", { action: "reopened", number });
6551045Claude808 }
809
810 return { state: "open" };
811 },
812};
813
814// ---------------------------------------------------------------------------
815// gluecron_create_pr
816// ---------------------------------------------------------------------------
817
818const createPr: McpToolHandler = {
819 tool: {
820 name: "gluecron_create_pr",
821 description:
822 "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 labs823 annotations: { title: "Create pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude824 inputSchema: {
825 type: "object",
826 properties: {
827 owner: { type: "string", description: "Repo owner username" },
828 repo: { type: "string", description: "Repo name" },
829 title: { type: "string", description: "PR title" },
830 body: { type: "string", description: "PR body (Markdown). Optional." },
831 head_branch: { type: "string", description: "Branch with the changes" },
832 base_branch: {
833 type: "string",
834 description: "Target branch (default: repo default branch)",
835 },
836 },
837 required: ["owner", "repo", "title", "head_branch"],
838 },
839 },
840 async run(args, ctx) {
841 const owner = argString(args, "owner");
842 const repo = argString(args, "repo");
843 const title = argString(args, "title");
844 const body = argString(args, "body", "");
845 const headBranch = argString(args, "head_branch");
846
847 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
848 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
849
850 if (baseBranch === headBranch) {
851 throw new McpError(
852 ERR_INVALID_PARAMS,
853 "base and head branches must be different"
854 );
855 }
856
857 const [pr] = await db
858 .insert(pullRequests)
859 .values({
860 repositoryId: gate.repoId,
861 authorId: gate.userId,
862 title,
863 body: body || null,
864 baseBranch,
865 headBranch,
866 })
867 .returning();
868
869 await audit({
870 userId: gate.userId,
871 repositoryId: gate.repoId,
872 action: "pr.opened",
873 targetType: "pull_request",
874 targetId: pr.id,
875 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
876 });
877
b7b5f75ccanty labs878 void logActivity({
879 repositoryId: gate.repoId,
880 userId: gate.userId,
881 action: "pr_open",
882 targetType: "pull_request",
883 targetId: String(pr.number),
884 metadata: { source: "mcp", baseBranch, headBranch },
885 });
a54e588Test User886 void fireWebhooks(gate.repoId, "pr", { action: "opened", number: pr.number, baseBranch, headBranch });
b7b5f75ccanty labs887
6551045Claude888 if (gate.ownerId !== gate.userId) {
889 notify(gate.ownerId, {
890 kind: "pr_opened",
891 title: `New PR: ${title}`,
892 url: prUrl(owner, repo, pr.number),
893 repositoryId: gate.repoId,
a28cedeClaude894 }).catch((err) => {
895 console.warn(
896 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
897 err instanceof Error ? err.message : err
898 );
899 });
6551045Claude900 }
901
902 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
903 },
904};
905
906// ---------------------------------------------------------------------------
907// gluecron_get_pr
908// ---------------------------------------------------------------------------
909
910const getPr: McpToolHandler = {
911 tool: {
912 name: "gluecron_get_pr",
913 description:
914 "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 labs915 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude916 inputSchema: {
917 type: "object",
918 properties: {
919 owner: { type: "string", description: "Repo owner username" },
920 repo: { type: "string", description: "Repo name" },
921 number: { type: "number", description: "PR number" },
922 },
923 required: ["owner", "repo", "number"],
924 },
925 },
926 async run(args, ctx) {
927 const owner = argString(args, "owner");
928 const repo = argString(args, "repo");
929 const number = argNumber(args, "number");
930
931 requireAuthedCtx(ctx, "gluecron_get_pr");
932 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
933
934 const pr = await loadPrByNumber(info.repoId, number);
935 if (!pr) {
936 throw new McpError(
937 ERR_METHOD_NOT_FOUND,
938 `pr not found: ${owner}/${repo}#${number}`
939 );
940 }
941
942 const [author] = await db
943 .select({ username: users.username })
944 .from(users)
945 .where(eq(users.id, pr.authorId))
946 .limit(1);
947
948 // Best-effort mergeability hint via a quick head-ref resolve.
949 let mergeable: boolean | null = null;
950 try {
951 const headSha = await resolveRef(owner, repo, pr.headBranch);
952 mergeable = headSha ? true : null;
953 } catch {
954 mergeable = null;
955 }
956
957 return {
958 number: pr.number,
959 title: pr.title,
960 body: pr.body || "",
961 state: pr.state,
962 baseBranch: pr.baseBranch,
963 headBranch: pr.headBranch,
964 isDraft: pr.isDraft,
965 mergeable,
966 author: author?.username ?? null,
967 createdAt: pr.createdAt,
968 updatedAt: pr.updatedAt,
969 mergedAt: pr.mergedAt,
970 closedAt: pr.closedAt,
971 url: prUrl(owner, repo, pr.number),
972 };
973 },
974};
975
976// ---------------------------------------------------------------------------
977// gluecron_list_prs
978// ---------------------------------------------------------------------------
979
980const listPrs: McpToolHandler = {
981 tool: {
982 name: "gluecron_list_prs",
983 description:
984 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs985 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude986 inputSchema: {
987 type: "object",
988 properties: {
989 owner: { type: "string", description: "Repo owner username" },
990 repo: { type: "string", description: "Repo name" },
991 state: {
992 type: "string",
993 description: "open | closed | merged | all (default: open)",
994 },
995 },
996 required: ["owner", "repo"],
997 },
998 },
999 async run(args, ctx) {
1000 const owner = argString(args, "owner");
1001 const repo = argString(args, "repo");
1002 const state = argString(args, "state", "open");
1003 if (!["open", "closed", "merged", "all"].includes(state)) {
1004 throw new McpError(
1005 ERR_INVALID_PARAMS,
1006 `state must be one of open|closed|merged|all (got "${state}")`
1007 );
1008 }
1009
1010 requireAuthedCtx(ctx, "gluecron_list_prs");
1011 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
1012
1013 const whereClause =
1014 state === "all"
1015 ? eq(pullRequests.repositoryId, info.repoId)
1016 : and(
1017 eq(pullRequests.repositoryId, info.repoId),
1018 eq(pullRequests.state, state)
1019 );
1020
1021 const rows = await db
1022 .select({
1023 number: pullRequests.number,
1024 title: pullRequests.title,
1025 state: pullRequests.state,
1026 baseBranch: pullRequests.baseBranch,
1027 headBranch: pullRequests.headBranch,
1028 isDraft: pullRequests.isDraft,
1029 authorUsername: users.username,
1030 createdAt: pullRequests.createdAt,
1031 updatedAt: pullRequests.updatedAt,
1032 })
1033 .from(pullRequests)
1034 .innerJoin(users, eq(pullRequests.authorId, users.id))
1035 .where(whereClause)
1036 .orderBy(desc(pullRequests.createdAt))
1037 .limit(50);
1038
1039 return {
1040 total: rows.length,
1041 prs: rows.map((p) => ({
1042 number: p.number,
1043 title: p.title,
1044 state: p.state,
1045 baseBranch: p.baseBranch,
1046 headBranch: p.headBranch,
1047 isDraft: p.isDraft,
1048 author: p.authorUsername,
1049 createdAt: p.createdAt,
1050 updatedAt: p.updatedAt,
1051 url: prUrl(owner, repo, p.number),
1052 })),
1053 };
1054 },
1055};
1056
1057// ---------------------------------------------------------------------------
1058// gluecron_comment_pr
1059// ---------------------------------------------------------------------------
1060
1061const commentPr: McpToolHandler = {
1062 tool: {
1063 name: "gluecron_comment_pr",
1064 description:
1065 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1066 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1067 inputSchema: {
1068 type: "object",
1069 properties: {
1070 owner: { type: "string", description: "Repo owner username" },
1071 repo: { type: "string", description: "Repo name" },
1072 number: { type: "number", description: "PR number" },
1073 body: { type: "string", description: "Comment body (Markdown)" },
1074 },
1075 required: ["owner", "repo", "number", "body"],
1076 },
1077 },
1078 async run(args, ctx) {
1079 const owner = argString(args, "owner");
1080 const repo = argString(args, "repo");
1081 const number = argNumber(args, "number");
1082 const body = argString(args, "body");
1083
1084 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1085 const pr = await loadPrByNumber(gate.repoId, number);
1086 if (!pr) {
1087 throw new McpError(
1088 ERR_METHOD_NOT_FOUND,
1089 `pr not found: ${owner}/${repo}#${number}`
1090 );
1091 }
1092
1093 const [inserted] = await db
1094 .insert(prComments)
1095 .values({
1096 pullRequestId: pr.id,
1097 authorId: gate.userId,
1098 body,
1099 })
1100 .returning();
1101
1102 try {
1103 const { publish } = await import("./sse");
1104 publish(`repo:${gate.repoId}:pr:${number}`, {
1105 event: "pr-comment",
1106 data: {
1107 pullRequestId: pr.id,
1108 commentId: inserted.id,
1109 authorId: gate.userId,
1110 authorUsername: null,
1111 },
1112 });
1113 } catch {
1114 /* SSE best-effort */
1115 }
1116
1117 await audit({
1118 userId: gate.userId,
1119 repositoryId: gate.repoId,
1120 action: "pr.commented",
1121 targetType: "pull_request",
1122 targetId: pr.id,
1123 metadata: { source: "mcp", number },
1124 });
1125
b7b5f75ccanty labs1126 void logActivity({
1127 repositoryId: gate.repoId,
1128 userId: gate.userId,
1129 action: "comment",
1130 targetType: "pull_request",
1131 targetId: String(number),
1132 metadata: { source: "mcp", commentId: inserted.id },
1133 });
a54e588Test User1134 void fireWebhooks(gate.repoId, "pr", { action: "commented", number });
b7b5f75ccanty labs1135
6551045Claude1136 return { commentId: inserted.id };
1137 },
1138};
1139
1140// ---------------------------------------------------------------------------
1141// gluecron_merge_pr
1142// ---------------------------------------------------------------------------
1143
1144const mergePr: McpToolHandler = {
1145 tool: {
1146 name: "gluecron_merge_pr",
1147 description:
534f04aClaude1148 "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 labs1149 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1150 inputSchema: {
1151 type: "object",
1152 properties: {
1153 owner: { type: "string", description: "Repo owner username" },
1154 repo: { type: "string", description: "Repo name" },
1155 number: { type: "number", description: "PR number" },
534f04aClaude1156 confirm_high_risk: {
1157 type: "boolean",
1158 description:
1159 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1160 },
6551045Claude1161 },
1162 required: ["owner", "repo", "number"],
1163 },
1164 },
1165 async run(args, ctx) {
1166 const owner = argString(args, "owner");
1167 const repo = argString(args, "repo");
1168 const number = argNumber(args, "number");
534f04aClaude1169 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1170
1171 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1172 const pr = await loadPrByNumber(gate.repoId, number);
1173 if (!pr) {
1174 throw new McpError(
1175 ERR_METHOD_NOT_FOUND,
1176 `pr not found: ${owner}/${repo}#${number}`
1177 );
1178 }
1179 if (pr.state !== "open") {
1180 return { merged: false, reason: `pr is ${pr.state}, not open` };
1181 }
1182 if (pr.isDraft) {
1183 return {
1184 merged: false,
1185 reason: "This PR is a draft. Mark it as ready for review before merging.",
1186 };
1187 }
1188
534f04aClaude1189 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1190 // fall back to most-recent cached row; finally compute on demand so the
1191 // MCP caller always gets a score (HTTP path is async + tolerant of a
1192 // missing score, but MCP callers want an answer in one round trip).
1193 let risk: PrRiskScore | null = null;
1194 try {
1195 risk =
1196 (await getCachedPrRisk(pr.id)) ||
1197 (await getLatestCachedPrRisk(pr.id)) ||
1198 (await computePrRiskForPullRequest(pr.id));
1199 } catch {
1200 risk = null;
1201 }
1202
1203 if (risk && risk.band === "critical" && !confirmHighRisk) {
1204 return {
1205 merged: false,
1206 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1207 riskScore: serialisePrRiskForResponse(risk),
1208 };
1209 }
1210
6551045Claude1211 const headSha = await resolveRef(owner, repo, pr.headBranch);
1212 if (!headSha) {
1213 return { merged: false, reason: "Head branch not found" };
1214 }
1215
1216 // AI review approval signal — same heuristic as routes/pulls.tsx.
1217 const aiComments = await db
1218 .select()
1219 .from(prComments)
1220 .where(
1221 and(
1222 eq(prComments.pullRequestId, pr.id),
1223 eq(prComments.isAiReview, true)
1224 )
1225 );
1226 const aiApproved =
1227 aiComments.length === 0 ||
1228 aiComments.some(
1229 (c) =>
1230 c.body.includes("**Approved**") ||
1231 c.body.includes("approved: true") ||
1232 c.body.toLowerCase().includes("lgtm")
1233 );
1234
1235 const gateResult = await runAllGateChecks(
1236 owner,
1237 repo,
1238 pr.baseBranch,
1239 pr.headBranch,
1240 headSha,
1241 aiApproved
1242 );
1243
1244 const hardFailures = gateResult.checks.filter(
1245 (check) => !check.passed && check.name !== "Merge check"
1246 );
1247 if (hardFailures.length > 0) {
1248 return {
1249 merged: false,
1250 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1251 };
1252 }
1253
1254 // D5 — branch-protection enforcement
1255 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1256 if (protectionRule) {
1257 const humanApprovals = await countHumanApprovals(pr.id);
1258 const required = await listRequiredChecks(protectionRule.id);
1259 const passingNames =
1260 required.length > 0
1261 ? await passingCheckNames(gate.repoId, headSha)
1262 : [];
1263 const decision = evaluateProtection(
1264 protectionRule,
1265 {
1266 aiApproved,
1267 humanApprovalCount: humanApprovals,
1268 gateResultGreen: hardFailures.length === 0,
1269 hasFailedGates: hardFailures.length > 0,
1270 passingCheckNames: passingNames,
1271 },
1272 required.map((r) => r.checkName)
1273 );
91b054eccanty labs1274
1275 // CODEOWNERS enforcement — additive to evaluateProtection(), only
1276 // when the rule already requires human review at all (no new DB
1277 // column for this pass). Fail-open on any internal error: a bug here
1278 // must never hard-block every merge platform-wide. Mirrors the HTTP
1279 // merge path in routes/pulls.tsx.
1280 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
1281 try {
1282 const codeownersDiffProc = Bun.spawn(
1283 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
1284 { cwd: getRepoPath(owner, repo), stdout: "pipe", stderr: "pipe" }
1285 );
1286 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
1287 await codeownersDiffProc.exited;
1288 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
1289 if (changedPaths.length > 0) {
1290 const { satisfied, missingOwners } = await requiredOwnersApproved(
1291 owner,
1292 repo,
1293 gate.defaultBranch,
1294 pr.id,
1295 changedPaths
1296 );
1297 if (!satisfied) {
1298 decision.allowed = false;
1299 decision.reasons.push(
1300 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
1301 );
1302 }
1303 }
1304 } catch (err) {
1305 console.warn(
1306 "[codeowners] merge enforcement failed:",
1307 err instanceof Error ? err.message : err
1308 );
1309 }
1310 }
1311
6551045Claude1312 if (!decision.allowed) {
1313 return { merged: false, reason: decision.reasons.join(" ") };
1314 }
1315 }
1316
1317 const repoDir = getRepoPath(owner, repo);
1318 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1319 const hasConflicts = mergeCheck && !mergeCheck.passed;
1320
1321 if (hasConflicts && isAiReviewEnabled()) {
1322 const mergeResult = await mergeWithAutoResolve(
1323 owner,
1324 repo,
1325 pr.baseBranch,
1326 pr.headBranch,
1327 `Merge pull request #${pr.number}: ${pr.title}`
1328 );
1329 if (!mergeResult.success) {
1330 return {
1331 merged: false,
1332 reason: mergeResult.error || "Auto-merge failed",
1333 };
1334 }
1335 if (mergeResult.resolvedFiles.length > 0) {
1336 await db.insert(prComments).values({
1337 pullRequestId: pr.id,
1338 authorId: gate.userId,
1339 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1340 .map((f) => `- \`${f}\``)
1341 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1342 isAiReview: true,
1343 });
1344 }
1345 } else {
1346 const ffProc = Bun.spawn(
1347 [
1348 "git",
1349 "update-ref",
1350 `refs/heads/${pr.baseBranch}`,
1351 `refs/heads/${pr.headBranch}`,
1352 ],
1353 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1354 );
1355 const ffExit = await ffProc.exited;
1356 if (ffExit !== 0) {
1357 return {
1358 merged: false,
1359 reason: "Merge failed — unable to update branch ref",
1360 };
1361 }
1362 }
1363
1364 await db
1365 .update(pullRequests)
1366 .set({
1367 state: "merged",
1368 mergedAt: new Date(),
1369 mergedBy: gate.userId,
1370 updatedAt: new Date(),
1371 })
1372 .where(eq(pullRequests.id, pr.id));
1373
1374 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1375 try {
1376 const { extractClosingRefsMulti } = await import("./close-keywords");
1377 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1378 for (const n of refs) {
1379 const target = await loadIssueByNumber(gate.repoId, n);
1380 if (!target || target.state !== "open") continue;
1381 await db
1382 .update(issues)
1383 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1384 .where(eq(issues.id, target.id));
1385 await db.insert(issueComments).values({
1386 issueId: target.id,
1387 authorId: gate.userId,
1388 body: `Closed by pull request #${pr.number}.`,
1389 });
1390 }
1391 } catch {
1392 /* never block merge on close-keyword failures */
1393 }
1394
1395 await audit({
1396 userId: gate.userId,
1397 repositoryId: gate.repoId,
1398 action: "pr.merged",
1399 targetType: "pull_request",
1400 targetId: pr.id,
1401 metadata: { source: "mcp", number, sha: headSha },
1402 });
1403
b7b5f75ccanty labs1404 void logActivity({
1405 repositoryId: gate.repoId,
1406 userId: gate.userId,
1407 action: "pr_merge",
1408 targetType: "pull_request",
1409 targetId: String(number),
1410 metadata: { source: "mcp", sha: headSha },
1411 });
a54e588Test User1412 void fireWebhooks(gate.repoId, "pr", { action: "merged", number });
b7b5f75ccanty labs1413
6551045Claude1414 // Resolved post-merge SHA (best effort).
1415 let mergedSha: string | null = null;
1416 try {
1417 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1418 } catch {
1419 mergedSha = null;
1420 }
1421
534f04aClaude1422 // Block M3 — informational payload: when the risk score is high or
1423 // critical, include the score + summary in the response even on a
1424 // successful merge so the caller has the audit context. Low/medium
1425 // bands stay quiet to keep response noise low.
1426 const response: {
1427 merged: true;
1428 sha: string;
1429 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1430 } = {
6551045Claude1431 merged: true,
1432 sha: mergedSha ?? headSha,
1433 };
534f04aClaude1434 if (risk && (risk.band === "high" || risk.band === "critical")) {
1435 response.riskScore = serialisePrRiskForResponse(risk);
1436 }
1437 return response;
6551045Claude1438 },
1439};
1440
534f04aClaude1441/**
1442 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1443 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1444 */
1445function serialisePrRiskForResponse(risk: PrRiskScore) {
1446 return {
1447 score: risk.score,
1448 band: risk.band,
1449 aiSummary: risk.aiSummary,
1450 commitSha: risk.commitSha,
1451 signals: risk.signals,
1452 generatedAt:
1453 risk.generatedAt instanceof Date
1454 ? risk.generatedAt.toISOString()
1455 : String(risk.generatedAt),
1456 };
1457}
1458
6551045Claude1459// ---------------------------------------------------------------------------
1460// gluecron_close_pr
1461// ---------------------------------------------------------------------------
1462
1463const closePr: McpToolHandler = {
1464 tool: {
1465 name: "gluecron_close_pr",
1466 description:
1467 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1468 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1469 inputSchema: {
1470 type: "object",
1471 properties: {
1472 owner: { type: "string", description: "Repo owner username" },
1473 repo: { type: "string", description: "Repo name" },
1474 number: { type: "number", description: "PR number" },
1475 },
1476 required: ["owner", "repo", "number"],
1477 },
1478 },
1479 async run(args, ctx) {
1480 const owner = argString(args, "owner");
1481 const repo = argString(args, "repo");
1482 const number = argNumber(args, "number");
1483
1484 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1485 const pr = await loadPrByNumber(gate.repoId, number);
1486 if (!pr) {
1487 throw new McpError(
1488 ERR_METHOD_NOT_FOUND,
1489 `pr not found: ${owner}/${repo}#${number}`
1490 );
1491 }
1492
1493 if (pr.state === "open") {
1494 await db
1495 .update(pullRequests)
1496 .set({
1497 state: "closed",
1498 closedAt: new Date(),
1499 updatedAt: new Date(),
1500 })
1501 .where(eq(pullRequests.id, pr.id));
1502
1503 await audit({
1504 userId: gate.userId,
1505 repositoryId: gate.repoId,
1506 action: "pr.closed",
1507 targetType: "pull_request",
1508 targetId: pr.id,
1509 metadata: { source: "mcp", number },
1510 });
a54e588Test User1511 void fireWebhooks(gate.repoId, "pr", { action: "closed", number });
6551045Claude1512 }
1513
1514 return { state: pr.state === "merged" ? "merged" : "closed" };
1515 },
1516};
1517
1518function sqlExpr(expr: string) {
1519 return drizzleSql.raw(expr);
1520}
1521
2c2163eClaude1522// ---------------------------------------------------------------------------
1523// Default tool registry
1524// ---------------------------------------------------------------------------
1525
1526export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1527 // The original 15-tool surface (read + K1 write) remains the source of
1528 // truth for the core gate-enforced workflows. The expanded set in
1529 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1530 // adapter over an existing lib helper — so AI agents can drive
1531 // Gluecron end-to-end.
1532 //
1533 // The expanded module imports the `mcp*` helpers re-exported above —
1534 // since those are `const` exports they're populated by the time
1535 // `defaultTools()` is called at request time, so the circular ESM
1536 // import resolves cleanly.
2c2163eClaude1537 return {
1538 [repoSearch.tool.name]: repoSearch,
1539 [repoReadFile.tool.name]: repoReadFile,
1540 [repoListIssues.tool.name]: repoListIssues,
1541 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1542 [repoHealth.tool.name]: repoHealth,
6551045Claude1543 // Block K1 — write surface
1544 [createIssue.tool.name]: createIssue,
1545 [commentIssue.tool.name]: commentIssue,
1546 [closeIssue.tool.name]: closeIssue,
1547 [reopenIssue.tool.name]: reopenIssue,
1548 [createPr.tool.name]: createPr,
1549 [getPr.tool.name]: getPr,
1550 [listPrs.tool.name]: listPrs,
1551 [commentPr.tool.name]: commentPr,
1552 [mergePr.tool.name]: mergePr,
1553 [closePr.tool.name]: closePr,
0feb720Claude1554 // Expanded surface (50+ tools total)
1555 ...expandedTools(),
2c2163eClaude1556 };
1557}
1558
0feb720Claude1559// ---------------------------------------------------------------------------
1560// Shared helpers (re-exported so the expanded tool surface in
1561// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1562// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1563// them keeps the wrapper modules tiny.
1564// ---------------------------------------------------------------------------
1565
1566export {
1567 argString as mcpArgString,
1568 argNumber as mcpArgNumber,
1569 gateWriteAccess as mcpGateWriteAccess,
1570 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1571 requireAuthedCtx as mcpRequireAuthedCtx,
1572 loadPrByNumber as mcpLoadPrByNumber,
1573 loadIssueByNumber as mcpLoadIssueByNumber,
1574 prUrl as mcpPrUrl,
1575 issueUrl as mcpIssueUrl,
1576};
1577
2c2163eClaude1578/** Test-only export of internal helpers + per-tool handlers. */
1579export const __test = {
1580 argString,
1581 argNumber,
1582 repoSearch,
1583 repoReadFile,
1584 repoListIssues,
1585 repoExplain,
f5a18f9Claude1586 repoHealth,
6551045Claude1587 // Block K1 — write surface
1588 createIssue,
1589 commentIssue,
1590 closeIssue,
1591 reopenIssue,
1592 createPr,
1593 getPr,
1594 listPrs,
1595 commentPr,
1596 mergePr,
1597 closePr,
1598 gateWriteAccess,
1599 resolveAccessibleRepo,
2c2163eClaude1600};