Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

mcp-tools.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

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