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-expanded.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-expanded.tsBlame2525 lines · 2 contributors
0feb720Claude1/**
2 * Expanded MCP tool surface — wraps every meaningful Gluecron action so
3 * any AI tool-use loop can drive the platform end-to-end.
4 *
5 * Each tool here is a THIN wrapper around an existing lib helper or a
6 * REST handler's underlying primitives — we never duplicate business
7 * logic. Auth/gating reuses the shared helpers exported from
8 * `mcp-tools.ts`.
9 *
10 * Categories (per the build spec):
11 *
12 * REPOS fork / delete / update / search / clone_url
13 * ISSUES close / reopen / label / unlabel / assign / search
14 * PRS close / request_changes / search / open_draft /
15 * generate_description
16 * FILES & GIT read_file / write_file / delete_file / list_tree /
17 * get_commit / create_branch / atomic_multi_file_commit
18 * AI WORKFLOWS ship_spec / voice_to_pr / refactor_across_repos /
19 * explain_repo / chat_with_repo / chat_continue /
20 * generate_tests / generate_commit_message /
21 * generate_release_notes / propose_migration /
22 * propose_doc_update
23 * CI / DEPLOYS trigger_workflow / get_workflow_run /
24 * get_workflow_logs / cancel_workflow_run /
25 * get_preview_url / provision_pr_sandbox
26 * AGENTS create_agent_session / acquire_lease /
27 * release_lease / get_agent_budget
28 * SEMANTIC semantic_search / find_symbol
29 * INSIGHTS pr_status_summary / repo_health / ai_cost_summary
30 *
31 * Scope contract:
32 * - Read-only tools accept any authenticated caller (anonymous works
33 * on public repos via the existing `resolveAccessibleRepo` flow).
34 * - Write tools require `repo` scope.
35 * - Destructive tools (delete_repo) require `admin` scope.
36 */
37
38import { and, eq, like, or, desc, asc } from "drizzle-orm";
39import { db } from "../db";
40import {
41 repositories,
42 users,
43 issues,
44 issueComments,
45 pullRequests,
46 prComments,
47 labels,
48 issueLabels,
49 workflows,
50 workflowRuns,
51 workflowJobs,
52} from "../db/schema";
53import {
54 repoExists,
55 getRepoPath,
56 getBlob,
57 getTree,
58 getTreeRecursive,
59 getCommit,
60 resolveRef,
61 refExists,
62 objectExists,
63 updateRef,
64 writeBlob,
65 createOrUpdateFileOnBranch,
66 getBlobShaAtPath,
67} from "../git/repository";
68import { join } from "path";
69import { mkdir, unlink, rm } from "fs/promises";
70import { config } from "./config";
71import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
72import type { McpContext } from "./mcp";
73import type { McpToolHandler } from "./mcp-tools";
74import {
75 mcpArgString,
76 mcpArgNumber,
77 mcpGateWriteAccess,
78 mcpResolveAccessibleRepo,
79 mcpRequireAuthedCtx,
80 mcpLoadPrByNumber,
81 mcpLoadIssueByNumber,
82 mcpPrUrl,
83 mcpIssueUrl,
84} from "./mcp-tools";
85
86// ---------------------------------------------------------------------------
87// Local helpers
88// ---------------------------------------------------------------------------
89
90/**
91 * Require a scope on the caller's token. Session-cookie callers receive
92 * `["repo","user","admin"]` so they always pass. Anonymous and limited
93 * PATs may fail here. When `ctx.scopes` is undefined we treat the context
94 * as legacy-permissive (matches the pre-expansion behaviour of the
95 * existing 15-tool surface).
96 */
97function requireScope(ctx: McpContext, scope: string, toolName: string): void {
98 // Undefined scopes → legacy/permissive. Anonymous (no userId) is caught
99 // separately by `requireAuthedCtx` further upstream.
100 if (ctx.scopes === undefined) return;
101 if (ctx.scopes.includes(scope) || ctx.scopes.includes("admin")) return;
102 throw new McpError(
103 ERR_INVALID_PARAMS,
104 `tool ${toolName} requires '${scope}' scope (token carries: ${ctx.scopes.join(",") || "none"})`
105 );
106}
107
108function argBool(args: Record<string, unknown>, key: string, fallback = false): boolean {
109 const v = args[key];
110 if (typeof v === "boolean") return v;
111 if (typeof v === "string") return v === "true" || v === "1";
112 return fallback;
113}
114
115function argStringArray(args: Record<string, unknown>, key: string): string[] {
116 const v = args[key];
117 if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
118 return [];
119}
120
121// ---------------------------------------------------------------------------
122// REPOS
123// ---------------------------------------------------------------------------
124
125const forkRepo: McpToolHandler = {
126 tool: {
127 name: "gluecron_fork_repo",
128 description:
129 "Fork a repository to the authenticated caller's namespace. Mirrors POST /:owner/:repo/fork. Returns {owner, repo, url}. Requires 'repo' scope.",
523ddadccanty labs130 annotations: { title: "Fork repository", readOnlyHint: false, destructiveHint: false },
0feb720Claude131 inputSchema: {
132 type: "object",
133 properties: {
134 owner: { type: "string", description: "Source repo owner" },
135 repo: { type: "string", description: "Source repo name" },
136 },
137 required: ["owner", "repo"],
138 },
139 },
140 async run(args, ctx) {
141 const owner = mcpArgString(args, "owner");
142 const repo = mcpArgString(args, "repo");
143 const userId = mcpRequireAuthedCtx(ctx, "gluecron_fork_repo");
144 requireScope(ctx, "repo", "gluecron_fork_repo");
145
146 const info = await mcpResolveAccessibleRepo(owner, repo, userId);
147
148 const [me] = await db
149 .select({ id: users.id, username: users.username })
150 .from(users)
151 .where(eq(users.id, userId))
152 .limit(1);
153 if (!me) throw new McpError(ERR_INVALID_PARAMS, "caller user not found");
154
155 if (me.username === owner) {
156 throw new McpError(ERR_INVALID_PARAMS, "cannot fork your own repository");
157 }
158 if (await repoExists(me.username, repo)) {
159 throw new McpError(
160 ERR_INVALID_PARAMS,
161 `${me.username}/${repo} already exists`
162 );
163 }
164
165 const sourcePath = getRepoPath(owner, repo);
166 const destPath = join(config.gitReposPath, me.username, `${repo}.git`);
167
168 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
169 stdout: "pipe",
170 stderr: "pipe",
171 });
172 const code = await proc.exited;
173 if (code !== 0) {
174 throw new McpError(ERR_INVALID_PARAMS, "git clone --bare failed");
175 }
176
177 const [sourceRepo] = await db
178 .select()
179 .from(repositories)
180 .where(eq(repositories.id, info.repoId))
181 .limit(1);
182 if (!sourceRepo) throw new McpError(ERR_METHOD_NOT_FOUND, "source repo row missing");
183
184 const [newRepo] = await db
185 .insert(repositories)
186 .values({
187 name: repo,
188 ownerId: me.id,
189 description: sourceRepo.description
190 ? `Fork of ${owner}/${repo} — ${sourceRepo.description}`
191 : `Fork of ${owner}/${repo}`,
192 isPrivate: false,
193 defaultBranch: sourceRepo.defaultBranch,
194 diskPath: destPath,
195 forkedFromId: sourceRepo.id,
196 })
197 .returning();
198
199 if (newRepo) {
200 try {
201 const { bootstrapRepository } = await import("./repo-bootstrap");
202 await bootstrapRepository({
203 repositoryId: newRepo.id,
204 ownerUserId: me.id,
205 defaultBranch: sourceRepo.defaultBranch,
206 skipWelcomeIssue: true,
207 });
208 } catch {
209 /* bootstrap is non-fatal */
210 }
211 }
212
213 try {
214 await db
215 .update(repositories)
216 .set({ forkCount: (sourceRepo.forkCount ?? 0) + 1 })
217 .where(eq(repositories.id, sourceRepo.id));
218 } catch {
219 /* non-fatal */
220 }
221
222 return {
223 owner: me.username,
224 repo,
225 url: `/${me.username}/${repo}`,
226 };
227 },
228};
229
230const deleteRepo: McpToolHandler = {
231 tool: {
232 name: "gluecron_delete_repo",
233 description:
234 "Permanently delete a repository row (git data on disk is left untouched). Owner-only. Requires 'admin' scope. Returns {deleted: true}.",
523ddadccanty labs235 annotations: { title: "Delete repository", readOnlyHint: false, destructiveHint: true },
0feb720Claude236 inputSchema: {
237 type: "object",
238 properties: {
239 owner: { type: "string", description: "Repo owner username" },
240 repo: { type: "string", description: "Repo name" },
241 },
242 required: ["owner", "repo"],
243 },
244 },
245 async run(args, ctx) {
246 const owner = mcpArgString(args, "owner");
247 const repo = mcpArgString(args, "repo");
248 requireScope(ctx, "admin", "gluecron_delete_repo");
249 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_delete_repo");
250
251 if (gate.ownerId !== gate.userId) {
252 throw new McpError(
253 ERR_METHOD_NOT_FOUND,
254 `no admin access to ${owner}/${repo}`
255 );
256 }
257
258 await db.delete(repositories).where(eq(repositories.id, gate.repoId));
259 return { deleted: true };
260 },
261};
262
263const updateRepo: McpToolHandler = {
264 tool: {
265 name: "gluecron_update_repo",
266 description:
267 "Update repository description / visibility / default_branch. Owner-only. Requires 'repo' scope. Returns {ok: true}.",
523ddadccanty labs268 // Destructive: overwrites prior settings (old values unrecoverable) and can flip visibility.
269 annotations: { title: "Update repository settings", readOnlyHint: false, destructiveHint: true },
0feb720Claude270 inputSchema: {
271 type: "object",
272 properties: {
273 owner: { type: "string", description: "Repo owner username" },
274 repo: { type: "string", description: "Repo name" },
275 description: { type: "string", description: "New description (optional)" },
276 is_private: { type: "boolean", description: "Set visibility (optional)" },
277 default_branch: { type: "string", description: "Set default branch (optional)" },
278 },
279 required: ["owner", "repo"],
280 },
281 },
282 async run(args, ctx) {
283 const owner = mcpArgString(args, "owner");
284 const repo = mcpArgString(args, "repo");
285 requireScope(ctx, "repo", "gluecron_update_repo");
286 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_update_repo");
287
288 if (gate.ownerId !== gate.userId) {
289 throw new McpError(
290 ERR_METHOD_NOT_FOUND,
291 `no admin access to ${owner}/${repo}`
292 );
293 }
294
295 const updates: Record<string, unknown> = { updatedAt: new Date() };
296 if (typeof args.description === "string") updates.description = args.description;
297 if (typeof args.is_private === "boolean") updates.isPrivate = args.is_private;
298 if (typeof args.default_branch === "string") updates.defaultBranch = args.default_branch;
299 if (Object.keys(updates).length === 1) {
300 return { ok: true, noop: true };
301 }
302 await db.update(repositories).set(updates).where(eq(repositories.id, gate.repoId));
303 return { ok: true };
304 },
305};
306
307const searchRepos: McpToolHandler = {
308 tool: {
309 name: "gluecron_search_repos",
310 description:
311 "Full-text search of public repositories by name/description. Mirrors GET /api/v2/search/repos. Returns ranked rows.",
523ddadccanty labs312 annotations: { title: "Search repositories (ranked)", readOnlyHint: true, destructiveHint: false },
0feb720Claude313 inputSchema: {
314 type: "object",
315 properties: {
316 query: { type: "string", description: "Search keyword" },
317 sort: { type: "string", description: "stars | updated | name (default: stars)" },
318 limit: { type: "number", description: "Max results (1-100, default 30)" },
319 },
320 required: ["query"],
321 },
322 },
323 async run(args) {
324 const q = mcpArgString(args, "query");
325 const sort = mcpArgString(args, "sort", "stars");
326 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 30)));
327 const orderBy =
328 sort === "updated"
329 ? desc(repositories.updatedAt)
330 : sort === "name"
331 ? asc(repositories.name)
332 : desc(repositories.starCount);
333 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
334 const rows = await db
335 .select({
336 id: repositories.id,
337 name: repositories.name,
338 description: repositories.description,
339 stars: repositories.starCount,
340 forks: repositories.forkCount,
341 ownerName: users.username,
342 })
343 .from(repositories)
344 .innerJoin(users, eq(repositories.ownerId, users.id))
345 .where(
346 and(
347 eq(repositories.isPrivate, false),
348 or(
349 like(repositories.name, pattern),
350 like(repositories.description, pattern)
351 )
352 )
353 )
354 .orderBy(orderBy)
355 .limit(limit);
356 return {
357 total: rows.length,
358 repos: rows.map((r) => ({
359 fullName: `${r.ownerName}/${r.name}`,
360 description: r.description || "",
361 stars: r.stars,
362 forks: r.forks,
363 })),
364 };
365 },
366};
367
368const cloneUrl: McpToolHandler = {
369 tool: {
370 name: "gluecron_clone_url",
371 description:
372 "Return the authenticated HTTPS clone URL for a repo + a credential-helper hint. Use this instead of embedding tokens in URLs. Returns {url, hint}.",
523ddadccanty labs373 annotations: { title: "Get clone URL", readOnlyHint: true, destructiveHint: false },
0feb720Claude374 inputSchema: {
375 type: "object",
376 properties: {
377 owner: { type: "string", description: "Repo owner username" },
378 repo: { type: "string", description: "Repo name" },
379 },
380 required: ["owner", "repo"],
381 },
382 },
383 async run(args, ctx) {
384 const owner = mcpArgString(args, "owner");
385 const repo = mcpArgString(args, "repo");
386 // Verify access; rejects private-without-access via the privacy
387 // contract.
388 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
389 const base = config.appBaseUrl?.replace(/\/$/, "") || "https://gluecron.com";
390 return {
391 url: `${base}/${owner}/${repo}.git`,
392 hint:
393 "Use a credential helper rather than embedding a PAT in the URL:\n" +
394 ` git -c credential.helper='!f() { echo "username=token"; echo "password=$GLUECRON_PAT"; }; f' clone ${base}/${owner}/${repo}.git`,
395 };
396 },
397};
398
399// ---------------------------------------------------------------------------
400// ISSUES — label/unlabel/assign/search (close/reopen already live in mcp-tools)
401// ---------------------------------------------------------------------------
402
403const labelIssue: McpToolHandler = {
404 tool: {
405 name: "gluecron_label_issue",
406 description:
407 "Attach one or more labels to an issue. Labels are created if they don't yet exist on the repo. Requires 'repo' scope. Returns {labels}.",
523ddadccanty labs408 annotations: { title: "Label issue", readOnlyHint: false, destructiveHint: false },
0feb720Claude409 inputSchema: {
410 type: "object",
411 properties: {
412 owner: { type: "string" },
413 repo: { type: "string" },
414 number: { type: "number" },
415 labels: { type: "array", description: "Label names (strings)" },
416 },
417 required: ["owner", "repo", "number", "labels"],
418 },
419 },
420 async run(args, ctx) {
421 const owner = mcpArgString(args, "owner");
422 const repo = mcpArgString(args, "repo");
423 const number = mcpArgNumber(args, "number");
424 const labelNames = argStringArray(args, "labels");
425 if (labelNames.length === 0) {
426 throw new McpError(ERR_INVALID_PARAMS, "labels must be a non-empty array of strings");
427 }
428 requireScope(ctx, "repo", "gluecron_label_issue");
429 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_label_issue");
430 const issue = await mcpLoadIssueByNumber(gate.repoId, number);
431 if (!issue) {
432 throw new McpError(ERR_METHOD_NOT_FOUND, `issue not found: ${owner}/${repo}#${number}`);
433 }
434
435 const applied: string[] = [];
436 for (const name of labelNames) {
437 const trimmed = name.trim();
438 if (!trimmed) continue;
439 // Ensure label row exists.
440 let [labelRow] = await db
441 .select()
442 .from(labels)
443 .where(and(eq(labels.repositoryId, gate.repoId), eq(labels.name, trimmed)))
444 .limit(1);
445 if (!labelRow) {
446 const inserted = await db
447 .insert(labels)
448 .values({ repositoryId: gate.repoId, name: trimmed })
449 .returning();
450 labelRow = inserted[0];
451 }
452 if (!labelRow) continue;
453 try {
454 await db
455 .insert(issueLabels)
456 .values({ issueId: issue.id, labelId: labelRow.id });
457 } catch {
458 /* unique violation — already attached */
459 }
460 applied.push(trimmed);
461 }
462 return { labels: applied };
463 },
464};
465
466const unlabelIssue: McpToolHandler = {
467 tool: {
468 name: "gluecron_unlabel_issue",
469 description:
470 "Detach a label from an issue. Idempotent. Requires 'repo' scope. Returns {removed: boolean}.",
523ddadccanty labs471 // Removal is trivially reversible (re-attach the label) — not destructive.
472 annotations: { title: "Remove issue label", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
0feb720Claude473 inputSchema: {
474 type: "object",
475 properties: {
476 owner: { type: "string" },
477 repo: { type: "string" },
478 number: { type: "number" },
479 label: { type: "string" },
480 },
481 required: ["owner", "repo", "number", "label"],
482 },
483 },
484 async run(args, ctx) {
485 const owner = mcpArgString(args, "owner");
486 const repo = mcpArgString(args, "repo");
487 const number = mcpArgNumber(args, "number");
488 const labelName = mcpArgString(args, "label");
489 requireScope(ctx, "repo", "gluecron_unlabel_issue");
490 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_unlabel_issue");
491 const issue = await mcpLoadIssueByNumber(gate.repoId, number);
492 if (!issue) {
493 throw new McpError(ERR_METHOD_NOT_FOUND, `issue not found: ${owner}/${repo}#${number}`);
494 }
495 const [labelRow] = await db
496 .select()
497 .from(labels)
498 .where(and(eq(labels.repositoryId, gate.repoId), eq(labels.name, labelName)))
499 .limit(1);
500 if (!labelRow) return { removed: false };
501 const result = await db
502 .delete(issueLabels)
503 .where(and(eq(issueLabels.issueId, issue.id), eq(issueLabels.labelId, labelRow.id)))
504 .returning({ id: issueLabels.id });
505 return { removed: result.length > 0 };
506 },
507};
508
509const assignIssue: McpToolHandler = {
510 tool: {
511 name: "gluecron_assign_issue",
512 description:
513 "Assign an issue to a user. Gluecron does not yet have a dedicated assignee table; assignment is modelled as an `assignee:<username>` label so it integrates with existing label tooling. Requires 'repo' scope.",
523ddadccanty labs514 annotations: { title: "Assign issue", readOnlyHint: false, destructiveHint: false },
0feb720Claude515 inputSchema: {
516 type: "object",
517 properties: {
518 owner: { type: "string" },
519 repo: { type: "string" },
520 number: { type: "number" },
521 assignee: { type: "string", description: "Username to assign" },
522 },
523 required: ["owner", "repo", "number", "assignee"],
524 },
525 },
526 async run(args, ctx) {
527 const owner = mcpArgString(args, "owner");
528 const repo = mcpArgString(args, "repo");
529 const number = mcpArgNumber(args, "number");
530 const assignee = mcpArgString(args, "assignee");
531 return await labelIssue.run(
532 { owner, repo, number, labels: [`assignee:${assignee}`] },
533 ctx
534 );
535 },
536};
537
538const searchIssues: McpToolHandler = {
539 tool: {
540 name: "gluecron_search_issues",
541 description:
542 "Search issues by title/body keyword on a single repo, filtered by state. Returns ranked rows.",
523ddadccanty labs543 annotations: { title: "Search issues", readOnlyHint: true, destructiveHint: false },
0feb720Claude544 inputSchema: {
545 type: "object",
546 properties: {
547 owner: { type: "string" },
548 repo: { type: "string" },
549 query: { type: "string", description: "Search keyword" },
550 state: { type: "string", description: "open | closed | all (default open)" },
551 limit: { type: "number" },
552 },
553 required: ["owner", "repo", "query"],
554 },
555 },
556 async run(args, ctx) {
557 const owner = mcpArgString(args, "owner");
558 const repo = mcpArgString(args, "repo");
559 const q = mcpArgString(args, "query");
560 const state = mcpArgString(args, "state", "open");
561 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25)));
562 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
563 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
564 const stateClause =
565 state === "all"
566 ? eq(issues.repositoryId, info.repoId)
567 : and(eq(issues.repositoryId, info.repoId), eq(issues.state, state));
568 const rows = await db
569 .select({
570 number: issues.number,
571 title: issues.title,
572 body: issues.body,
573 state: issues.state,
574 createdAt: issues.createdAt,
575 })
576 .from(issues)
577 .where(
578 and(
579 stateClause,
580 or(like(issues.title, pattern), like(issues.body, pattern))
581 )
582 )
583 .orderBy(desc(issues.createdAt))
584 .limit(limit);
585 return {
586 total: rows.length,
587 issues: rows.map((r) => ({
588 number: r.number,
589 title: r.title,
590 body: r.body || "",
591 state: r.state,
592 url: mcpIssueUrl(owner, repo, r.number),
593 createdAt: r.createdAt,
594 })),
595 };
596 },
597};
598
599// ---------------------------------------------------------------------------
600// PRS — close (already), request_changes, search, draft, generate_description
601// ---------------------------------------------------------------------------
602
603const requestChanges: McpToolHandler = {
604 tool: {
605 name: "gluecron_request_changes",
606 description:
607 "Post a 'changes requested' AI-review comment on a PR. The comment is tagged with isAiReview=true so the gate-checker recognises it. Requires 'repo' scope.",
523ddadccanty labs608 annotations: { title: "Request PR changes", readOnlyHint: false, destructiveHint: false },
0feb720Claude609 inputSchema: {
610 type: "object",
611 properties: {
612 owner: { type: "string" },
613 repo: { type: "string" },
614 number: { type: "number" },
615 body: { type: "string", description: "Review body (Markdown)" },
616 },
617 required: ["owner", "repo", "number", "body"],
618 },
619 },
620 async run(args, ctx) {
621 const owner = mcpArgString(args, "owner");
622 const repo = mcpArgString(args, "repo");
623 const number = mcpArgNumber(args, "number");
624 const body = mcpArgString(args, "body");
625 requireScope(ctx, "repo", "gluecron_request_changes");
626 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_request_changes");
627 const pr = await mcpLoadPrByNumber(gate.repoId, number);
628 if (!pr) {
629 throw new McpError(ERR_METHOD_NOT_FOUND, `pr not found: ${owner}/${repo}#${number}`);
630 }
631 const formatted = `**Changes requested**\n\n${body}`;
632 const [inserted] = await db
633 .insert(prComments)
634 .values({
635 pullRequestId: pr.id,
636 authorId: gate.userId,
637 body: formatted,
638 isAiReview: true,
639 })
640 .returning();
641 return { commentId: inserted.id };
642 },
643};
644
645const searchPrs: McpToolHandler = {
646 tool: {
647 name: "gluecron_search_prs",
648 description: "Search pull requests by title/body keyword on a repo.",
523ddadccanty labs649 annotations: { title: "Search pull requests", readOnlyHint: true, destructiveHint: false },
0feb720Claude650 inputSchema: {
651 type: "object",
652 properties: {
653 owner: { type: "string" },
654 repo: { type: "string" },
655 query: { type: "string" },
656 state: { type: "string", description: "open|closed|merged|all (default open)" },
657 limit: { type: "number" },
658 },
659 required: ["owner", "repo", "query"],
660 },
661 },
662 async run(args, ctx) {
663 const owner = mcpArgString(args, "owner");
664 const repo = mcpArgString(args, "repo");
665 const q = mcpArgString(args, "query");
666 const state = mcpArgString(args, "state", "open");
667 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25)));
668 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
669 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
670 const stateClause =
671 state === "all"
672 ? eq(pullRequests.repositoryId, info.repoId)
673 : and(eq(pullRequests.repositoryId, info.repoId), eq(pullRequests.state, state));
674 const rows = await db
675 .select({
676 number: pullRequests.number,
677 title: pullRequests.title,
678 body: pullRequests.body,
679 state: pullRequests.state,
680 baseBranch: pullRequests.baseBranch,
681 headBranch: pullRequests.headBranch,
682 isDraft: pullRequests.isDraft,
683 createdAt: pullRequests.createdAt,
684 })
685 .from(pullRequests)
686 .where(
687 and(
688 stateClause,
689 or(like(pullRequests.title, pattern), like(pullRequests.body, pattern))
690 )
691 )
692 .orderBy(desc(pullRequests.createdAt))
693 .limit(limit);
694 return {
695 total: rows.length,
696 prs: rows.map((p) => ({
697 number: p.number,
698 title: p.title,
699 body: p.body || "",
700 state: p.state,
701 baseBranch: p.baseBranch,
702 headBranch: p.headBranch,
703 isDraft: p.isDraft,
704 url: mcpPrUrl(owner, repo, p.number),
705 createdAt: p.createdAt,
706 })),
707 };
708 },
709};
710
711const openDraftPr: McpToolHandler = {
712 tool: {
713 name: "gluecron_open_draft_pr",
714 description:
715 "Open a draft pull request. Same payload as gluecron_create_pr but forces is_draft=true. Useful for AI-in-progress PRs that shouldn't run mergeability checks yet.",
523ddadccanty labs716 annotations: { title: "Open draft pull request", readOnlyHint: false, destructiveHint: false },
0feb720Claude717 inputSchema: {
718 type: "object",
719 properties: {
720 owner: { type: "string" },
721 repo: { type: "string" },
722 title: { type: "string" },
723 body: { type: "string" },
724 head_branch: { type: "string" },
725 base_branch: { type: "string" },
726 },
727 required: ["owner", "repo", "title", "head_branch"],
728 },
729 },
730 async run(args, ctx) {
731 const owner = mcpArgString(args, "owner");
732 const repo = mcpArgString(args, "repo");
733 const title = mcpArgString(args, "title");
734 const body = mcpArgString(args, "body", "");
735 const headBranch = mcpArgString(args, "head_branch");
736 requireScope(ctx, "repo", "gluecron_open_draft_pr");
737 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_open_draft_pr");
738 const baseBranch = mcpArgString(args, "base_branch", gate.defaultBranch);
739 if (baseBranch === headBranch) {
740 throw new McpError(ERR_INVALID_PARAMS, "base and head branches must be different");
741 }
742 const [pr] = await db
743 .insert(pullRequests)
744 .values({
745 repositoryId: gate.repoId,
746 authorId: gate.userId,
747 title,
748 body: body || null,
749 baseBranch,
750 headBranch,
751 isDraft: true,
752 })
753 .returning();
754 return { number: pr.number, url: mcpPrUrl(owner, repo, pr.number), isDraft: true };
755 },
756};
757
758const generatePrDescription: McpToolHandler = {
759 tool: {
760 name: "gluecron_generate_pr_description",
761 description:
762 "Generate an AI commit-message-style description for a diff. Uses src/lib/ai-commit-message.ts under the hood; gracefully degrades to a heuristic when ANTHROPIC_API_KEY is missing. Returns {subject, body}.",
523ddadccanty labs763 // Pure compute over the supplied diff — persists nothing.
764 annotations: { title: "Generate PR description", readOnlyHint: true, destructiveHint: false },
0feb720Claude765 inputSchema: {
766 type: "object",
767 properties: {
768 diff: { type: "string", description: "Unified-diff body" },
769 style: {
770 type: "string",
771 description: "'conventional' (default) or 'plain'",
772 },
773 },
774 required: ["diff"],
775 },
776 },
777 async run(args) {
778 const diff = mcpArgString(args, "diff");
779 const style = mcpArgString(args, "style", "conventional");
780 const { generateCommitMessage } = await import("./ai-commit-message");
781 const msg = await generateCommitMessage(diff, {
782 style: style === "plain" ? "plain" : "conventional",
783 });
784 return msg;
785 },
786};
787
788// ---------------------------------------------------------------------------
789// FILES & GIT PLUMBING
790// ---------------------------------------------------------------------------
791
792const readFile: McpToolHandler = {
793 tool: {
794 name: "gluecron_read_file",
795 description:
796 "Read a file from a repo at a given ref. Mirrors GET /api/v2/repos/:owner/:repo/contents/:path. Returns {path, size, content, encoding}.",
523ddadccanty labs797 annotations: { title: "Read file", readOnlyHint: true, destructiveHint: false },
0feb720Claude798 inputSchema: {
799 type: "object",
800 properties: {
801 owner: { type: "string" },
802 repo: { type: "string" },
803 ref: { type: "string", description: "Branch / tag / sha (default HEAD)" },
804 path: { type: "string" },
805 encoding: { type: "string", description: "'utf8' (default) or 'base64'" },
806 },
807 required: ["owner", "repo", "path"],
808 },
809 },
810 async run(args, ctx) {
811 const owner = mcpArgString(args, "owner");
812 const repo = mcpArgString(args, "repo");
813 const ref = mcpArgString(args, "ref", "HEAD");
814 const filePath = mcpArgString(args, "path");
815 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
816 const blob = await getBlob(owner, repo, ref, filePath);
817 if (!blob) {
818 throw new McpError(ERR_METHOD_NOT_FOUND, `path not found: ${owner}/${repo}@${ref}:${filePath}`);
819 }
820 return {
821 path: filePath,
822 size: blob.size,
823 isBinary: blob.isBinary,
824 content: blob.isBinary ? null : blob.content,
825 encoding: blob.isBinary ? null : "utf8",
826 };
827 },
828};
829
830const writeFile: McpToolHandler = {
831 tool: {
832 name: "gluecron_write_file",
833 description:
834 "Create or update a file on a branch via git plumbing. Wraps `createOrUpdateFileOnBranch`. Pass `content` as a UTF-8 string OR `content_base64` for binary. Requires 'repo' scope.",
523ddadccanty labs835 // Overwrites are recoverable from git history — not destructive.
836 annotations: { title: "Write file", readOnlyHint: false, destructiveHint: false },
0feb720Claude837 inputSchema: {
838 type: "object",
839 properties: {
840 owner: { type: "string" },
841 repo: { type: "string" },
842 path: { type: "string" },
843 branch: { type: "string" },
844 message: { type: "string", description: "Commit message" },
845 content: { type: "string", description: "UTF-8 file body (optional)" },
846 content_base64: { type: "string", description: "Base64 file body (optional)" },
847 expect_blob_sha: {
848 type: "string",
849 description: "Optimistic-concurrency check: existing blob sha must match.",
850 },
851 },
852 required: ["owner", "repo", "path", "branch", "message"],
853 },
854 },
855 async run(args, ctx) {
856 const owner = mcpArgString(args, "owner");
857 const repo = mcpArgString(args, "repo");
858 const filePath = mcpArgString(args, "path");
859 const branch = mcpArgString(args, "branch");
860 const message = mcpArgString(args, "message");
861 requireScope(ctx, "repo", "gluecron_write_file");
862 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_write_file");
863
864 let bytes: Uint8Array;
865 if (typeof args.content === "string") {
866 bytes = new TextEncoder().encode(args.content);
867 } else if (typeof args.content_base64 === "string") {
868 try {
869 bytes = new Uint8Array(Buffer.from(args.content_base64, "base64"));
870 } catch {
871 throw new McpError(ERR_INVALID_PARAMS, "content_base64 is not valid base64");
872 }
873 } else {
874 throw new McpError(ERR_INVALID_PARAMS, "either content or content_base64 is required");
875 }
876
877 const [author] = await db
878 .select({ username: users.username, email: users.email })
879 .from(users)
880 .where(eq(users.id, gate.userId))
881 .limit(1);
882 if (!author) throw new McpError(ERR_INVALID_PARAMS, "author lookup failed");
883
884 const expectBlobSha =
885 typeof args.expect_blob_sha === "string" ? args.expect_blob_sha : null;
886
887 const res = await createOrUpdateFileOnBranch({
888 owner,
889 name: repo,
890 branch,
891 filePath,
892 bytes,
893 message,
894 authorName: author.username,
895 authorEmail: author.email || `${author.username}@users.noreply.gluecron`,
896 expectBlobSha,
897 });
898 if ("error" in res) {
899 if (res.error === "sha-mismatch") {
900 throw new McpError(ERR_INVALID_PARAMS, "sha does not match current blob at path");
901 }
902 throw new McpError(ERR_INVALID_PARAMS, `write failed: ${res.error}`);
903 }
904 return {
905 commitSha: res.commitSha,
906 blobSha: res.blobSha,
907 parentSha: res.parentSha,
908 branch,
909 path: filePath,
910 };
911 },
912};
913
914const deleteFile: McpToolHandler = {
915 tool: {
916 name: "gluecron_delete_file",
917 description:
918 "Delete a file from a branch via git plumbing. Requires the existing blob sha (optimistic concurrency) and 'repo' scope. Mirrors DELETE /api/v2/contents.",
523ddadccanty labs919 annotations: { title: "Delete file", readOnlyHint: false, destructiveHint: true },
0feb720Claude920 inputSchema: {
921 type: "object",
922 properties: {
923 owner: { type: "string" },
924 repo: { type: "string" },
925 path: { type: "string" },
926 branch: { type: "string" },
927 message: { type: "string" },
928 sha: { type: "string", description: "Current blob sha (40-hex)" },
929 },
930 required: ["owner", "repo", "path", "branch", "message", "sha"],
931 },
932 },
933 async run(args, ctx) {
934 const owner = mcpArgString(args, "owner");
935 const repo = mcpArgString(args, "repo");
936 const filePath = mcpArgString(args, "path");
937 const branch = mcpArgString(args, "branch");
938 const message = mcpArgString(args, "message");
939 const expectSha = mcpArgString(args, "sha");
940 if (!/^[0-9a-f]{40}$/.test(expectSha)) {
941 throw new McpError(ERR_INVALID_PARAMS, "sha must be a 40-char lowercase hex string");
942 }
943 requireScope(ctx, "repo", "gluecron_delete_file");
944 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_delete_file");
945
946 const fullRef = `refs/heads/${branch}`;
947 const repoDir = getRepoPath(owner, repo);
948 const parentSha = await resolveRef(owner, repo, fullRef);
949 if (!parentSha) throw new McpError(ERR_METHOD_NOT_FOUND, "branch not found");
950 const existing = await getBlobShaAtPath(owner, repo, branch, filePath);
951 if (!existing) throw new McpError(ERR_METHOD_NOT_FOUND, "file not found");
952 if (existing !== expectSha) {
953 throw new McpError(ERR_INVALID_PARAMS, "sha does not match current blob at path");
954 }
955
956 const [author] = await db
957 .select({ username: users.username, email: users.email })
958 .from(users)
959 .where(eq(users.id, gate.userId))
960 .limit(1);
961 if (!author) throw new McpError(ERR_INVALID_PARAMS, "author lookup failed");
962
963 // Stand up a transient index + work-tree dir so git's safety checks pass.
964 const tmpIndex = join(
965 repoDir,
966 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
967 );
968 const tmpWorkTree = join(
969 repoDir,
970 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
971 );
972 await mkdir(tmpWorkTree, { recursive: true });
973 const env = {
974 ...process.env,
975 GIT_INDEX_FILE: tmpIndex,
976 GIT_DIR: repoDir,
977 GIT_WORK_TREE: tmpWorkTree,
978 GIT_AUTHOR_NAME: author.username,
979 GIT_AUTHOR_EMAIL: author.email || `${author.username}@users.noreply.gluecron`,
980 GIT_COMMITTER_NAME: author.username,
981 GIT_COMMITTER_EMAIL: author.email || `${author.username}@users.noreply.gluecron`,
982 };
983 const cleanup = async () => {
984 try {
985 await unlink(tmpIndex).catch(() => {});
986 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
987 } catch {
988 /* ignore */
989 }
990 };
991
992 const run = async (cmd: string[]): Promise<{ code: number; out: string }> => {
993 const proc = Bun.spawn(cmd, {
994 cwd: repoDir,
995 env,
996 stdout: "pipe",
997 stderr: "pipe",
998 });
999 const out = await new Response(proc.stdout).text();
1000 const code = await proc.exited;
1001 return { code, out };
1002 };
1003
1004 try {
1005 const rt = await run(["git", "read-tree", parentSha]);
1006 if (rt.code !== 0) {
1007 await cleanup();
1008 throw new McpError(ERR_INVALID_PARAMS, "read-tree failed");
1009 }
1010 const ui = await run(["git", "update-index", "--remove", filePath]);
1011 if (ui.code !== 0) {
1012 await cleanup();
1013 throw new McpError(ERR_INVALID_PARAMS, "update-index --remove failed");
1014 }
1015 const wt = await run(["git", "write-tree"]);
1016 const newTree = wt.out.trim();
1017 if (wt.code !== 0 || !/^[0-9a-f]{40}$/.test(newTree)) {
1018 await cleanup();
1019 throw new McpError(ERR_INVALID_PARAMS, "write-tree failed");
1020 }
1021 const ct = await run([
1022 "git",
1023 "commit-tree",
1024 newTree,
1025 "-p",
1026 parentSha,
1027 "-m",
1028 message,
1029 ]);
1030 const commitSha = ct.out.trim();
1031 if (ct.code !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1032 await cleanup();
1033 throw new McpError(ERR_INVALID_PARAMS, "commit-tree failed");
1034 }
1035 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1036 await cleanup();
1037 if (!ok) throw new McpError(ERR_INVALID_PARAMS, "update-ref failed");
1038 return { commitSha, branch, deletedPath: filePath };
1039 } catch (err) {
1040 await cleanup();
1041 throw err;
1042 }
1043 },
1044};
1045
1046const listTree: McpToolHandler = {
1047 tool: {
1048 name: "gluecron_list_tree",
1049 description:
1050 "List directory contents at a ref. Optionally `recursive: true` returns the full file list. Mirrors GET /api/v2/repos/.../tree/:ref.",
523ddadccanty labs1051 annotations: { title: "List tree", readOnlyHint: true, destructiveHint: false },
0feb720Claude1052 inputSchema: {
1053 type: "object",
1054 properties: {
1055 owner: { type: "string" },
1056 repo: { type: "string" },
1057 ref: { type: "string", description: "Branch / tag / sha" },
1058 path: { type: "string", description: "Sub-path within the repo" },
1059 recursive: { type: "boolean" },
1060 },
1061 required: ["owner", "repo", "ref"],
1062 },
1063 },
1064 async run(args, ctx) {
1065 const owner = mcpArgString(args, "owner");
1066 const repo = mcpArgString(args, "repo");
1067 const ref = mcpArgString(args, "ref");
1068 const path = mcpArgString(args, "path", "");
1069 const recursive = argBool(args, "recursive", false);
1070 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1071 if (recursive) {
1072 const out = await getTreeRecursive(owner, repo, ref, 50_000);
1073 if (!out) throw new McpError(ERR_METHOD_NOT_FOUND, "ref not found");
1074 return out;
1075 }
1076 const tree = await getTree(owner, repo, ref, path);
1077 return { path, ref, entries: tree };
1078 },
1079};
1080
1081const getCommitTool: McpToolHandler = {
1082 tool: {
1083 name: "gluecron_get_commit",
1084 description: "Fetch a single commit's metadata by SHA. Mirrors GET /api/v2/repos/.../commits/:sha.",
523ddadccanty labs1085 annotations: { title: "Get commit", readOnlyHint: true, destructiveHint: false },
0feb720Claude1086 inputSchema: {
1087 type: "object",
1088 properties: {
1089 owner: { type: "string" },
1090 repo: { type: "string" },
1091 sha: { type: "string", description: "Commit SHA" },
1092 },
1093 required: ["owner", "repo", "sha"],
1094 },
1095 },
1096 async run(args, ctx) {
1097 const owner = mcpArgString(args, "owner");
1098 const repo = mcpArgString(args, "repo");
1099 const sha = mcpArgString(args, "sha");
1100 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1101 const commit = await getCommit(owner, repo, sha);
1102 if (!commit) throw new McpError(ERR_METHOD_NOT_FOUND, `commit not found: ${sha}`);
1103 return commit;
1104 },
1105};
1106
1107const createBranch: McpToolHandler = {
1108 tool: {
1109 name: "gluecron_create_branch",
1110 description:
1111 "Create a new branch ref pointing at an existing sha. Mirrors POST /api/v2/repos/.../git/refs. Requires 'repo' scope. Returns {ref, sha}.",
523ddadccanty labs1112 annotations: { title: "Create branch", readOnlyHint: false, destructiveHint: false },
0feb720Claude1113 inputSchema: {
1114 type: "object",
1115 properties: {
1116 owner: { type: "string" },
1117 repo: { type: "string" },
1118 branch: { type: "string", description: "New branch name (short form)" },
1119 sha: { type: "string", description: "Target commit sha (40-hex)" },
1120 },
1121 required: ["owner", "repo", "branch", "sha"],
1122 },
1123 },
1124 async run(args, ctx) {
1125 const owner = mcpArgString(args, "owner");
1126 const repo = mcpArgString(args, "repo");
1127 const branchName = mcpArgString(args, "branch");
1128 const sha = mcpArgString(args, "sha");
1129 if (!/^[0-9a-f]{40}$/.test(sha)) {
1130 throw new McpError(ERR_INVALID_PARAMS, "sha must be a 40-char lowercase hex string");
1131 }
1132 requireScope(ctx, "repo", "gluecron_create_branch");
1133 await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_create_branch");
1134
1135 if (!(await objectExists(owner, repo, sha))) {
1136 throw new McpError(ERR_INVALID_PARAMS, `sha not found in repository: ${sha}`);
1137 }
1138 const ref = `refs/heads/${branchName}`;
1139 if (await refExists(owner, repo, ref)) {
1140 const existing = await resolveRef(owner, repo, ref);
1141 if (existing === sha) return { ref, sha, alreadyExists: true };
1142 throw new McpError(ERR_INVALID_PARAMS, `ref already exists: ${ref}`);
1143 }
1144 const ok = await updateRef(owner, repo, ref, sha);
1145 if (!ok) throw new McpError(ERR_INVALID_PARAMS, "update-ref failed");
1146 return { ref, sha };
1147 },
1148};
1149
1150const atomicMultiFileCommit: McpToolHandler = {
1151 tool: {
1152 name: "gluecron_atomic_multi_file_commit",
1153 description:
1154 "Apply a set of file writes + deletes as a single atomic commit on a branch (creates the branch if it doesn't exist). The killer agent tool: blob/tree/commit/ref-update sequence. Each change is `{path, content?, content_base64?, deleted?}`. Requires 'repo' scope.",
523ddadccanty labs1155 // Commits (incl. deletes) preserve prior state in git history — not destructive.
1156 annotations: { title: "Atomic multi-file commit", readOnlyHint: false, destructiveHint: false },
0feb720Claude1157 inputSchema: {
1158 type: "object",
1159 properties: {
1160 owner: { type: "string" },
1161 repo: { type: "string" },
1162 branch: { type: "string" },
1163 base_branch: {
1164 type: "string",
1165 description: "Branch to fork from when `branch` doesn't yet exist (default: repo default).",
1166 },
1167 message: { type: "string" },
1168 changes: {
1169 type: "array",
1170 description: "Array of {path, content?|content_base64?, deleted?} entries.",
1171 },
1172 },
1173 required: ["owner", "repo", "branch", "message", "changes"],
1174 },
1175 },
1176 async run(args, ctx) {
1177 const owner = mcpArgString(args, "owner");
1178 const repo = mcpArgString(args, "repo");
1179 const branch = mcpArgString(args, "branch");
1180 const message = mcpArgString(args, "message");
1181 requireScope(ctx, "repo", "gluecron_atomic_multi_file_commit");
1182 const gate = await mcpGateWriteAccess(
1183 { owner, repo },
1184 ctx,
1185 "gluecron_atomic_multi_file_commit"
1186 );
1187 const baseBranch = mcpArgString(args, "base_branch", gate.defaultBranch);
1188
1189 const rawChanges = Array.isArray(args.changes) ? args.changes : [];
1190 if (rawChanges.length === 0) {
1191 throw new McpError(ERR_INVALID_PARAMS, "changes must be a non-empty array");
1192 }
1193 type Change = {
1194 path: string;
1195 bytes?: Uint8Array;
1196 deleted: boolean;
1197 };
1198 const changes: Change[] = [];
1199 for (const entry of rawChanges) {
1200 if (!entry || typeof entry !== "object") {
1201 throw new McpError(ERR_INVALID_PARAMS, "each change must be an object");
1202 }
1203 const e = entry as Record<string, unknown>;
1204 const path = typeof e.path === "string" ? e.path.trim() : "";
1205 if (!path) {
1206 throw new McpError(ERR_INVALID_PARAMS, "change.path is required");
1207 }
1208 const deleted = e.deleted === true;
1209 let bytes: Uint8Array | undefined;
1210 if (!deleted) {
1211 if (typeof e.content === "string") {
1212 bytes = new TextEncoder().encode(e.content);
1213 } else if (typeof e.content_base64 === "string") {
1214 try {
1215 bytes = new Uint8Array(Buffer.from(e.content_base64, "base64"));
1216 } catch {
1217 throw new McpError(ERR_INVALID_PARAMS, `bad base64 for ${path}`);
1218 }
1219 } else {
1220 throw new McpError(
1221 ERR_INVALID_PARAMS,
1222 `change.${path}: provide content, content_base64, or deleted:true`
1223 );
1224 }
1225 }
1226 changes.push({ path, bytes, deleted });
1227 }
1228
1229 const repoDir = getRepoPath(owner, repo);
1230 const fullRef = `refs/heads/${branch}`;
1231
1232 // Resolve parent — branch exists OR fall back to base_branch HEAD.
1233 let parentSha: string | null = null;
1234 if (await refExists(owner, repo, fullRef)) {
1235 parentSha = await resolveRef(owner, repo, fullRef);
1236 } else {
1237 parentSha = await resolveRef(owner, repo, baseBranch);
1238 if (!parentSha) {
1239 throw new McpError(
1240 ERR_INVALID_PARAMS,
1241 `base_branch ${baseBranch} does not exist`
1242 );
1243 }
1244 }
1245
1246 const [author] = await db
1247 .select({ username: users.username, email: users.email })
1248 .from(users)
1249 .where(eq(users.id, gate.userId))
1250 .limit(1);
1251 if (!author) throw new McpError(ERR_INVALID_PARAMS, "author lookup failed");
1252
1253 const tmpIndex = join(
1254 repoDir,
1255 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1256 );
1257 const tmpWorkTree = join(
1258 repoDir,
1259 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1260 );
1261 await mkdir(tmpWorkTree, { recursive: true });
1262 const env = {
1263 ...process.env,
1264 GIT_INDEX_FILE: tmpIndex,
1265 GIT_DIR: repoDir,
1266 GIT_WORK_TREE: tmpWorkTree,
1267 GIT_AUTHOR_NAME: author.username,
1268 GIT_AUTHOR_EMAIL: author.email || `${author.username}@users.noreply.gluecron`,
1269 GIT_COMMITTER_NAME: author.username,
1270 GIT_COMMITTER_EMAIL: author.email || `${author.username}@users.noreply.gluecron`,
1271 };
1272 const cleanup = async () => {
1273 await unlink(tmpIndex).catch(() => {});
1274 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
1275 };
1276 const run = async (cmd: string[]): Promise<{ code: number; out: string }> => {
1277 const proc = Bun.spawn(cmd, { cwd: repoDir, env, stdout: "pipe", stderr: "pipe" });
1278 const out = await new Response(proc.stdout).text();
1279 const code = await proc.exited;
1280 return { code, out };
1281 };
1282
1283 try {
1284 // 1. Seed the index from the parent tree.
1285 if (parentSha) {
1286 const rt = await run(["git", "read-tree", parentSha]);
1287 if (rt.code !== 0) {
1288 await cleanup();
1289 throw new McpError(ERR_INVALID_PARAMS, "read-tree failed");
1290 }
1291 }
1292
1293 // 2. Apply each change.
1294 for (const change of changes) {
1295 if (change.deleted) {
1296 const ui = await run(["git", "update-index", "--remove", change.path]);
1297 if (ui.code !== 0) {
1298 await cleanup();
1299 throw new McpError(
1300 ERR_INVALID_PARAMS,
1301 `delete failed: ${change.path}`
1302 );
1303 }
1304 } else {
1305 const blobSha = await writeBlob(owner, repo, change.bytes!);
1306 if (!blobSha) {
1307 await cleanup();
1308 throw new McpError(ERR_INVALID_PARAMS, `write-blob failed: ${change.path}`);
1309 }
1310 const ui = await run([
1311 "git",
1312 "update-index",
1313 "--add",
1314 "--cacheinfo",
1315 `100644,${blobSha},${change.path}`,
1316 ]);
1317 if (ui.code !== 0) {
1318 await cleanup();
1319 throw new McpError(ERR_INVALID_PARAMS, `update-index failed: ${change.path}`);
1320 }
1321 }
1322 }
1323
1324 // 3. write-tree → commit-tree → update-ref.
1325 const wt = await run(["git", "write-tree"]);
1326 const newTree = wt.out.trim();
1327 if (wt.code !== 0 || !/^[0-9a-f]{40}$/.test(newTree)) {
1328 await cleanup();
1329 throw new McpError(ERR_INVALID_PARAMS, "write-tree failed");
1330 }
1331 const ctArgs = parentSha
1332 ? ["git", "commit-tree", newTree, "-p", parentSha, "-m", message]
1333 : ["git", "commit-tree", newTree, "-m", message];
1334 const ct = await run(ctArgs);
1335 const commitSha = ct.out.trim();
1336 if (ct.code !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1337 await cleanup();
1338 throw new McpError(ERR_INVALID_PARAMS, "commit-tree failed");
1339 }
1340 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha || undefined);
1341 await cleanup();
1342 if (!ok) throw new McpError(ERR_INVALID_PARAMS, "update-ref failed");
1343
1344 return {
1345 commitSha,
1346 branch,
1347 parentSha,
1348 files: changes.map((c) => ({ path: c.path, deleted: c.deleted })),
1349 };
1350 } catch (err) {
1351 await cleanup();
1352 throw err;
1353 }
1354 },
1355};
1356
1357// ---------------------------------------------------------------------------
1358// AI WORKFLOWS
1359// ---------------------------------------------------------------------------
1360
1361const shipSpec: McpToolHandler = {
1362 tool: {
1363 name: "gluecron_ship_spec",
1364 description:
1365 "Drop a spec file in .gluecron/specs/ with status: ready so the autopilot picks it up. Wraps voice-to-pr.shipAsSpec — handle both voice + manual specs. Requires 'repo' scope.",
523ddadccanty labs1366 annotations: { title: "Ship spec", readOnlyHint: false, destructiveHint: false },
0feb720Claude1367 inputSchema: {
1368 type: "object",
1369 properties: {
1370 owner: { type: "string" },
1371 repo: { type: "string" },
1372 title: { type: "string" },
1373 body: { type: "string", description: "Spec body (Markdown)" },
1374 },
1375 required: ["owner", "repo", "title", "body"],
1376 },
1377 },
1378 async run(args, ctx) {
1379 const owner = mcpArgString(args, "owner");
1380 const repo = mcpArgString(args, "repo");
1381 const title = mcpArgString(args, "title");
1382 const body = mcpArgString(args, "body");
1383 requireScope(ctx, "repo", "gluecron_ship_spec");
1384 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_ship_spec");
1385 const { shipAsSpec } = await import("./voice-to-pr");
1386 // We use voice-to-pr's `shipAsSpec` even for non-voice specs — it
1387 // builds the front-matter + commits via createOrUpdateFileOnBranch.
1388 const transcript = `${title}\n\n${body}`;
1389 const res = await shipAsSpec({
1390 repositoryId: gate.repoId,
1391 userId: gate.userId,
1392 transcript,
1393 interpretation: { kind: "spec", title, body_markdown: body },
1394 });
1395 if (!res.ok) {
1396 throw new McpError(ERR_INVALID_PARAMS, res.error);
1397 }
1398 return res;
1399 },
1400};
1401
1402const voiceToPr: McpToolHandler = {
1403 tool: {
1404 name: "gluecron_voice_to_pr",
1405 description:
1406 "Interpret a free-form voice transcript and either ship it as a spec or create an issue (caller picks via `as`). Wraps src/lib/voice-to-pr.ts. Requires 'repo' scope.",
523ddadccanty labs1407 annotations: { title: "Voice to PR", readOnlyHint: false, destructiveHint: false },
0feb720Claude1408 inputSchema: {
1409 type: "object",
1410 properties: {
1411 owner: { type: "string" },
1412 repo: { type: "string" },
1413 transcript: { type: "string" },
1414 as: {
1415 type: "string",
1416 description: "'spec' or 'issue' (default: auto via interpretVoiceTranscript)",
1417 },
1418 },
1419 required: ["owner", "repo", "transcript"],
1420 },
1421 },
1422 async run(args, ctx) {
1423 const owner = mcpArgString(args, "owner");
1424 const repo = mcpArgString(args, "repo");
1425 const transcript = mcpArgString(args, "transcript");
1426 const as = mcpArgString(args, "as", "auto");
1427 requireScope(ctx, "repo", "gluecron_voice_to_pr");
1428 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_voice_to_pr");
1429
1430 const { interpretVoiceTranscript, shipAsSpec, createIssueFromVoice } =
1431 await import("./voice-to-pr");
1432
1433 let intent: "spec" | "issue";
1434 let interpretation;
1435 if (as === "spec" || as === "issue") {
1436 const interp = await interpretVoiceTranscript({ transcript });
1437 interpretation = interp.ok ? interp.suggestion : undefined;
1438 intent = as;
1439 } else {
1440 const interp = await interpretVoiceTranscript({ transcript });
1441 if (!interp.ok) {
1442 throw new McpError(ERR_INVALID_PARAMS, interp.error);
1443 }
1444 interpretation = interp.suggestion;
1445 intent = interp.suggestion.kind === "issue" ? "issue" : "spec";
1446 }
1447
1448 if (intent === "spec") {
1449 const res = await shipAsSpec({
1450 repositoryId: gate.repoId,
1451 userId: gate.userId,
1452 transcript,
1453 interpretation,
1454 });
1455 if (!res.ok) throw new McpError(ERR_INVALID_PARAMS, res.error);
1456 return { kind: "spec", ...res };
1457 }
1458 const res = await createIssueFromVoice({
1459 repositoryId: gate.repoId,
1460 userId: gate.userId,
1461 transcript,
1462 interpretation,
1463 });
1464 if (!res.ok) throw new McpError(ERR_INVALID_PARAMS, res.error);
1465 return { kind: "issue", ...res };
1466 },
1467};
1468
1469const refactorAcrossRepos: McpToolHandler = {
1470 tool: {
1471 name: "gluecron_refactor_across_repos",
1472 description:
1473 "Plan + execute a refactor that spans multiple repos owned by the caller. Wraps src/lib/multi-repo-refactor.ts. `dry_run: true` returns the plan only. Requires 'repo' scope.",
523ddadccanty labs1474 // Creates branches/PRs across repos; prior state stays in git history — not destructive.
1475 annotations: { title: "Refactor across repos", readOnlyHint: false, destructiveHint: false },
0feb720Claude1476 inputSchema: {
1477 type: "object",
1478 properties: {
1479 description: { type: "string", description: "Natural-language description" },
1480 repository_ids: {
1481 type: "array",
1482 description: "Optional explicit repo IDs to scope the refactor to.",
1483 },
1484 dry_run: {
1485 type: "boolean",
1486 description: "When true, returns the plan and does NOT execute.",
1487 },
1488 },
1489 required: ["description"],
1490 },
1491 },
1492 async run(args, ctx) {
1493 const description = mcpArgString(args, "description");
1494 const userId = mcpRequireAuthedCtx(ctx, "gluecron_refactor_across_repos");
1495 requireScope(ctx, "repo", "gluecron_refactor_across_repos");
1496 const dryRun = argBool(args, "dry_run", false);
1497 const ids = argStringArray(args, "repository_ids");
1498 const { planRefactor, executeRefactor } = await import("./multi-repo-refactor");
1499 const planRes = await planRefactor({
1500 userId,
1501 description,
1502 repositoryIds: ids.length ? ids : undefined,
1503 });
1504 if (!planRes.ok) throw new McpError(ERR_INVALID_PARAMS, planRes.error);
1505 if (dryRun) return { plan: planRes.plan, refactor: planRes.refactor, executed: false };
1506 const exec = await executeRefactor({ refactorId: planRes.refactor.id });
1507 if (!exec.ok) throw new McpError(ERR_INVALID_PARAMS, exec.error);
1508 return {
1509 plan: planRes.plan,
1510 refactor: exec.refactor,
1511 children: exec.children,
1512 executed: true,
1513 };
1514 },
1515};
1516
1517const explainRepo: McpToolHandler = {
1518 tool: {
1519 name: "gluecron_explain_repo",
1520 description:
1521 "Return the cached AI 'explain this codebase' Markdown for a repo. Pure read — never triggers a new generation (use the web UI for that).",
523ddadccanty labs1522 annotations: { title: "Explain repository (cached)", readOnlyHint: true, destructiveHint: false },
0feb720Claude1523 inputSchema: {
1524 type: "object",
1525 properties: {
1526 owner: { type: "string" },
1527 repo: { type: "string" },
1528 },
1529 required: ["owner", "repo"],
1530 },
1531 },
1532 async run(args, ctx) {
1533 const owner = mcpArgString(args, "owner");
1534 const repo = mcpArgString(args, "repo");
1535 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1536 const { codebaseExplanations } = await import("../db/schema");
1537 const [row] = await db
1538 .select()
1539 .from(codebaseExplanations)
1540 .where(
1541 eq(
1542 codebaseExplanations.repositoryId,
1543 (await mcpResolveAccessibleRepo(owner, repo, ctx.userId)).repoId
1544 )
1545 )
1546 .orderBy(desc(codebaseExplanations.generatedAt))
1547 .limit(1);
1548 if (!row) return { explanation: null };
1549 return {
1550 commitSha: row.commitSha,
1551 generatedAt: row.generatedAt,
1552 markdown: row.markdown,
1553 };
1554 },
1555};
1556
1557const chatWithRepo: McpToolHandler = {
1558 tool: {
1559 name: "gluecron_chat_with_repo",
1560 description:
1561 "Start a new chat with a repo: creates a chat row, sends the first user message, streams + persists the assistant reply. Returns {chat_id, reply}. Requires authentication.",
523ddadccanty labs1562 // Persists chat rows — a write, though a benign one.
1563 annotations: { title: "Chat with repository", readOnlyHint: false, destructiveHint: false },
0feb720Claude1564 inputSchema: {
1565 type: "object",
1566 properties: {
1567 owner: { type: "string" },
1568 repo: { type: "string" },
1569 message: { type: "string", description: "Initial user message" },
1570 title: { type: "string", description: "Chat title (optional)" },
1571 },
1572 required: ["owner", "repo", "message"],
1573 },
1574 },
1575 async run(args, ctx) {
1576 const owner = mcpArgString(args, "owner");
1577 const repo = mcpArgString(args, "repo");
1578 const userMessage = mcpArgString(args, "message");
1579 const title = mcpArgString(args, "title", "");
1580 const userId = mcpRequireAuthedCtx(ctx, "gluecron_chat_with_repo");
1581 const info = await mcpResolveAccessibleRepo(owner, repo, userId);
1582 const { createChat, appendUserMessage, streamAssistantReply } = await import(
1583 "./repo-chat"
1584 );
1585 const chat = await createChat({
1586 repositoryId: info.repoId,
1587 ownerUserId: userId,
1588 title: title || userMessage.slice(0, 80),
1589 });
1590 if (!chat) throw new McpError(ERR_INVALID_PARAMS, "chat creation failed");
1591 await appendUserMessage(chat.id, userMessage);
1592 const reply = await streamAssistantReply({
1593 chatId: chat.id,
1594 repoId: info.repoId,
1595 userMessage,
1596 });
1597 return {
1598 chat_id: chat.id,
1599 reply: reply ? { id: reply.id, content: reply.content, citations: reply.citations } : null,
1600 };
1601 },
1602};
1603
1604const chatContinue: McpToolHandler = {
1605 tool: {
1606 name: "gluecron_chat_continue",
1607 description:
1608 "Send another message to an existing repo chat. Returns the assistant's reply.",
523ddadccanty labs1609 annotations: { title: "Continue repository chat", readOnlyHint: false, destructiveHint: false },
0feb720Claude1610 inputSchema: {
1611 type: "object",
1612 properties: {
1613 chat_id: { type: "string" },
1614 message: { type: "string" },
1615 },
1616 required: ["chat_id", "message"],
1617 },
1618 },
1619 async run(args, ctx) {
1620 const chatId = mcpArgString(args, "chat_id");
1621 const userMessage = mcpArgString(args, "message");
1622 const userId = mcpRequireAuthedCtx(ctx, "gluecron_chat_continue");
1623 const { getChatForUser, appendUserMessage, streamAssistantReply } = await import(
1624 "./repo-chat"
1625 );
1626 const chat = await getChatForUser(chatId, userId);
1627 if (!chat) throw new McpError(ERR_METHOD_NOT_FOUND, "chat not found");
1628 await appendUserMessage(chatId, userMessage);
1629 const reply = await streamAssistantReply({
1630 chatId,
1631 repoId: chat.repositoryId,
1632 userMessage,
1633 });
1634 return {
1635 chat_id: chatId,
1636 reply: reply ? { id: reply.id, content: reply.content, citations: reply.citations } : null,
1637 };
1638 },
1639};
1640
1641const generateTests: McpToolHandler = {
1642 tool: {
1643 name: "gluecron_generate_tests",
1644 description:
1645 "Generate tests for a PR via Claude. Wraps src/lib/ai-test-generator.generateTestsForPr. Mode 'follow-up-pr' opens a new PR; 'append-commit' commits onto the head branch. Requires 'repo' scope.",
523ddadccanty labs1646 annotations: { title: "Generate tests for PR", readOnlyHint: false, destructiveHint: false },
0feb720Claude1647 inputSchema: {
1648 type: "object",
1649 properties: {
1650 owner: { type: "string" },
1651 repo: { type: "string" },
1652 number: { type: "number", description: "PR number" },
1653 mode: { type: "string", description: "'follow-up-pr' or 'append-commit'" },
1654 },
1655 required: ["owner", "repo", "number"],
1656 },
1657 },
1658 async run(args, ctx) {
1659 const owner = mcpArgString(args, "owner");
1660 const repo = mcpArgString(args, "repo");
1661 const number = mcpArgNumber(args, "number");
1662 const mode = mcpArgString(args, "mode", "follow-up-pr");
1663 if (mode !== "follow-up-pr" && mode !== "append-commit") {
1664 throw new McpError(ERR_INVALID_PARAMS, "mode must be follow-up-pr|append-commit");
1665 }
1666 requireScope(ctx, "repo", "gluecron_generate_tests");
1667 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_generate_tests");
1668 const pr = await mcpLoadPrByNumber(gate.repoId, number);
1669 if (!pr) throw new McpError(ERR_METHOD_NOT_FOUND, "pr not found");
1670 const { generateTestsForPr } = await import("./ai-test-generator");
1671 const res = await generateTestsForPr({ prId: pr.id, mode });
1672 if (!res.ok) throw new McpError(ERR_INVALID_PARAMS, res.error || "test generation failed");
1673 return res;
1674 },
1675};
1676
1677const generateCommitMessageTool: McpToolHandler = {
1678 tool: {
1679 name: "gluecron_generate_commit_message",
1680 description:
1681 "Generate a commit message for a diff. Same engine as gluecron_generate_pr_description but explicit for the commit-message use case.",
523ddadccanty labs1682 annotations: { title: "Generate commit message", readOnlyHint: true, destructiveHint: false },
0feb720Claude1683 inputSchema: {
1684 type: "object",
1685 properties: {
1686 diff: { type: "string" },
1687 style: { type: "string", description: "'conventional' (default) or 'plain'" },
1688 },
1689 required: ["diff"],
1690 },
1691 },
1692 async run(args) {
1693 const diff = mcpArgString(args, "diff");
1694 const style = mcpArgString(args, "style", "conventional");
1695 const { generateCommitMessage } = await import("./ai-commit-message");
1696 return await generateCommitMessage(diff, {
1697 style: style === "plain" ? "plain" : "conventional",
1698 });
1699 },
1700};
1701
1702const generateReleaseNotes: McpToolHandler = {
1703 tool: {
1704 name: "gluecron_generate_release_notes",
1705 description:
1706 "Generate release notes between two tags. Wraps src/lib/ai-release-notes.generateReleaseNotes. Returns the rendered Markdown + section data.",
523ddadccanty labs1707 // generateReleaseNotes only computes + returns; persistence lives in the separate backfill task.
1708 annotations: { title: "Generate release notes", readOnlyHint: true, destructiveHint: false },
0feb720Claude1709 inputSchema: {
1710 type: "object",
1711 properties: {
1712 owner: { type: "string" },
1713 repo: { type: "string" },
1714 from_tag: { type: "string", description: "Previous tag (optional)" },
1715 to_tag: { type: "string", description: "New tag" },
1716 },
1717 required: ["owner", "repo", "to_tag"],
1718 },
1719 },
1720 async run(args, ctx) {
1721 const owner = mcpArgString(args, "owner");
1722 const repo = mcpArgString(args, "repo");
1723 const toTag = mcpArgString(args, "to_tag");
1724 const fromTag = mcpArgString(args, "from_tag", "");
1725 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1726 const { generateReleaseNotes: gen } = await import("./ai-release-notes");
1727 return await gen({
1728 repositoryId: info.repoId,
1729 fromTag: fromTag || null,
1730 toTag,
1731 });
1732 },
1733};
1734
1735const proposeMigration: McpToolHandler = {
1736 tool: {
1737 name: "gluecron_propose_migration",
1738 description:
1739 "Propose a dependency-upgrade PR. Wraps src/lib/migration-assistant.proposeMajorMigration. Requires 'repo' scope.",
523ddadccanty labs1740 annotations: { title: "Propose migration PR", readOnlyHint: false, destructiveHint: false },
0feb720Claude1741 inputSchema: {
1742 type: "object",
1743 properties: {
1744 owner: { type: "string" },
1745 repo: { type: "string" },
1746 dependency: { type: "string" },
1747 from_version: { type: "string" },
1748 to_version: { type: "string" },
1749 base_sha: { type: "string", description: "Commit sha to fork from" },
1750 changelog: { type: "string", description: "Optional changelog text" },
1751 },
1752 required: ["owner", "repo", "dependency", "from_version", "to_version", "base_sha"],
1753 },
1754 },
1755 async run(args, ctx) {
1756 const owner = mcpArgString(args, "owner");
1757 const repo = mcpArgString(args, "repo");
1758 const dependency = mcpArgString(args, "dependency");
1759 const fromVersion = mcpArgString(args, "from_version");
1760 const toVersion = mcpArgString(args, "to_version");
1761 const baseSha = mcpArgString(args, "base_sha");
1762 const changelog = mcpArgString(args, "changelog", "");
1763 requireScope(ctx, "repo", "gluecron_propose_migration");
1764 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_propose_migration");
1765 const { proposeMajorMigration } = await import("./migration-assistant");
1766 const res = await proposeMajorMigration({
1767 repositoryId: gate.repoId,
1768 dependency,
1769 fromVersion,
1770 toVersion,
1771 baseSha,
1772 changelog: changelog || null,
1773 });
1774 if (!res) {
1775 throw new McpError(
1776 ERR_INVALID_PARAMS,
1777 "migration proposal returned null (no manifest, throttle, or AI failure)"
1778 );
1779 }
1780 return res;
1781 },
1782};
1783
1784const proposeDocUpdate: McpToolHandler = {
1785 tool: {
1786 name: "gluecron_propose_doc_update",
1787 description:
1788 "Manual trigger for the AI doc-update flow: scans tracked sections on the default branch and opens a PR rewriting stale prose. Requires 'repo' scope.",
523ddadccanty labs1789 annotations: { title: "Propose doc update PR", readOnlyHint: false, destructiveHint: false },
0feb720Claude1790 inputSchema: {
1791 type: "object",
1792 properties: {
1793 owner: { type: "string" },
1794 repo: { type: "string" },
1795 },
1796 required: ["owner", "repo"],
1797 },
1798 },
1799 async run(args, ctx) {
1800 const owner = mcpArgString(args, "owner");
1801 const repo = mcpArgString(args, "repo");
1802 requireScope(ctx, "repo", "gluecron_propose_doc_update");
1803 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_propose_doc_update");
1804 const { runDocDriftCheckForRepo } = await import("./ai-doc-updater");
1805 const res = await runDocDriftCheckForRepo(gate.repoId);
1806 return res ?? { proposed: 0, note: "no drift detected or AI unavailable" };
1807 },
1808};
1809
1810// ---------------------------------------------------------------------------
1811// CI / DEPLOYS
1812// ---------------------------------------------------------------------------
1813
1814const triggerWorkflow: McpToolHandler = {
1815 tool: {
1816 name: "gluecron_trigger_workflow",
1817 description:
1818 "Dispatch a workflow_dispatch run. Mirrors POST /api/v2/repos/.../actions/workflows/:filename/dispatches. Requires 'repo' scope.",
523ddadccanty labs1819 annotations: { title: "Trigger workflow", readOnlyHint: false, destructiveHint: false },
0feb720Claude1820 inputSchema: {
1821 type: "object",
1822 properties: {
1823 owner: { type: "string" },
1824 repo: { type: "string" },
1825 filename: { type: "string", description: "Workflow filename (e.g. ci.yml)" },
1826 ref: { type: "string", description: "Branch / tag / sha (default: repo default branch)" },
1827 inputs: { type: "object", description: "Workflow inputs (object)" },
1828 },
1829 required: ["owner", "repo", "filename"],
1830 },
1831 },
1832 async run(args, ctx) {
1833 const owner = mcpArgString(args, "owner");
1834 const repo = mcpArgString(args, "repo");
1835 const filename = mcpArgString(args, "filename");
1836 requireScope(ctx, "repo", "gluecron_trigger_workflow");
1837 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_trigger_workflow");
1838
1839 // Match the api-v2 helper: stored workflow path is
1840 // `.gluecron/workflows/<filename>`; we match by trailing basename.
1841 const candidates = await db
1842 .select()
1843 .from(workflows)
1844 .where(eq(workflows.repositoryId, gate.repoId));
1845 const wfRow = candidates.find((row) => {
1846 const idx = row.path.lastIndexOf("/");
1847 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
1848 return base === filename;
1849 });
1850 if (!wfRow) throw new McpError(ERR_METHOD_NOT_FOUND, `workflow not found: ${filename}`);
1851
1852 let parsedObj: Record<string, unknown> = {};
1853 try {
1854 const v = JSON.parse(wfRow.parsed);
1855 if (v && typeof v === "object" && !Array.isArray(v)) {
1856 parsedObj = v as Record<string, unknown>;
1857 }
1858 } catch {
1859 /* */
1860 }
1861
1862 // Inline-copy of the dispatch-spec extractor in src/routes/api-v2.ts
1863 // (private to that module). Same shape, same semantics — keeping the
1864 // logic local avoids an export-shuffle in the api-v2 module.
1865 const rawOn = parsedObj.on as
1866 | string
1867 | string[]
1868 | Record<string, unknown>
1869 | null
1870 | undefined;
1871 type DispatchInputSpec = { required?: boolean; default?: unknown };
1872 let dispatchEnabled = false;
1873 let dispatchInputs: Record<string, DispatchInputSpec> | null = null;
1874 if (typeof rawOn === "string") {
1875 dispatchEnabled = rawOn === "workflow_dispatch";
1876 } else if (Array.isArray(rawOn)) {
1877 dispatchEnabled = rawOn.includes("workflow_dispatch");
1878 } else if (rawOn && typeof rawOn === "object") {
1879 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
1880 if (slot !== undefined) {
1881 dispatchEnabled = true;
1882 if (slot && typeof slot === "object") {
1883 const inputs = (slot as Record<string, unknown>).inputs;
1884 if (inputs && typeof inputs === "object" && !Array.isArray(inputs)) {
1885 dispatchInputs = inputs as Record<string, DispatchInputSpec>;
1886 }
1887 }
1888 }
1889 }
1890 if (!dispatchEnabled) {
1891 throw new McpError(ERR_INVALID_PARAMS, "workflow has no workflow_dispatch trigger");
1892 }
1893
1894 const providedInputs =
1895 args.inputs && typeof args.inputs === "object" && !Array.isArray(args.inputs)
1896 ? (args.inputs as Record<string, unknown>)
1897 : undefined;
1898 if (dispatchInputs) {
1899 const missing: string[] = [];
1900 for (const [n, spec] of Object.entries(dispatchInputs)) {
1901 if (!spec || typeof spec !== "object") continue;
1902 const typed = spec as DispatchInputSpec;
1903 const has =
1904 !!providedInputs &&
1905 Object.prototype.hasOwnProperty.call(providedInputs, n) &&
1906 providedInputs[n] !== undefined &&
1907 providedInputs[n] !== null;
1908 if (typed.required && !has && typed.default === undefined) {
1909 missing.push(n);
1910 }
1911 }
1912 if (missing.length) {
1913 throw new McpError(
1914 ERR_INVALID_PARAMS,
1915 `missing required workflow inputs: ${missing.join(",")}`
1916 );
1917 }
1918 }
1919
1920 const refIn = mcpArgString(args, "ref", gate.defaultBranch);
1921 const commitSha = await resolveRef(owner, repo, refIn);
1922 if (!commitSha) {
1923 throw new McpError(ERR_INVALID_PARAMS, `ref not found: ${refIn}`);
1924 }
1925 const { enqueueRun } = await import("./workflow-runner");
1926 const runId = await enqueueRun({
1927 workflowId: wfRow.id,
1928 repositoryId: gate.repoId,
1929 event: "workflow_dispatch",
1930 ref: refIn,
1931 commitSha,
1932 triggeredBy: gate.userId,
1933 });
1934 if (!runId) throw new McpError(ERR_INVALID_PARAMS, "enqueueRun failed");
1935 return { runId, workflowName: wfRow.name, ref: refIn, commitSha };
1936 },
1937};
1938
1939const getWorkflowRun: McpToolHandler = {
1940 tool: {
1941 name: "gluecron_get_workflow_run",
1942 description: "Fetch a workflow run's metadata + status. Mirrors GET /api/v2/repos/.../actions/runs/:id.",
523ddadccanty labs1943 annotations: { title: "Get workflow run", readOnlyHint: true, destructiveHint: false },
0feb720Claude1944 inputSchema: {
1945 type: "object",
1946 properties: {
1947 owner: { type: "string" },
1948 repo: { type: "string" },
1949 run_id: { type: "string" },
1950 },
1951 required: ["owner", "repo", "run_id"],
1952 },
1953 },
1954 async run(args, ctx) {
1955 const owner = mcpArgString(args, "owner");
1956 const repo = mcpArgString(args, "repo");
1957 const runId = mcpArgString(args, "run_id");
1958 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1959 const [run] = await db
1960 .select()
1961 .from(workflowRuns)
1962 .where(eq(workflowRuns.id, runId))
1963 .limit(1);
1964 if (!run || run.repositoryId !== info.repoId) {
1965 throw new McpError(ERR_METHOD_NOT_FOUND, "run not found");
1966 }
1967 return run;
1968 },
1969};
1970
1971const getWorkflowLogs: McpToolHandler = {
1972 tool: {
1973 name: "gluecron_get_workflow_logs",
1974 description:
1975 "Return concatenated per-job logs for a workflow run, plus per-job metadata. JSON-friendly companion to the ZIP-download endpoint.",
523ddadccanty labs1976 annotations: { title: "Get workflow logs", readOnlyHint: true, destructiveHint: false },
0feb720Claude1977 inputSchema: {
1978 type: "object",
1979 properties: {
1980 owner: { type: "string" },
1981 repo: { type: "string" },
1982 run_id: { type: "string" },
1983 },
1984 required: ["owner", "repo", "run_id"],
1985 },
1986 },
1987 async run(args, ctx) {
1988 const owner = mcpArgString(args, "owner");
1989 const repo = mcpArgString(args, "repo");
1990 const runId = mcpArgString(args, "run_id");
1991 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1992 const [run] = await db
1993 .select()
1994 .from(workflowRuns)
1995 .where(eq(workflowRuns.id, runId))
1996 .limit(1);
1997 if (!run || run.repositoryId !== info.repoId) {
1998 throw new McpError(ERR_METHOD_NOT_FOUND, "run not found");
1999 }
2000 const jobs = await db
2001 .select()
2002 .from(workflowJobs)
2003 .where(eq(workflowJobs.runId, run.id))
2004 .orderBy(asc(workflowJobs.jobOrder));
2005 return {
2006 runId: run.id,
2007 status: run.status,
2008 conclusion: run.conclusion,
2009 jobs: jobs.map((j) => ({
2010 id: j.id,
2011 name: j.name,
2012 status: j.status,
2013 conclusion: j.conclusion,
2014 logs: j.logs || "",
2015 })),
2016 };
2017 },
2018};
2019
2020const cancelWorkflowRun: McpToolHandler = {
2021 tool: {
2022 name: "gluecron_cancel_workflow_run",
2023 description: "Cancel a queued/running workflow run. Requires 'repo' scope.",
523ddadccanty labs2024 // Cancellation kills in-flight work that cannot be resumed — destructive.
2025 annotations: { title: "Cancel workflow run", readOnlyHint: false, destructiveHint: true },
0feb720Claude2026 inputSchema: {
2027 type: "object",
2028 properties: {
2029 owner: { type: "string" },
2030 repo: { type: "string" },
2031 run_id: { type: "string" },
2032 },
2033 required: ["owner", "repo", "run_id"],
2034 },
2035 },
2036 async run(args, ctx) {
2037 const owner = mcpArgString(args, "owner");
2038 const repo = mcpArgString(args, "repo");
2039 const runId = mcpArgString(args, "run_id");
2040 requireScope(ctx, "repo", "gluecron_cancel_workflow_run");
2041 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_cancel_workflow_run");
2042 const [run] = await db
2043 .select()
2044 .from(workflowRuns)
2045 .where(eq(workflowRuns.id, runId))
2046 .limit(1);
2047 if (!run || run.repositoryId !== gate.repoId) {
2048 throw new McpError(ERR_METHOD_NOT_FOUND, "run not found");
2049 }
2050 if (run.status !== "queued" && run.status !== "running") {
2051 return { cancelled: false, reason: `run is ${run.status}` };
2052 }
2053 const now = new Date();
2054 await db
2055 .update(workflowRuns)
2056 .set({ status: "cancelled", conclusion: "cancelled", finishedAt: now })
2057 .where(eq(workflowRuns.id, run.id));
2058 await db
2059 .update(workflowJobs)
2060 .set({ status: "cancelled", conclusion: "cancelled", finishedAt: now })
2061 .where(eq(workflowJobs.runId, run.id));
2062 return { cancelled: true };
2063 },
2064};
2065
2066const getPreviewUrl: McpToolHandler = {
2067 tool: {
2068 name: "gluecron_get_preview_url",
2069 description:
2070 "Return the branch-preview URL + status for a (repo, branch) pair. Wraps branch-previews.getPreviewForBranch.",
523ddadccanty labs2071 annotations: { title: "Get preview URL", readOnlyHint: true, destructiveHint: false },
0feb720Claude2072 inputSchema: {
2073 type: "object",
2074 properties: {
2075 owner: { type: "string" },
2076 repo: { type: "string" },
2077 branch: { type: "string" },
2078 },
2079 required: ["owner", "repo", "branch"],
2080 },
2081 },
2082 async run(args, ctx) {
2083 const owner = mcpArgString(args, "owner");
2084 const repo = mcpArgString(args, "repo");
2085 const branch = mcpArgString(args, "branch");
2086 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
2087 const { getPreviewForBranch, previewStatusLabel, formatExpiresIn, buildPreviewUrl } =
2088 await import("./branch-previews");
2089 const row = await getPreviewForBranch(info.repoId, branch);
2090 if (!row) {
2091 return {
2092 exists: false,
2093 url: buildPreviewUrl(owner, repo, branch),
2094 status: "missing",
2095 };
2096 }
2097 return {
2098 exists: true,
2099 url: row.previewUrl,
2100 status: row.status,
2101 statusLabel: previewStatusLabel(row.status),
2102 expiresAt: row.expiresAt,
2103 expiresIn: formatExpiresIn(row.expiresAt),
2104 };
2105 },
2106};
2107
2108const provisionPrSandbox: McpToolHandler = {
2109 tool: {
2110 name: "gluecron_provision_pr_sandbox",
2111 description:
2112 "Provision (or re-provision) a sandbox for a PR. Wraps pr-sandbox.provisionSandbox. Requires 'repo' scope.",
523ddadccanty labs2113 // Re-provision replaces an ephemeral sandbox only — not destructive.
2114 annotations: { title: "Provision PR sandbox", readOnlyHint: false, destructiveHint: false },
0feb720Claude2115 inputSchema: {
2116 type: "object",
2117 properties: {
2118 owner: { type: "string" },
2119 repo: { type: "string" },
2120 number: { type: "number" },
2121 },
2122 required: ["owner", "repo", "number"],
2123 },
2124 },
2125 async run(args, ctx) {
2126 const owner = mcpArgString(args, "owner");
2127 const repo = mcpArgString(args, "repo");
2128 const number = mcpArgNumber(args, "number");
2129 requireScope(ctx, "repo", "gluecron_provision_pr_sandbox");
2130 const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_provision_pr_sandbox");
2131 const pr = await mcpLoadPrByNumber(gate.repoId, number);
2132 if (!pr) throw new McpError(ERR_METHOD_NOT_FOUND, "pr not found");
2133 const { provisionSandbox } = await import("./pr-sandbox");
2134 const row = await provisionSandbox({ prId: pr.id });
2135 if (!row) throw new McpError(ERR_INVALID_PARAMS, "provision failed");
2136 return row;
2137 },
2138};
2139
2140// ---------------------------------------------------------------------------
2141// AGENTS
2142// ---------------------------------------------------------------------------
2143
2144const createAgentSession: McpToolHandler = {
2145 tool: {
2146 name: "gluecron_create_agent_session",
2147 description:
2148 "Mint a new agent-multiplayer session. Returns the plaintext `token` exactly once (store it). Requires 'admin' scope.",
523ddadccanty labs2149 annotations: { title: "Create agent session", readOnlyHint: false, destructiveHint: false },
0feb720Claude2150 inputSchema: {
2151 type: "object",
2152 properties: {
2153 name: { type: "string", description: "Human-readable session name (unique per owner)" },
2154 repository_id: { type: "string", description: "Optional repo to scope to" },
2155 branch_namespace: { type: "string", description: "Optional branch namespace override" },
2156 budget_cents_per_day: { type: "number", description: "Daily budget cap in cents (default 500)" },
2157 },
2158 required: ["name"],
2159 },
2160 },
2161 async run(args, ctx) {
2162 const userId = mcpRequireAuthedCtx(ctx, "gluecron_create_agent_session");
2163 requireScope(ctx, "admin", "gluecron_create_agent_session");
2164 const name = mcpArgString(args, "name");
2165 const { createAgentSession } = await import("./agent-multiplayer");
2166 const res = await createAgentSession({
2167 ownerUserId: userId,
2168 name,
2169 repositoryId:
2170 typeof args.repository_id === "string" ? args.repository_id : null,
2171 branchNamespace:
2172 typeof args.branch_namespace === "string" ? args.branch_namespace : undefined,
2173 budgetCentsPerDay:
2174 typeof args.budget_cents_per_day === "number"
2175 ? args.budget_cents_per_day
2176 : undefined,
2177 });
2178 if (!res) throw new McpError(ERR_INVALID_PARAMS, "session creation failed");
2179 return {
2180 session: { id: res.session.id, name: res.session.name },
2181 token: res.token,
2182 warning: "Token is shown exactly once — store it now.",
2183 };
2184 },
2185};
2186
2187const acquireLease: McpToolHandler = {
2188 tool: {
2189 name: "gluecron_acquire_lease",
2190 description:
2191 "Grab an exclusive lease on a target (e.g. a PR or branch) for an agent session. Returns null when another agent holds an active lease.",
523ddadccanty labs2192 annotations: { title: "Acquire lease", readOnlyHint: false, destructiveHint: false },
0feb720Claude2193 inputSchema: {
2194 type: "object",
2195 properties: {
2196 agent_session_id: { type: "string" },
2197 target_type: { type: "string", description: "e.g. 'pull_request', 'branch'" },
2198 target_id: { type: "string" },
2199 duration_ms: { type: "number", description: "Lease duration (default 5 minutes)" },
2200 },
2201 required: ["agent_session_id", "target_type", "target_id"],
2202 },
2203 },
2204 async run(args, ctx) {
2205 mcpRequireAuthedCtx(ctx, "gluecron_acquire_lease");
2206 requireScope(ctx, "repo", "gluecron_acquire_lease");
2207 const sessionId = mcpArgString(args, "agent_session_id");
2208 const targetType = mcpArgString(args, "target_type");
2209 const targetId = mcpArgString(args, "target_id");
2210 const durationMs =
2211 typeof args.duration_ms === "number" ? args.duration_ms : undefined;
2212 const { acquireLease } = await import("./agent-multiplayer");
2213 const lease = await acquireLease(sessionId, targetType, targetId, durationMs);
2214 return { lease };
2215 },
2216};
2217
2218const releaseLeaseTool: McpToolHandler = {
2219 tool: {
2220 name: "gluecron_release_lease",
2221 description: "Release a lease by id. Idempotent. Returns {released}.",
523ddadccanty labs2222 annotations: { title: "Release lease", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
0feb720Claude2223 inputSchema: {
2224 type: "object",
2225 properties: { lease_id: { type: "string" } },
2226 required: ["lease_id"],
2227 },
2228 },
2229 async run(args, ctx) {
2230 mcpRequireAuthedCtx(ctx, "gluecron_release_lease");
2231 requireScope(ctx, "repo", "gluecron_release_lease");
2232 const leaseId = mcpArgString(args, "lease_id");
2233 const { releaseLease } = await import("./agent-multiplayer");
2234 return { released: await releaseLease(leaseId) };
2235 },
2236};
2237
2238const getAgentBudget: McpToolHandler = {
2239 tool: {
2240 name: "gluecron_get_agent_budget",
2241 description: "Return spent / cap / remaining cents for an agent session.",
523ddadccanty labs2242 annotations: { title: "Get agent budget", readOnlyHint: true, destructiveHint: false },
0feb720Claude2243 inputSchema: {
2244 type: "object",
2245 properties: { agent_session_id: { type: "string" } },
2246 required: ["agent_session_id"],
2247 },
2248 },
2249 async run(args, ctx) {
2250 mcpRequireAuthedCtx(ctx, "gluecron_get_agent_budget");
2251 const sessionId = mcpArgString(args, "agent_session_id");
2252 const { getAgentUsage } = await import("./agent-multiplayer");
2253 return await getAgentUsage(sessionId);
2254 },
2255};
2256
2257// ---------------------------------------------------------------------------
2258// SEMANTIC
2259// ---------------------------------------------------------------------------
2260
2261const semanticSearch: McpToolHandler = {
2262 tool: {
2263 name: "gluecron_semantic_search",
2264 description:
2265 "Query the per-repo vector index (Voyage embeddings when configured, hash fallback otherwise). Wraps src/lib/semantic-search.searchRepository.",
523ddadccanty labs2266 annotations: { title: "Semantic search", readOnlyHint: true, destructiveHint: false },
0feb720Claude2267 inputSchema: {
2268 type: "object",
2269 properties: {
2270 owner: { type: "string" },
2271 repo: { type: "string" },
2272 query: { type: "string" },
2273 limit: { type: "number" },
2274 },
2275 required: ["owner", "repo", "query"],
2276 },
2277 },
2278 async run(args, ctx) {
2279 const owner = mcpArgString(args, "owner");
2280 const repo = mcpArgString(args, "repo");
2281 const query = mcpArgString(args, "query");
2282 const limit = mcpArgNumber(args, "limit", 20);
2283 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
2284 const { searchRepository } = await import("./semantic-search");
2285 const hits = await searchRepository({
2286 repositoryId: info.repoId,
2287 query,
2288 limit,
2289 });
2290 return { hits };
2291 },
2292};
2293
2294const findSymbol: McpToolHandler = {
2295 tool: {
2296 name: "gluecron_find_symbol",
2297 description:
2298 "Find definitions of a symbol by name within a repo. Wraps src/lib/symbols.findDefinitions.",
523ddadccanty labs2299 annotations: { title: "Find symbol", readOnlyHint: true, destructiveHint: false },
0feb720Claude2300 inputSchema: {
2301 type: "object",
2302 properties: {
2303 owner: { type: "string" },
2304 repo: { type: "string" },
2305 name: { type: "string", description: "Symbol name" },
2306 },
2307 required: ["owner", "repo", "name"],
2308 },
2309 },
2310 async run(args, ctx) {
2311 const owner = mcpArgString(args, "owner");
2312 const repo = mcpArgString(args, "repo");
2313 const name = mcpArgString(args, "name");
2314 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
2315 const { findDefinitions } = await import("./symbols");
2316 const defs = await findDefinitions(info.repoId, name);
2317 return { definitions: defs };
2318 },
2319};
2320
2321// ---------------------------------------------------------------------------
2322// INSIGHTS
2323// ---------------------------------------------------------------------------
2324
2325const prStatusSummary: McpToolHandler = {
2326 tool: {
2327 name: "gluecron_pr_status_summary",
2328 description:
2329 "Compute a one-shot status summary for a PR: state, risk score, AI-review verdicts (trio), gate signals. Read-only.",
523ddadccanty labs2330 annotations: { title: "PR status summary", readOnlyHint: true, destructiveHint: false },
0feb720Claude2331 inputSchema: {
2332 type: "object",
2333 properties: {
2334 owner: { type: "string" },
2335 repo: { type: "string" },
2336 number: { type: "number" },
2337 },
2338 required: ["owner", "repo", "number"],
2339 },
2340 },
2341 async run(args, ctx) {
2342 const owner = mcpArgString(args, "owner");
2343 const repo = mcpArgString(args, "repo");
2344 const number = mcpArgNumber(args, "number");
2345 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
2346 const pr = await mcpLoadPrByNumber(info.repoId, number);
2347 if (!pr) throw new McpError(ERR_METHOD_NOT_FOUND, "pr not found");
2348 const { getLatestCachedPrRisk } = await import("./pr-risk");
2349 const risk = await getLatestCachedPrRisk(pr.id);
2350 const aiComments = await db
2351 .select({ body: prComments.body, isAiReview: prComments.isAiReview })
2352 .from(prComments)
2353 .where(and(eq(prComments.pullRequestId, pr.id), eq(prComments.isAiReview, true)));
2354 const { TRIO_SUMMARY_MARKER } = await import("./ai-review-trio");
2355 const trioSummary = aiComments.find((c) => c.body.includes(TRIO_SUMMARY_MARKER));
2356 return {
2357 number: pr.number,
2358 title: pr.title,
2359 state: pr.state,
2360 isDraft: pr.isDraft,
2361 baseBranch: pr.baseBranch,
2362 headBranch: pr.headBranch,
2363 risk: risk
2364 ? { score: risk.score, band: risk.band, summary: risk.aiSummary }
2365 : null,
2366 aiReviewCount: aiComments.length,
2367 trioSummary: trioSummary ? trioSummary.body : null,
2368 url: mcpPrUrl(owner, repo, pr.number),
2369 };
2370 },
2371};
2372
2373const aiCostSummary: McpToolHandler = {
2374 tool: {
2375 name: "gluecron_ai_cost_summary",
2376 description:
2377 "Return AI spend rollups. Scope by one of: user_id (self), repo {owner,repo}, or agent_session_id. Defaults to caller's user spend.",
523ddadccanty labs2378 annotations: { title: "AI cost summary", readOnlyHint: true, destructiveHint: false },
0feb720Claude2379 inputSchema: {
2380 type: "object",
2381 properties: {
2382 scope: { type: "string", description: "'user' (default) | 'repo' | 'agent'" },
2383 owner: { type: "string" },
2384 repo: { type: "string" },
2385 agent_session_id: { type: "string" },
2386 },
2387 },
2388 },
2389 async run(args, ctx) {
2390 const scope = mcpArgString(args, "scope", "user");
2391 const userId = mcpRequireAuthedCtx(ctx, "gluecron_ai_cost_summary");
2392 const {
2393 summarizeCostsForUser,
2394 summarizeCostsForRepo,
2395 summarizeCostsForAgent,
2396 } = await import("./ai-cost-tracker");
2397 if (scope === "repo") {
2398 const owner = mcpArgString(args, "owner");
2399 const repo = mcpArgString(args, "repo");
2400 const info = await mcpResolveAccessibleRepo(owner, repo, userId);
2401 return await summarizeCostsForRepo(info.repoId);
2402 }
2403 if (scope === "agent") {
2404 const sessionId = mcpArgString(args, "agent_session_id");
2405 return await summarizeCostsForAgent(sessionId);
2406 }
2407 return await summarizeCostsForUser(userId);
2408 },
2409};
2410
2411// ---------------------------------------------------------------------------
2412// Registry
2413// ---------------------------------------------------------------------------
2414
2415/**
2416 * Every expanded tool keyed by name. Merged into `defaultTools()` in
2417 * `mcp-tools.ts` so the MCP HTTP route advertises all of them.
2418 */
2419export function expandedTools(): Record<string, McpToolHandler> {
2420 return {
2421 [forkRepo.tool.name]: forkRepo,
2422 [deleteRepo.tool.name]: deleteRepo,
2423 [updateRepo.tool.name]: updateRepo,
2424 [searchRepos.tool.name]: searchRepos,
2425 [cloneUrl.tool.name]: cloneUrl,
2426
2427 [labelIssue.tool.name]: labelIssue,
2428 [unlabelIssue.tool.name]: unlabelIssue,
2429 [assignIssue.tool.name]: assignIssue,
2430 [searchIssues.tool.name]: searchIssues,
2431
2432 [requestChanges.tool.name]: requestChanges,
2433 [searchPrs.tool.name]: searchPrs,
2434 [openDraftPr.tool.name]: openDraftPr,
2435 [generatePrDescription.tool.name]: generatePrDescription,
2436
2437 [readFile.tool.name]: readFile,
2438 [writeFile.tool.name]: writeFile,
2439 [deleteFile.tool.name]: deleteFile,
2440 [listTree.tool.name]: listTree,
2441 [getCommitTool.tool.name]: getCommitTool,
2442 [createBranch.tool.name]: createBranch,
2443 [atomicMultiFileCommit.tool.name]: atomicMultiFileCommit,
2444
2445 [shipSpec.tool.name]: shipSpec,
2446 [voiceToPr.tool.name]: voiceToPr,
2447 [refactorAcrossRepos.tool.name]: refactorAcrossRepos,
2448 [explainRepo.tool.name]: explainRepo,
2449 [chatWithRepo.tool.name]: chatWithRepo,
2450 [chatContinue.tool.name]: chatContinue,
2451 [generateTests.tool.name]: generateTests,
2452 [generateCommitMessageTool.tool.name]: generateCommitMessageTool,
2453 [generateReleaseNotes.tool.name]: generateReleaseNotes,
2454 [proposeMigration.tool.name]: proposeMigration,
2455 [proposeDocUpdate.tool.name]: proposeDocUpdate,
2456
2457 [triggerWorkflow.tool.name]: triggerWorkflow,
2458 [getWorkflowRun.tool.name]: getWorkflowRun,
2459 [getWorkflowLogs.tool.name]: getWorkflowLogs,
2460 [cancelWorkflowRun.tool.name]: cancelWorkflowRun,
2461 [getPreviewUrl.tool.name]: getPreviewUrl,
2462 [provisionPrSandbox.tool.name]: provisionPrSandbox,
2463
2464 [createAgentSession.tool.name]: createAgentSession,
2465 [acquireLease.tool.name]: acquireLease,
2466 [releaseLeaseTool.tool.name]: releaseLeaseTool,
2467 [getAgentBudget.tool.name]: getAgentBudget,
2468
2469 [semanticSearch.tool.name]: semanticSearch,
2470 [findSymbol.tool.name]: findSymbol,
2471
2472 [prStatusSummary.tool.name]: prStatusSummary,
2473 [aiCostSummary.tool.name]: aiCostSummary,
2474 };
2475}
2476
2477/** Test-only export of the per-tool handlers. */
2478export const __expandedTest = {
2479 forkRepo,
2480 deleteRepo,
2481 updateRepo,
2482 searchRepos,
2483 cloneUrl,
2484 labelIssue,
2485 unlabelIssue,
2486 assignIssue,
2487 searchIssues,
2488 requestChanges,
2489 searchPrs,
2490 openDraftPr,
2491 generatePrDescription,
2492 readFile,
2493 writeFile,
2494 deleteFile,
2495 listTree,
2496 getCommitTool,
2497 createBranch,
2498 atomicMultiFileCommit,
2499 shipSpec,
2500 voiceToPr,
2501 refactorAcrossRepos,
2502 explainRepo,
2503 chatWithRepo,
2504 chatContinue,
2505 generateTests,
2506 generateCommitMessageTool,
2507 generateReleaseNotes,
2508 proposeMigration,
2509 proposeDocUpdate,
2510 triggerWorkflow,
2511 getWorkflowRun,
2512 getWorkflowLogs,
2513 cancelWorkflowRun,
2514 getPreviewUrl,
2515 provisionPrSandbox,
2516 createAgentSession,
2517 acquireLease,
2518 releaseLeaseTool,
2519 getAgentBudget,
2520 semanticSearch,
2521 findSymbol,
2522 prStatusSummary,
2523 aiCostSummary,
2524 requireScope,
2525};