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