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