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