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

mcp-tools.ts

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

mcp-tools.tsBlame1525 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";
35import { notify, audit } from "./notify";
36import { runAllGateChecks } from "./gate";
37import {
38 matchProtection,
39 countHumanApprovals,
40 listRequiredChecks,
41 passingCheckNames,
42 evaluateProtection,
43} from "./branch-protection";
44import { mergeWithAutoResolve } from "./merge-resolver";
45import { isAiReviewEnabled } from "./ai-review";
534f04aClaude46import {
47 computePrRiskForPullRequest,
48 getCachedPrRisk,
49 getLatestCachedPrRisk,
50 type PrRiskScore,
51} from "./pr-risk";
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
872 if (gate.ownerId !== gate.userId) {
873 notify(gate.ownerId, {
874 kind: "pr_opened",
875 title: `New PR: ${title}`,
876 url: prUrl(owner, repo, pr.number),
877 repositoryId: gate.repoId,
a28cedeClaude878 }).catch((err) => {
879 console.warn(
880 `[mcp] pr_opened notify failed for ${owner}/${repo}#${pr.number}:`,
881 err instanceof Error ? err.message : err
882 );
883 });
6551045Claude884 }
885
886 return { number: pr.number, url: prUrl(owner, repo, pr.number) };
887 },
888};
889
890// ---------------------------------------------------------------------------
891// gluecron_get_pr
892// ---------------------------------------------------------------------------
893
894const getPr: McpToolHandler = {
895 tool: {
896 name: "gluecron_get_pr",
897 description:
898 "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 labs899 annotations: { title: "Get pull request", readOnlyHint: true, destructiveHint: false },
6551045Claude900 inputSchema: {
901 type: "object",
902 properties: {
903 owner: { type: "string", description: "Repo owner username" },
904 repo: { type: "string", description: "Repo name" },
905 number: { type: "number", description: "PR number" },
906 },
907 required: ["owner", "repo", "number"],
908 },
909 },
910 async run(args, ctx) {
911 const owner = argString(args, "owner");
912 const repo = argString(args, "repo");
913 const number = argNumber(args, "number");
914
915 requireAuthedCtx(ctx, "gluecron_get_pr");
916 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
917
918 const pr = await loadPrByNumber(info.repoId, number);
919 if (!pr) {
920 throw new McpError(
921 ERR_METHOD_NOT_FOUND,
922 `pr not found: ${owner}/${repo}#${number}`
923 );
924 }
925
926 const [author] = await db
927 .select({ username: users.username })
928 .from(users)
929 .where(eq(users.id, pr.authorId))
930 .limit(1);
931
932 // Best-effort mergeability hint via a quick head-ref resolve.
933 let mergeable: boolean | null = null;
934 try {
935 const headSha = await resolveRef(owner, repo, pr.headBranch);
936 mergeable = headSha ? true : null;
937 } catch {
938 mergeable = null;
939 }
940
941 return {
942 number: pr.number,
943 title: pr.title,
944 body: pr.body || "",
945 state: pr.state,
946 baseBranch: pr.baseBranch,
947 headBranch: pr.headBranch,
948 isDraft: pr.isDraft,
949 mergeable,
950 author: author?.username ?? null,
951 createdAt: pr.createdAt,
952 updatedAt: pr.updatedAt,
953 mergedAt: pr.mergedAt,
954 closedAt: pr.closedAt,
955 url: prUrl(owner, repo, pr.number),
956 };
957 },
958};
959
960// ---------------------------------------------------------------------------
961// gluecron_list_prs
962// ---------------------------------------------------------------------------
963
964const listPrs: McpToolHandler = {
965 tool: {
966 name: "gluecron_list_prs",
967 description:
968 "List pull requests on a repo, filtered by state (open|closed|merged|all). Authenticated callers only. Returns up to 50 summary rows.",
523ddadccanty labs969 annotations: { title: "List pull requests", readOnlyHint: true, destructiveHint: false },
6551045Claude970 inputSchema: {
971 type: "object",
972 properties: {
973 owner: { type: "string", description: "Repo owner username" },
974 repo: { type: "string", description: "Repo name" },
975 state: {
976 type: "string",
977 description: "open | closed | merged | all (default: open)",
978 },
979 },
980 required: ["owner", "repo"],
981 },
982 },
983 async run(args, ctx) {
984 const owner = argString(args, "owner");
985 const repo = argString(args, "repo");
986 const state = argString(args, "state", "open");
987 if (!["open", "closed", "merged", "all"].includes(state)) {
988 throw new McpError(
989 ERR_INVALID_PARAMS,
990 `state must be one of open|closed|merged|all (got "${state}")`
991 );
992 }
993
994 requireAuthedCtx(ctx, "gluecron_list_prs");
995 const info = await resolveAccessibleRepo(owner, repo, ctx.userId);
996
997 const whereClause =
998 state === "all"
999 ? eq(pullRequests.repositoryId, info.repoId)
1000 : and(
1001 eq(pullRequests.repositoryId, info.repoId),
1002 eq(pullRequests.state, state)
1003 );
1004
1005 const rows = await db
1006 .select({
1007 number: pullRequests.number,
1008 title: pullRequests.title,
1009 state: pullRequests.state,
1010 baseBranch: pullRequests.baseBranch,
1011 headBranch: pullRequests.headBranch,
1012 isDraft: pullRequests.isDraft,
1013 authorUsername: users.username,
1014 createdAt: pullRequests.createdAt,
1015 updatedAt: pullRequests.updatedAt,
1016 })
1017 .from(pullRequests)
1018 .innerJoin(users, eq(pullRequests.authorId, users.id))
1019 .where(whereClause)
1020 .orderBy(desc(pullRequests.createdAt))
1021 .limit(50);
1022
1023 return {
1024 total: rows.length,
1025 prs: rows.map((p) => ({
1026 number: p.number,
1027 title: p.title,
1028 state: p.state,
1029 baseBranch: p.baseBranch,
1030 headBranch: p.headBranch,
1031 isDraft: p.isDraft,
1032 author: p.authorUsername,
1033 createdAt: p.createdAt,
1034 updatedAt: p.updatedAt,
1035 url: prUrl(owner, repo, p.number),
1036 })),
1037 };
1038 },
1039};
1040
1041// ---------------------------------------------------------------------------
1042// gluecron_comment_pr
1043// ---------------------------------------------------------------------------
1044
1045const commentPr: McpToolHandler = {
1046 tool: {
1047 name: "gluecron_comment_pr",
1048 description:
1049 "Add a comment to a pull request. Requires authenticated caller with write access. Returns {commentId}.",
523ddadccanty labs1050 annotations: { title: "Comment on pull request", readOnlyHint: false, destructiveHint: false },
6551045Claude1051 inputSchema: {
1052 type: "object",
1053 properties: {
1054 owner: { type: "string", description: "Repo owner username" },
1055 repo: { type: "string", description: "Repo name" },
1056 number: { type: "number", description: "PR number" },
1057 body: { type: "string", description: "Comment body (Markdown)" },
1058 },
1059 required: ["owner", "repo", "number", "body"],
1060 },
1061 },
1062 async run(args, ctx) {
1063 const owner = argString(args, "owner");
1064 const repo = argString(args, "repo");
1065 const number = argNumber(args, "number");
1066 const body = argString(args, "body");
1067
1068 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_comment_pr");
1069 const pr = await loadPrByNumber(gate.repoId, number);
1070 if (!pr) {
1071 throw new McpError(
1072 ERR_METHOD_NOT_FOUND,
1073 `pr not found: ${owner}/${repo}#${number}`
1074 );
1075 }
1076
1077 const [inserted] = await db
1078 .insert(prComments)
1079 .values({
1080 pullRequestId: pr.id,
1081 authorId: gate.userId,
1082 body,
1083 })
1084 .returning();
1085
1086 try {
1087 const { publish } = await import("./sse");
1088 publish(`repo:${gate.repoId}:pr:${number}`, {
1089 event: "pr-comment",
1090 data: {
1091 pullRequestId: pr.id,
1092 commentId: inserted.id,
1093 authorId: gate.userId,
1094 authorUsername: null,
1095 },
1096 });
1097 } catch {
1098 /* SSE best-effort */
1099 }
1100
1101 await audit({
1102 userId: gate.userId,
1103 repositoryId: gate.repoId,
1104 action: "pr.commented",
1105 targetType: "pull_request",
1106 targetId: pr.id,
1107 metadata: { source: "mcp", number },
1108 });
1109
1110 return { commentId: inserted.id };
1111 },
1112};
1113
1114// ---------------------------------------------------------------------------
1115// gluecron_merge_pr
1116// ---------------------------------------------------------------------------
1117
1118const mergePr: McpToolHandler = {
1119 tool: {
1120 name: "gluecron_merge_pr",
1121 description:
534f04aClaude1122 "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 labs1123 annotations: { title: "Merge pull request", readOnlyHint: false, destructiveHint: true },
6551045Claude1124 inputSchema: {
1125 type: "object",
1126 properties: {
1127 owner: { type: "string", description: "Repo owner username" },
1128 repo: { type: "string", description: "Repo name" },
1129 number: { type: "number", description: "PR number" },
534f04aClaude1130 confirm_high_risk: {
1131 type: "boolean",
1132 description:
1133 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1134 },
6551045Claude1135 },
1136 required: ["owner", "repo", "number"],
1137 },
1138 },
1139 async run(args, ctx) {
1140 const owner = argString(args, "owner");
1141 const repo = argString(args, "repo");
1142 const number = argNumber(args, "number");
534f04aClaude1143 const confirmHighRisk = args.confirm_high_risk === true;
6551045Claude1144
1145 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
1146 const pr = await loadPrByNumber(gate.repoId, number);
1147 if (!pr) {
1148 throw new McpError(
1149 ERR_METHOD_NOT_FOUND,
1150 `pr not found: ${owner}/${repo}#${number}`
1151 );
1152 }
1153 if (pr.state !== "open") {
1154 return { merged: false, reason: `pr is ${pr.state}, not open` };
1155 }
1156 if (pr.isDraft) {
1157 return {
1158 merged: false,
1159 reason: "This PR is a draft. Mark it as ready for review before merging.",
1160 };
1161 }
1162
534f04aClaude1163 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1164 // fall back to most-recent cached row; finally compute on demand so the
1165 // MCP caller always gets a score (HTTP path is async + tolerant of a
1166 // missing score, but MCP callers want an answer in one round trip).
1167 let risk: PrRiskScore | null = null;
1168 try {
1169 risk =
1170 (await getCachedPrRisk(pr.id)) ||
1171 (await getLatestCachedPrRisk(pr.id)) ||
1172 (await computePrRiskForPullRequest(pr.id));
1173 } catch {
1174 risk = null;
1175 }
1176
1177 if (risk && risk.band === "critical" && !confirmHighRisk) {
1178 return {
1179 merged: false,
1180 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1181 riskScore: serialisePrRiskForResponse(risk),
1182 };
1183 }
1184
6551045Claude1185 const headSha = await resolveRef(owner, repo, pr.headBranch);
1186 if (!headSha) {
1187 return { merged: false, reason: "Head branch not found" };
1188 }
1189
1190 // AI review approval signal — same heuristic as routes/pulls.tsx.
1191 const aiComments = await db
1192 .select()
1193 .from(prComments)
1194 .where(
1195 and(
1196 eq(prComments.pullRequestId, pr.id),
1197 eq(prComments.isAiReview, true)
1198 )
1199 );
1200 const aiApproved =
1201 aiComments.length === 0 ||
1202 aiComments.some(
1203 (c) =>
1204 c.body.includes("**Approved**") ||
1205 c.body.includes("approved: true") ||
1206 c.body.toLowerCase().includes("lgtm")
1207 );
1208
1209 const gateResult = await runAllGateChecks(
1210 owner,
1211 repo,
1212 pr.baseBranch,
1213 pr.headBranch,
1214 headSha,
1215 aiApproved
1216 );
1217
1218 const hardFailures = gateResult.checks.filter(
1219 (check) => !check.passed && check.name !== "Merge check"
1220 );
1221 if (hardFailures.length > 0) {
1222 return {
1223 merged: false,
1224 reason: hardFailures.map((f) => `${f.name}: ${f.details}`).join("; "),
1225 };
1226 }
1227
1228 // D5 — branch-protection enforcement
1229 const protectionRule = await matchProtection(gate.repoId, pr.baseBranch);
1230 if (protectionRule) {
1231 const humanApprovals = await countHumanApprovals(pr.id);
1232 const required = await listRequiredChecks(protectionRule.id);
1233 const passingNames =
1234 required.length > 0
1235 ? await passingCheckNames(gate.repoId, headSha)
1236 : [];
1237 const decision = evaluateProtection(
1238 protectionRule,
1239 {
1240 aiApproved,
1241 humanApprovalCount: humanApprovals,
1242 gateResultGreen: hardFailures.length === 0,
1243 hasFailedGates: hardFailures.length > 0,
1244 passingCheckNames: passingNames,
1245 },
1246 required.map((r) => r.checkName)
1247 );
1248 if (!decision.allowed) {
1249 return { merged: false, reason: decision.reasons.join(" ") };
1250 }
1251 }
1252
1253 const repoDir = getRepoPath(owner, repo);
1254 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1255 const hasConflicts = mergeCheck && !mergeCheck.passed;
1256
1257 if (hasConflicts && isAiReviewEnabled()) {
1258 const mergeResult = await mergeWithAutoResolve(
1259 owner,
1260 repo,
1261 pr.baseBranch,
1262 pr.headBranch,
1263 `Merge pull request #${pr.number}: ${pr.title}`
1264 );
1265 if (!mergeResult.success) {
1266 return {
1267 merged: false,
1268 reason: mergeResult.error || "Auto-merge failed",
1269 };
1270 }
1271 if (mergeResult.resolvedFiles.length > 0) {
1272 await db.insert(prComments).values({
1273 pullRequestId: pr.id,
1274 authorId: gate.userId,
1275 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles
1276 .map((f) => `- \`${f}\``)
1277 .join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1278 isAiReview: true,
1279 });
1280 }
1281 } else {
1282 const ffProc = Bun.spawn(
1283 [
1284 "git",
1285 "update-ref",
1286 `refs/heads/${pr.baseBranch}`,
1287 `refs/heads/${pr.headBranch}`,
1288 ],
1289 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1290 );
1291 const ffExit = await ffProc.exited;
1292 if (ffExit !== 0) {
1293 return {
1294 merged: false,
1295 reason: "Merge failed — unable to update branch ref",
1296 };
1297 }
1298 }
1299
1300 await db
1301 .update(pullRequests)
1302 .set({
1303 state: "merged",
1304 mergedAt: new Date(),
1305 mergedBy: gate.userId,
1306 updatedAt: new Date(),
1307 })
1308 .where(eq(pullRequests.id, pr.id));
1309
1310 // J7 — closing keywords. Best-effort; mirrors routes/pulls.tsx.
1311 try {
1312 const { extractClosingRefsMulti } = await import("./close-keywords");
1313 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1314 for (const n of refs) {
1315 const target = await loadIssueByNumber(gate.repoId, n);
1316 if (!target || target.state !== "open") continue;
1317 await db
1318 .update(issues)
1319 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1320 .where(eq(issues.id, target.id));
1321 await db.insert(issueComments).values({
1322 issueId: target.id,
1323 authorId: gate.userId,
1324 body: `Closed by pull request #${pr.number}.`,
1325 });
1326 }
1327 } catch {
1328 /* never block merge on close-keyword failures */
1329 }
1330
1331 await audit({
1332 userId: gate.userId,
1333 repositoryId: gate.repoId,
1334 action: "pr.merged",
1335 targetType: "pull_request",
1336 targetId: pr.id,
1337 metadata: { source: "mcp", number, sha: headSha },
1338 });
1339
1340 // Resolved post-merge SHA (best effort).
1341 let mergedSha: string | null = null;
1342 try {
1343 mergedSha = await resolveRef(owner, repo, pr.baseBranch);
1344 } catch {
1345 mergedSha = null;
1346 }
1347
534f04aClaude1348 // Block M3 — informational payload: when the risk score is high or
1349 // critical, include the score + summary in the response even on a
1350 // successful merge so the caller has the audit context. Low/medium
1351 // bands stay quiet to keep response noise low.
1352 const response: {
1353 merged: true;
1354 sha: string;
1355 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1356 } = {
6551045Claude1357 merged: true,
1358 sha: mergedSha ?? headSha,
1359 };
534f04aClaude1360 if (risk && (risk.band === "high" || risk.band === "critical")) {
1361 response.riskScore = serialisePrRiskForResponse(risk);
1362 }
1363 return response;
6551045Claude1364 },
1365};
1366
534f04aClaude1367/**
1368 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1369 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1370 */
1371function serialisePrRiskForResponse(risk: PrRiskScore) {
1372 return {
1373 score: risk.score,
1374 band: risk.band,
1375 aiSummary: risk.aiSummary,
1376 commitSha: risk.commitSha,
1377 signals: risk.signals,
1378 generatedAt:
1379 risk.generatedAt instanceof Date
1380 ? risk.generatedAt.toISOString()
1381 : String(risk.generatedAt),
1382 };
1383}
1384
6551045Claude1385// ---------------------------------------------------------------------------
1386// gluecron_close_pr
1387// ---------------------------------------------------------------------------
1388
1389const closePr: McpToolHandler = {
1390 tool: {
1391 name: "gluecron_close_pr",
1392 description:
1393 "Close an open pull request without merging. Requires authenticated caller with write access. Idempotent. Returns {state}.",
523ddadccanty labs1394 annotations: { title: "Close pull request", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
6551045Claude1395 inputSchema: {
1396 type: "object",
1397 properties: {
1398 owner: { type: "string", description: "Repo owner username" },
1399 repo: { type: "string", description: "Repo name" },
1400 number: { type: "number", description: "PR number" },
1401 },
1402 required: ["owner", "repo", "number"],
1403 },
1404 },
1405 async run(args, ctx) {
1406 const owner = argString(args, "owner");
1407 const repo = argString(args, "repo");
1408 const number = argNumber(args, "number");
1409
1410 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_close_pr");
1411 const pr = await loadPrByNumber(gate.repoId, number);
1412 if (!pr) {
1413 throw new McpError(
1414 ERR_METHOD_NOT_FOUND,
1415 `pr not found: ${owner}/${repo}#${number}`
1416 );
1417 }
1418
1419 if (pr.state === "open") {
1420 await db
1421 .update(pullRequests)
1422 .set({
1423 state: "closed",
1424 closedAt: new Date(),
1425 updatedAt: new Date(),
1426 })
1427 .where(eq(pullRequests.id, pr.id));
1428
1429 await audit({
1430 userId: gate.userId,
1431 repositoryId: gate.repoId,
1432 action: "pr.closed",
1433 targetType: "pull_request",
1434 targetId: pr.id,
1435 metadata: { source: "mcp", number },
1436 });
1437 }
1438
1439 return { state: pr.state === "merged" ? "merged" : "closed" };
1440 },
1441};
1442
1443function sqlExpr(expr: string) {
1444 return drizzleSql.raw(expr);
1445}
1446
2c2163eClaude1447// ---------------------------------------------------------------------------
1448// Default tool registry
1449// ---------------------------------------------------------------------------
1450
1451export function defaultTools(): Record<string, McpToolHandler> {
0feb720Claude1452 // The original 15-tool surface (read + K1 write) remains the source of
1453 // truth for the core gate-enforced workflows. The expanded set in
1454 // `mcp-tools-expanded.ts` adds ~40 additional wrappers — each a thin
1455 // adapter over an existing lib helper — so AI agents can drive
1456 // Gluecron end-to-end.
1457 //
1458 // The expanded module imports the `mcp*` helpers re-exported above —
1459 // since those are `const` exports they're populated by the time
1460 // `defaultTools()` is called at request time, so the circular ESM
1461 // import resolves cleanly.
2c2163eClaude1462 return {
1463 [repoSearch.tool.name]: repoSearch,
1464 [repoReadFile.tool.name]: repoReadFile,
1465 [repoListIssues.tool.name]: repoListIssues,
1466 [repoExplain.tool.name]: repoExplain,
f5a18f9Claude1467 [repoHealth.tool.name]: repoHealth,
6551045Claude1468 // Block K1 — write surface
1469 [createIssue.tool.name]: createIssue,
1470 [commentIssue.tool.name]: commentIssue,
1471 [closeIssue.tool.name]: closeIssue,
1472 [reopenIssue.tool.name]: reopenIssue,
1473 [createPr.tool.name]: createPr,
1474 [getPr.tool.name]: getPr,
1475 [listPrs.tool.name]: listPrs,
1476 [commentPr.tool.name]: commentPr,
1477 [mergePr.tool.name]: mergePr,
1478 [closePr.tool.name]: closePr,
0feb720Claude1479 // Expanded surface (50+ tools total)
1480 ...expandedTools(),
2c2163eClaude1481 };
1482}
1483
0feb720Claude1484// ---------------------------------------------------------------------------
1485// Shared helpers (re-exported so the expanded tool surface in
1486// `mcp-tools-expanded.ts` can reuse the same gating + arg-parsing without
1487// duplicating logic). Each is a thin pure helper or DB lookup — exporting
1488// them keeps the wrapper modules tiny.
1489// ---------------------------------------------------------------------------
1490
1491export {
1492 argString as mcpArgString,
1493 argNumber as mcpArgNumber,
1494 gateWriteAccess as mcpGateWriteAccess,
1495 resolveAccessibleRepo as mcpResolveAccessibleRepo,
1496 requireAuthedCtx as mcpRequireAuthedCtx,
1497 loadPrByNumber as mcpLoadPrByNumber,
1498 loadIssueByNumber as mcpLoadIssueByNumber,
1499 prUrl as mcpPrUrl,
1500 issueUrl as mcpIssueUrl,
1501};
1502
2c2163eClaude1503/** Test-only export of internal helpers + per-tool handlers. */
1504export const __test = {
1505 argString,
1506 argNumber,
1507 repoSearch,
1508 repoReadFile,
1509 repoListIssues,
1510 repoExplain,
f5a18f9Claude1511 repoHealth,
6551045Claude1512 // Block K1 — write surface
1513 createIssue,
1514 commentIssue,
1515 closeIssue,
1516 reopenIssue,
1517 createPr,
1518 getPr,
1519 listPrs,
1520 commentPr,
1521 mergePr,
1522 closePr,
1523 gateWriteAccess,
1524 resolveAccessibleRepo,
2c2163eClaude1525};