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