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