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.tsBlame1453 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,
589 }).catch(() => {});
590 }
591
592 return {
593 number: inserted.number,
594 url: issueUrl(owner, repo, inserted.number),
595 };
596 },
597};
598
599// ---------------------------------------------------------------------------
600// gluecron_comment_issue
601// ---------------------------------------------------------------------------
602
603const commentIssue: McpToolHandler = {
604 tool: {
605 name: "gluecron_comment_issue",
606 description:
607 "Add a comment to an existing issue. Requires authenticated caller with write access. Returns {commentId}.",
608 inputSchema: {
609 type: "object",
610 properties: {
611 owner: { type: "string", description: "Repo owner username" },
612 repo: { type: "string", description: "Repo name" },
613 number: { type: "number", description: "Issue number" },
614 body: { type: "string", description: "Comment body (Markdown)" },
615 },
616 required: ["owner", "repo", "number", "body"],
617 },
618 },
619 async run(args, ctx) {
620 const owner = argString(args, "owner");
621 const repo = argString(args, "repo");
622 const number = argNumber(args, "number");
623 const body = argString(args, "body");
624
625 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_issue");
626 const issue = await loadIssueByNumber(gate.repoId, number);
627 if (!issue) {
628 throw new McpError(
629 ERR_METHOD_NOT_FOUND,
630 `issue not found: ${owner}/${repo}#${number}`
631 );
632 }
633
634 const [inserted] = await db
635 .insert(issueComments)
636 .values({
637 issueId: issue.id,
638 authorId: gate.userId,
639 body,
640 })
641 .returning();
642
643 // SSE fanout — best-effort.
644 try {
645 const { publish } = await import("./sse");
646 publish(`repo:${gate.repoId}:issue:${number}`, {
647 event: "issue-comment",
648 data: {
649 issueId: issue.id,
650 commentId: inserted.id,
651 authorId: gate.userId,
652 authorUsername: null,
653 },
654 });
655 } catch {
656 /* SSE best-effort */
657 }
658
659 await audit({
660 userId: gate.userId,
661 repositoryId: gate.repoId,
662 action: "issue.commented",
663 targetType: "issue",
664 targetId: issue.id,
665 metadata: { source: "mcp", number },
666 });
667
668 return { commentId: inserted.id };
669 },
670};
671
672// ---------------------------------------------------------------------------
673// gluecron_close_issue
674// ---------------------------------------------------------------------------
675
676const closeIssue: McpToolHandler = {
677 tool: {
678 name: "gluecron_close_issue",
679 description:
680 "Close an open issue. Requires authenticated caller with write access. Idempotent — closing an already-closed issue is a no-op. Returns {state}.",
681 inputSchema: {
682 type: "object",
683 properties: {
684 owner: { type: "string", description: "Repo owner username" },
685 repo: { type: "string", description: "Repo name" },
686 number: { type: "number", description: "Issue number" },
687 },
688 required: ["owner", "repo", "number"],
689 },
690 },
691 async run(args, ctx) {
692 const owner = argString(args, "owner");
693 const repo = argString(args, "repo");
694 const number = argNumber(args, "number");
695
696 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_issue");
697 const issue = await loadIssueByNumber(gate.repoId, number);
698 if (!issue) {
699 throw new McpError(
700 ERR_METHOD_NOT_FOUND,
701 `issue not found: ${owner}/${repo}#${number}`
702 );
703 }
704
705 if (issue.state !== "closed") {
706 await db
707 .update(issues)
708 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
709 .where(eq(issues.id, issue.id));
710
711 await audit({
712 userId: gate.userId,
713 repositoryId: gate.repoId,
714 action: "issue.closed",
715 targetType: "issue",
716 targetId: issue.id,
717 metadata: { source: "mcp", number },
718 });
719 }
720
721 return { state: "closed" };
722 },
723};
724
725// ---------------------------------------------------------------------------
726// gluecron_reopen_issue
727// ---------------------------------------------------------------------------
728
729const reopenIssue: McpToolHandler = {
730 tool: {
731 name: "gluecron_reopen_issue",
732 description:
733 "Reopen a previously closed issue. Requires authenticated caller with write access. Idempotent. Returns {state}.",
734 inputSchema: {
735 type: "object",
736 properties: {
737 owner: { type: "string", description: "Repo owner username" },
738 repo: { type: "string", description: "Repo name" },
739 number: { type: "number", description: "Issue number" },
740 },
741 required: ["owner", "repo", "number"],
742 },
743 },
744 async run(args, ctx) {
745 const owner = argString(args, "owner");
746 const repo = argString(args, "repo");
747 const number = argNumber(args, "number");
748
749 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_reopen_issue");
750 const issue = await loadIssueByNumber(gate.repoId, number);
751 if (!issue) {
752 throw new McpError(
753 ERR_METHOD_NOT_FOUND,
754 `issue not found: ${owner}/${repo}#${number}`
755 );
756 }
757
758 if (issue.state !== "open") {
759 await db
760 .update(issues)
761 .set({ state: "open", closedAt: null, updatedAt: new Date() })
762 .where(eq(issues.id, issue.id));
763
764 await audit({
765 userId: gate.userId,
766 repositoryId: gate.repoId,
767 action: "issue.reopened",
768 targetType: "issue",
769 targetId: issue.id,
770 metadata: { source: "mcp", number },
771 });
772 }
773
774 return { state: "open" };
775 },
776};
777
778// ---------------------------------------------------------------------------
779// gluecron_create_pr
780// ---------------------------------------------------------------------------
781
782const createPr: McpToolHandler = {
783 tool: {
784 name: "gluecron_create_pr",
785 description:
786 "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}.",
787 inputSchema: {
788 type: "object",
789 properties: {
790 owner: { type: "string", description: "Repo owner username" },
791 repo: { type: "string", description: "Repo name" },
792 title: { type: "string", description: "PR title" },
793 body: { type: "string", description: "PR body (Markdown). Optional." },
794 head_branch: { type: "string", description: "Branch with the changes" },
795 base_branch: {
796 type: "string",
797 description: "Target branch (default: repo default branch)",
798 },
799 },
800 required: ["owner", "repo", "title", "head_branch"],
801 },
802 },
803 async run(args, ctx) {
804 const owner = argString(args, "owner");
805 const repo = argString(args, "repo");
806 const title = argString(args, "title");
807 const body = argString(args, "body", "");
808 const headBranch = argString(args, "head_branch");
809
810 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_create_pr");
811 const baseBranch = argString(args, "base_branch", gate.defaultBranch);
812
813 if (baseBranch === headBranch) {
814 throw new McpError(
815 ERR_INVALID_PARAMS,
816 "base and head branches must be different"
817 );
818 }
819
820 const [pr] = await db
821 .insert(pullRequests)
822 .values({
823 repositoryId: gate.repoId,
824 authorId: gate.userId,
825 title,
826 body: body || null,
827 baseBranch,
828 headBranch,
829 })
830 .returning();
831
832 await audit({
833 userId: gate.userId,
834 repositoryId: gate.repoId,
835 action: "pr.opened",
836 targetType: "pull_request",
837 targetId: pr.id,
838 metadata: { source: "mcp", number: pr.number, baseBranch, headBranch },
839 });
840
841 if (gate.ownerId !== gate.userId) {
842 notify(gate.ownerId, {
843 kind: "pr_opened",
844 title: `New PR: ${title}`,
845 url: prUrl(owner, repo, pr.number),
846 repositoryId: gate.repoId,
847 }).catch(() => {});
848 }
849
850 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
851 },
852};
853
854// ---------------------------------------------------------------------------
855// gluecron_get_pr
856// ---------------------------------------------------------------------------
857
858const getPr: McpToolHandler = {
859 tool: {
860 name: "gluecron_get_pr",
861 description:
862 "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).",
863 inputSchema: {
864 type: "object",
865 properties: {
866 owner: { type: "string", description: "Repo owner username" },
867 repo: { type: "string", description: "Repo name" },
868 number: { type: "number", description: "PR number" },
869 },
870 required: ["owner", "repo", "number"],
871 },
872 },
873 async run(args, ctx) {
874 const owner = argString(args, "owner");
875 const repo = argString(args, "repo");
876 const number = argNumber(args, "number");
877
878 requireAuthedCtx(ctx, "gluecron_get_pr");
879 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
880
881 const pr = await loadPrByNumber(info.repoId, number);
882 if (!pr) {
883 throw new McpError(
884 ERR_METHOD_NOT_FOUND,
885 `pr not found: ${owner}/${repo}#${number}`
886 );
887 }
888
889 const [author] = await db
890 .select({ username: users.username })
891 .from(users)
892 .where(eq(users.id, pr.authorId))
893 .limit(1);
894
895 // Best-effort mergeability hint via a quick head-ref resolve.
896 let mergeable: boolean | null = null;
897 try {
898 const headSha = await resolveRef(owner, repo, pr.headBranch);
899 mergeable = headSha ? true : null;
900 } catch {
901 mergeable = null;
902 }
903
904 return {
905 number: pr.number,
906 title: pr.title,
907 body: pr.body || "",
908 state: pr.state,
909 baseBranch: pr.baseBranch,
910 headBranch: pr.headBranch,
911 isDraft: pr.isDraft,
912 mergeable,
913 author: author?.username ?? null,
914 createdAt: pr.createdAt,
915 updatedAt: pr.updatedAt,
916 mergedAt: pr.mergedAt,
917 closedAt: pr.closedAt,
918 url: prUrl(owner, repo, pr.number),
919 };
920 },
921};
922
923// ---------------------------------------------------------------------------
924// gluecron_list_prs
925// ---------------------------------------------------------------------------
926
927const listPrs: McpToolHandler = {
928 tool: {
929 name: "gluecron_list_prs",
930 description:
931 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
932 inputSchema: {
933 type: "object",
934 properties: {
935 owner: { type: "string", description: "Repo owner username" },
936 repo: { type: "string", description: "Repo name" },
937 state: {
938 type: "string",
939 description: "open | closed | merged | all (default: open)",
940 },
941 },
942 required: ["owner", "repo"],
943 },
944 },
945 async run(args, ctx) {
946 const owner = argString(args, "owner");
947 const repo = argString(args, "repo");
948 const state = argString(args, "state", "open");
949 if (!["open", "closed", "merged", "all"].includes(state)) {
950 throw new McpError(
951 ERR_INVALID_PARAMS,
952 `state must be one of open|closed|merged|all (got "${state}")`
953 );
954 }
955
956 requireAuthedCtx(ctx, "gluecron_list_prs");
957 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
958
959 const whereClause =
960 state === "all"
961 ? eq(pullRequests.repositoryId, info.repoId)
962 : and(
963 eq(pullRequests.repositoryId, info.repoId),
964 eq(pullRequests.state, state)
965 );
966
967 const rows = await db
968 .select({
969 number: pullRequests.number,
970 title: pullRequests.title,
971 state: pullRequests.state,
972 baseBranch: pullRequests.baseBranch,
973 headBranch: pullRequests.headBranch,
974 isDraft: pullRequests.isDraft,
975 authorUsername: users.username,
976 createdAt: pullRequests.createdAt,
977 updatedAt: pullRequests.updatedAt,
978 })
979 .from(pullRequests)
980 .innerJoin(users, eq(pullRequests.authorId, users.id))
981 .where(whereClause)
982 .orderBy(desc(pullRequests.createdAt))
983 .limit(50);
984
985 return {
986 total: rows.length,
987 prs: rows.map((p) => ({
988 number: p.number,
989 title: p.title,
990 state: p.state,
991 baseBranch: p.baseBranch,
992 headBranch: p.headBranch,
993 isDraft: p.isDraft,
994 author: p.authorUsername,
995 createdAt: p.createdAt,
996 updatedAt: p.updatedAt,
997 url: prUrl(owner, repo, p.number),
998 })),
999 };
1000 },
1001};
1002
1003// ---------------------------------------------------------------------------
1004// gluecron_comment_pr
1005// ---------------------------------------------------------------------------
1006
1007const commentPr: McpToolHandler = {
1008 tool: {
1009 name: "gluecron_comment_pr",
1010 description:
1011 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
1012 inputSchema: {
1013 type: "object",
1014 properties: {
1015 owner: { type: "string", description: "Repo owner username" },
1016 repo: { type: "string", description: "Repo name" },
1017 number: { type: "number", description: "PR number" },
1018 body: { type: "string", description: "Comment body (Markdown)" },
1019 },
1020 required: ["owner", "repo", "number", "body"],
1021 },
1022 },
1023 async run(args, ctx) {
1024 const owner = argString(args, "owner");
1025 const repo = argString(args, "repo");
1026 const number = argNumber(args, "number");
1027 const body = argString(args, "body");
1028
1029 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1030 const pr = await loadPrByNumber(gate.repoId, number);
1031 if (!pr) {
1032 throw new McpError(
1033 ERR_METHOD_NOT_FOUND,
1034 `pr not found: ${owner}/${repo}#${number}`
1035 );
1036 }
1037
1038 const [inserted] = await db
1039 .insert(prComments)
1040 .values({
1041 pullRequestId: pr.id,
1042 authorId: gate.userId,
1043 body,
1044 })
1045 .returning();
1046
1047 try {
1048 const { publish } = await import("./sse");
1049 publish(`repo:${gate.repoId}:pr:${number}`, {
1050 event: "pr-comment",
1051 data: {
1052 pullRequestId: pr.id,
1053 commentId: inserted.id,
1054 authorId: gate.userId,
1055 authorUsername: null,
1056 },
1057 });
1058 } catch {
1059 /* SSE best-effort */
1060 }
1061
1062 await audit({
1063 userId: gate.userId,
1064 repositoryId: gate.repoId,
1065 action: "pr.commented",
1066 targetType: "pull_request",
1067 targetId: pr.id,
1068 metadata: { source: "mcp", number },
1069 });
1070
1071 return { commentId: inserted.id };
1072 },
1073};
1074
1075// ---------------------------------------------------------------------------
1076// gluecron_merge_pr
1077// ---------------------------------------------------------------------------
1078
1079const mergePr: McpToolHandler = {
1080 tool: {
1081 name: "gluecron_merge_pr",
1082 description:
534f04aClaude1083 "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?}.",
6551045Claude1084 inputSchema: {
1085 type: "object",
1086 properties: {
1087 owner: { type: "string", description: "Repo owner username" },
1088 repo: { type: "string", description: "Repo name" },
1089 number: { type: "number", description: "PR number" },
534f04aClaude1090 confirm_high_risk: {
1091 type: "boolean",
1092 description:
1093 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1094 },
6551045Claude1095 },
1096 required: ["owner", "repo", "number"],
1097 },
1098 },
1099 async run(args, ctx) {
1100 const owner = argString(args, "owner");
1101 const repo = argString(args, "repo");
1102 const number = argNumber(args, "number");
534f04aClaude1103 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1104
1105 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1106 const pr = await loadPrByNumber(gate.repoId, number);
1107 if (!pr) {
1108 throw new McpError(
1109 ERR_METHOD_NOT_FOUND,
1110 `pr not found: ${owner}/${repo}#${number}`
1111 );
1112 }
1113 if (pr.state !== "open") {
1114 return { merged: false, reason: `pr is ${pr.state}, not open` };
1115 }
1116 if (pr.isDraft) {
1117 return {
1118 merged: false,
1119 reason: "This PR is a draft. Mark it as ready for review before merging.",
1120 };
1121 }
1122
534f04aClaude1123 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1124 // fall back to most-recent cached row; finally compute on demand so the
1125 // MCP caller always gets a score (HTTP path is async + tolerant of a
1126 // missing score, but MCP callers want an answer in one round trip).
1127 let risk: PrRiskScore | null = null;
1128 try {
1129 risk =
1130 (await getCachedPrRisk(pr.id)) ||
1131 (await getLatestCachedPrRisk(pr.id)) ||
1132 (await computePrRiskForPullRequest(pr.id));
1133 } catch {
1134 risk = null;
1135 }
1136
1137 if (risk && risk.band === "critical" && !confirmHighRisk) {
1138 return {
1139 merged: false,
1140 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1141 riskScore: serialisePrRiskForResponse(risk),
1142 };
1143 }
1144
6551045Claude1145 const headSha = await resolveRef(owner, repo, pr.headBranch);
1146 if (!headSha) {
1147 return { merged: false, reason: "Head branch not found" };
1148 }
1149
1150 // AI review approval signal — same heuristic as routes/pulls.tsx.
1151 const aiComments = await db
1152 .select()
1153 .from(prComments)
1154 .where(
1155 and(
1156 eq(prComments.pullRequestId, pr.id),
1157 eq(prComments.isAiReview, true)
1158 )
1159 );
1160 const aiApproved =
1161 aiComments.length === 0 ||
1162 aiComments.some(
1163 (c) =>
1164 c.body.includes("**Approved**") ||
1165 c.body.includes("approved: true") ||
1166 c.body.toLowerCase().includes("lgtm")
1167 );
1168
1169 const gateResult = await runAllGateChecks(
1170 owner,
1171 repo,
1172 pr.baseBranch,
1173 pr.headBranch,
1174 headSha,
1175 aiApproved
1176 );
1177
1178 const hardFailures = gateResult.checks.filter(
1179 (check) => !check.passed && check.name !== "Merge check"
1180 );
1181 if (hardFailures.length > 0) {
1182 return {
1183 merged: false,
1184 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1185 };
1186 }
1187
1188 // D5 — branch-protection enforcement
1189 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1190 if (protectionRule) {
1191 const humanApprovals = await countHumanApprovals(pr.id);
1192 const required = await listRequiredChecks(protectionRule.id);
1193 const passingNames =
1194 required.length > 0
1195 ? await passingCheckNames(gate.repoId, headSha)
1196 : [];
1197 const decision = evaluateProtection(
1198 protectionRule,
1199 {
1200 aiApproved,
1201 humanApprovalCount: humanApprovals,
1202 gateResultGreen: hardFailures.length === 0,
1203 hasFailedGates: hardFailures.length > 0,
1204 passingCheckNames: passingNames,
1205 },
1206 required.map((r) => r.checkName)
1207 );
1208 if (!decision.allowed) {
1209 return { merged: false, reason: decision.reasons.join(" ") };
1210 }
1211 }
1212
1213 const repoDir = getRepoPath(owner, repo);
1214 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1215 const hasConflicts = mergeCheck && !mergeCheck.passed;
1216
1217 if (hasConflicts && isAiReviewEnabled()) {
1218 const mergeResult = await mergeWithAutoResolve(
1219 owner,
1220 repo,
1221 pr.baseBranch,
1222 pr.headBranch,
1223 `Merge pull request #${pr.number}: ${pr.title}`
1224 );
1225 if (!mergeResult.success) {
1226 return {
1227 merged: false,
1228 reason: mergeResult.error || "Auto-merge failed",
1229 };
1230 }
1231 if (mergeResult.resolvedFiles.length > 0) {
1232 await db.insert(prComments).values({
1233 pullRequestId: pr.id,
1234 authorId: gate.userId,
1235 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1236 .map((f) => `- \`${f}\``)
1237 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1238 isAiReview: true,
1239 });
1240 }
1241 } else {
1242 const ffProc = Bun.spawn(
1243 [
1244 "git",
1245 "update-ref",
1246 `refs/heads/${pr.baseBranch}`,
1247 `refs/heads/${pr.headBranch}`,
1248 ],
1249 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1250 );
1251 const ffExit = await ffProc.exited;
1252 if (ffExit !== 0) {
1253 return {
1254 merged: false,
1255 reason: "Merge failed — unable to update branch ref",
1256 };
1257 }
1258 }
1259
1260 await db
1261 .update(pullRequests)
1262 .set({
1263 state: "merged",
1264 mergedAt: new Date(),
1265 mergedBy: gate.userId,
1266 updatedAt: new Date(),
1267 })
1268 .where(eq(pullRequests.id, pr.id));
1269
1270 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1271 try {
1272 const { extractClosingRefsMulti } = await import("./close-keywords");
1273 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1274 for (const n of refs) {
1275 const target = await loadIssueByNumber(gate.repoId, n);
1276 if (!target || target.state !== "open") continue;
1277 await db
1278 .update(issues)
1279 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1280 .where(eq(issues.id, target.id));
1281 await db.insert(issueComments).values({
1282 issueId: target.id,
1283 authorId: gate.userId,
1284 body: `Closed by pull request #${pr.number}.`,
1285 });
1286 }
1287 } catch {
1288 /* never block merge on close-keyword failures */
1289 }
1290
1291 await audit({
1292 userId: gate.userId,
1293 repositoryId: gate.repoId,
1294 action: "pr.merged",
1295 targetType: "pull_request",
1296 targetId: pr.id,
1297 metadata: { source: "mcp", number, sha: headSha },
1298 });
1299
1300 // Resolved post-merge SHA (best effort).
1301 let mergedSha: string | null = null;
1302 try {
1303 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1304 } catch {
1305 mergedSha = null;
1306 }
1307
534f04aClaude1308 // Block M3 — informational payload: when the risk score is high or
1309 // critical, include the score + summary in the response even on a
1310 // successful merge so the caller has the audit context. Low/medium
1311 // bands stay quiet to keep response noise low.
1312 const response: {
1313 merged: true;
1314 sha: string;
1315 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1316 } = {
6551045Claude1317 merged: true,
1318 sha: mergedSha ?? headSha,
1319 };
534f04aClaude1320 if (risk && (risk.band === "high" || risk.band === "critical")) {
1321 response.riskScore = serialisePrRiskForResponse(risk);
1322 }
1323 return response;
6551045Claude1324 },
1325};
1326
534f04aClaude1327/**
1328 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1329 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1330 */
1331function serialisePrRiskForResponse(risk: PrRiskScore) {
1332 return {
1333 score: risk.score,
1334 band: risk.band,
1335 aiSummary: risk.aiSummary,
1336 commitSha: risk.commitSha,
1337 signals: risk.signals,
1338 generatedAt:
1339 risk.generatedAt instanceof Date
1340 ? risk.generatedAt.toISOString()
1341 : String(risk.generatedAt),
1342 };
1343}
1344
6551045Claude1345// ---------------------------------------------------------------------------
1346// gluecron_close_pr
1347// ---------------------------------------------------------------------------
1348
1349const closePr: McpToolHandler = {
1350 tool: {
1351 name: "gluecron_close_pr",
1352 description:
1353 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
1354 inputSchema: {
1355 type: "object",
1356 properties: {
1357 owner: { type: "string", description: "Repo owner username" },
1358 repo: { type: "string", description: "Repo name" },
1359 number: { type: "number", description: "PR number" },
1360 },
1361 required: ["owner", "repo", "number"],
1362 },
1363 },
1364 async run(args, ctx) {
1365 const owner = argString(args, "owner");
1366 const repo = argString(args, "repo");
1367 const number = argNumber(args, "number");
1368
1369 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1370 const pr = await loadPrByNumber(gate.repoId, number);
1371 if (!pr) {
1372 throw new McpError(
1373 ERR_METHOD_NOT_FOUND,
1374 `pr not found: ${owner}/${repo}#${number}`
1375 );
1376 }
1377
1378 if (pr.state === "open") {
1379 await db
1380 .update(pullRequests)
1381 .set({
1382 state: "closed",
1383 closedAt: new Date(),
1384 updatedAt: new Date(),
1385 })
1386 .where(eq(pullRequests.id, pr.id));
1387
1388 await audit({
1389 userId: gate.userId,
1390 repositoryId: gate.repoId,
1391 action: "pr.closed",
1392 targetType: "pull_request",
1393 targetId: pr.id,
1394 metadata: { source: "mcp", number },
1395 });
1396 }
1397
1398 return { state: pr.state === "merged" ? "merged" : "closed" };
1399 },
1400};
1401
1402function sqlExpr(expr: string) {
1403 return drizzleSql.raw(expr);
1404}
1405
2c2163eClaude1406// ---------------------------------------------------------------------------
1407// Default tool registry
1408// ---------------------------------------------------------------------------
1409
1410export function defaultTools(): Record<string, McpToolHandler> {
1411 return {
1412 [repoSearch.tool.name]: repoSearch,
1413 [repoReadFile.tool.name]: repoReadFile,
1414 [repoListIssues.tool.name]: repoListIssues,
1415 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1416 [repoHealth.tool.name]: repoHealth,
6551045Claude1417 // Block K1 — write surface
1418 [createIssue.tool.name]: createIssue,
1419 [commentIssue.tool.name]: commentIssue,
1420 [closeIssue.tool.name]: closeIssue,
1421 [reopenIssue.tool.name]: reopenIssue,
1422 [createPr.tool.name]: createPr,
1423 [getPr.tool.name]: getPr,
1424 [listPrs.tool.name]: listPrs,
1425 [commentPr.tool.name]: commentPr,
1426 [mergePr.tool.name]: mergePr,
1427 [closePr.tool.name]: closePr,
2c2163eClaude1428 };
1429}
1430
1431/** Test-only export of internal helpers + per-tool handlers. */
1432export const __test = {
1433 argString,
1434 argNumber,
1435 repoSearch,
1436 repoReadFile,
1437 repoListIssues,
1438 repoExplain,
f5a18f9Claude1439 repoHealth,
6551045Claude1440 // Block K1 — write surface
1441 createIssue,
1442 commentIssue,
1443 closeIssue,
1444 reopenIssue,
1445 createPr,
1446 getPr,
1447 listPrs,
1448 commentPr,
1449 mergePr,
1450 closePr,
1451 gateWriteAccess,
1452 resolveAccessibleRepo,
2c2163eClaude1453};