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

mcp-tools.ts

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

mcp-tools.tsBlame968 lines · 1 contributor
9989d86Claude1/**
2 * MCP (Model Context Protocol) tool definitions and implementations.
3 *
4 * Each tool has:
5 * - name: unique identifier
6 * - description: human/model readable explanation (required by MCP spec)
7 * - inputSchema: JSON Schema for the arguments
8 * - handler: async function that receives (args, user) and returns a result
9 *
10 * The route (src/routes/mcp.ts) dispatches tools/call to handler() and
11 * wraps errors in JSON-RPC error objects.
12 */
13
14import { eq, and, desc, inArray } from "drizzle-orm";
15import { db } from "../db";
16import {
17 repositories,
18 users,
19 issues,
20 issueLabels,
21 labels,
22 pullRequests,
23 prComments,
24 gateRuns,
25} from "../db/schema";
26import type { User } from "../db/schema";
27import {
28 getTree,
29 getBlob,
30 listCommits,
31 searchCode,
32 getRepoPath,
33} from "../git/repository";
34import { loadRepoByPath } from "./namespace";
35import { explainCodebase, getCachedExplanation } from "./ai-explain";
36import { triggerAiReview } from "./ai-review";
37
38// --------------------------------------------------------------------------
39// Shared error helpers
40// --------------------------------------------------------------------------
41
42export class McpToolError extends Error {
43 constructor(
44 public code: number,
45 message: string
46 ) {
47 super(message);
48 this.name = "McpToolError";
49 }
50}
51
52function notFound(msg: string): never {
53 throw new McpToolError(-32004, msg);
54}
55
56function unauthorized(msg = "Authentication required"): never {
57 throw new McpToolError(-32001, msg);
58}
59
60function invalidParams(msg: string): never {
61 throw new McpToolError(-32602, msg);
62}
63
64// --------------------------------------------------------------------------
65// Helper: resolve owner username → user row
66// --------------------------------------------------------------------------
67
68async function resolveOwnerUser(username: string): Promise<User> {
69 const [u] = await db
70 .select()
71 .from(users)
72 .where(eq(users.username, username))
73 .limit(1);
74 if (!u) notFound(`User '${username}' not found`);
75 return u;
76}
77
78// --------------------------------------------------------------------------
79// Helper: load repo + enforce visibility
80// --------------------------------------------------------------------------
81
82async function loadAndCheckRepo(
83 owner: string,
84 repoName: string,
85 caller: User | null
86) {
87 const repo = await loadRepoByPath(owner, repoName);
88 if (!repo) notFound(`Repository '${owner}/${repoName}' not found`);
89 if (repo.isPrivate) {
90 if (!caller) unauthorized(`Repository '${owner}/${repoName}' is private`);
91 // caller must be the owner (org-owned repos not checked here for brevity)
92 if (repo.ownerId !== caller.id) {
93 unauthorized(`Access denied to private repository '${owner}/${repoName}'`);
94 }
95 }
96 return repo;
97}
98
99// --------------------------------------------------------------------------
100// Tool type
101// --------------------------------------------------------------------------
102
103export interface McpTool {
104 name: string;
105 description: string;
106 inputSchema: Record<string, unknown>;
107 handler: (args: Record<string, unknown>, user: User | null) => Promise<unknown>;
108}
109
110// --------------------------------------------------------------------------
111// Tool: list_repositories
112// --------------------------------------------------------------------------
113
114const listRepositoriesTool: McpTool = {
115 name: "list_repositories",
116 description:
117 "List repositories owned by a Gluecron user. If username is omitted the authenticated user's repos are returned.",
118 inputSchema: {
119 type: "object",
120 properties: {
121 username: {
122 type: "string",
123 description: "Username whose repositories to list. Defaults to the authenticated user.",
124 },
125 },
126 },
127 async handler(args, user) {
128 const usernameArg = args.username as string | undefined;
129 let targetUser: User;
130 if (usernameArg) {
131 targetUser = await resolveOwnerUser(usernameArg);
132 } else {
133 if (!user) unauthorized("Provide a username or authenticate");
134 targetUser = user;
135 }
136
137 const rows = await db
138 .select({
139 id: repositories.id,
140 name: repositories.name,
141 description: repositories.description,
142 isPrivate: repositories.isPrivate,
143 defaultBranch: repositories.defaultBranch,
144 stars: repositories.starCount,
145 createdAt: repositories.createdAt,
146 updatedAt: repositories.updatedAt,
147 pushedAt: repositories.pushedAt,
148 })
149 .from(repositories)
150 .where(
151 and(
152 eq(repositories.ownerId, targetUser.id),
153 // Only show private repos to the owner themselves
154 user?.id === targetUser.id
155 ? undefined
156 : eq(repositories.isPrivate, false)
157 )
158 )
159 .orderBy(desc(repositories.updatedAt));
160
161 return rows.map((r) => ({
162 owner: targetUser.username,
163 name: r.name,
164 description: r.description ?? null,
165 isPrivate: r.isPrivate,
166 defaultBranch: r.defaultBranch,
167 stars: r.stars,
168 createdAt: r.createdAt,
169 updatedAt: r.updatedAt,
170 pushedAt: r.pushedAt ?? null,
171 }));
172 },
173};
174
175// --------------------------------------------------------------------------
176// Tool: get_repository
177// --------------------------------------------------------------------------
178
179const getRepositoryTool: McpTool = {
180 name: "get_repository",
181 description:
182 "Get full details for a repository including description, default branch, star/fork/issue counts, and recent activity.",
183 inputSchema: {
184 type: "object",
185 properties: {
186 owner: { type: "string", description: "Repository owner username" },
187 name: { type: "string", description: "Repository name" },
188 },
189 required: ["owner", "name"],
190 },
191 async handler(args, user) {
192 const owner = args.owner as string;
193 const repoName = args.name as string;
194 if (!owner || !repoName) invalidParams("owner and name are required");
195
196 const repo = await loadAndCheckRepo(owner, repoName, user);
197
198 // Recent activity
199 const activity = await db
200 .select()
201 .from(
202 (await import("../db/schema")).activityFeed
203 )
204 .where(eq((await import("../db/schema")).activityFeed.repositoryId, repo.id))
205 .orderBy(desc((await import("../db/schema")).activityFeed.createdAt))
206 .limit(10);
207
208 return {
209 id: repo.id,
210 owner,
211 name: repo.name,
212 description: repo.description ?? null,
213 isPrivate: repo.isPrivate,
214 isArchived: repo.isArchived,
215 isTemplate: repo.isTemplate,
216 defaultBranch: repo.defaultBranch,
217 stars: repo.starCount,
218 forks: repo.forkCount,
219 openIssues: repo.issueCount,
220 createdAt: repo.createdAt,
221 updatedAt: repo.updatedAt,
222 pushedAt: repo.pushedAt ?? null,
223 recentActivity: activity.map((a) => ({
224 action: a.action,
225 targetType: a.targetType ?? null,
226 targetId: a.targetId ?? null,
227 createdAt: a.createdAt,
228 })),
229 };
230 },
231};
232
233// --------------------------------------------------------------------------
234// Tool: get_file_contents
235// --------------------------------------------------------------------------
236
237const getFileContentsTool: McpTool = {
238 name: "get_file_contents",
239 description: "Get the contents of a file in a repository at a given ref (branch, tag, or commit SHA).",
240 inputSchema: {
241 type: "object",
242 properties: {
243 owner: { type: "string", description: "Repository owner username" },
244 repo: { type: "string", description: "Repository name" },
245 path: { type: "string", description: "File path within the repository" },
246 ref: {
247 type: "string",
248 description: "Branch, tag, or commit SHA. Defaults to the repository default branch.",
249 },
250 },
251 required: ["owner", "repo", "path"],
252 },
253 async handler(args, user) {
254 const owner = args.owner as string;
255 const repoName = args.repo as string;
256 const filePath = args.path as string;
257 if (!owner || !repoName || !filePath) invalidParams("owner, repo, and path are required");
258
259 const repo = await loadAndCheckRepo(owner, repoName, user);
260 const ref = (args.ref as string | undefined) || repo.defaultBranch;
261
262 const blob = await getBlob(owner, repoName, ref, filePath);
263 if (!blob) notFound(`File '${filePath}' not found at ref '${ref}'`);
264
265 if (blob.isBinary) {
266 return {
267 content: "",
268 encoding: "binary",
269 size: blob.size,
270 path: filePath,
271 isBinary: true,
272 };
273 }
274
275 return {
276 content: blob.content,
277 encoding: "utf8",
278 size: blob.size,
279 path: filePath,
280 isBinary: false,
281 };
282 },
283};
284
285// --------------------------------------------------------------------------
286// Tool: list_files
287// --------------------------------------------------------------------------
288
289const listFilesTool: McpTool = {
290 name: "list_files",
291 description: "List files and directories in a repository at a given path and ref.",
292 inputSchema: {
293 type: "object",
294 properties: {
295 owner: { type: "string", description: "Repository owner username" },
296 repo: { type: "string", description: "Repository name" },
297 path: {
298 type: "string",
299 description: "Directory path to list. Defaults to the root directory.",
300 },
301 ref: {
302 type: "string",
303 description: "Branch, tag, or commit SHA. Defaults to the repository default branch.",
304 },
305 },
306 required: ["owner", "repo"],
307 },
308 async handler(args, user) {
309 const owner = args.owner as string;
310 const repoName = args.repo as string;
311 if (!owner || !repoName) invalidParams("owner and repo are required");
312
313 const repo = await loadAndCheckRepo(owner, repoName, user);
314 const ref = (args.ref as string | undefined) || repo.defaultBranch;
315 const treePath = (args.path as string | undefined) || "";
316
317 const entries = await getTree(owner, repoName, ref, treePath);
318
319 return entries.map((e) => {
320 const entryPath = treePath ? `${treePath}/${e.name}` : e.name;
321 return {
322 name: e.name,
323 path: entryPath,
324 type: e.type === "tree" ? "dir" : "file",
325 size: e.type === "blob" ? (e.size ?? null) : null,
326 sha: e.sha,
327 };
328 });
329 },
330};
331
332// --------------------------------------------------------------------------
333// Tool: search_code
334// --------------------------------------------------------------------------
335
336const searchCodeTool: McpTool = {
337 name: "search_code",
338 description: "Search for a string or pattern in the source code of a repository.",
339 inputSchema: {
340 type: "object",
341 properties: {
342 owner: { type: "string", description: "Repository owner username" },
343 repo: { type: "string", description: "Repository name" },
344 query: { type: "string", description: "Search query string (passed to git grep)" },
345 ref: {
346 type: "string",
347 description: "Branch, tag, or commit SHA. Defaults to the repository default branch.",
348 },
349 },
350 required: ["owner", "repo", "query"],
351 },
352 async handler(args, user) {
353 const owner = args.owner as string;
354 const repoName = args.repo as string;
355 const query = args.query as string;
356 if (!owner || !repoName || !query) invalidParams("owner, repo, and query are required");
357
358 const repo = await loadAndCheckRepo(owner, repoName, user);
359 const ref = (args.ref as string | undefined) || repo.defaultBranch;
360
361 const matches = await searchCode(owner, repoName, ref, query);
362
363 return matches.map((m) => ({
364 file: m.file,
365 line: m.lineNum,
366 content: m.line,
367 }));
368 },
369};
370
371// --------------------------------------------------------------------------
372// Tool: list_commits
373// --------------------------------------------------------------------------
374
375const listCommitsTool: McpTool = {
376 name: "list_commits",
377 description: "List recent commits on a branch of a repository.",
378 inputSchema: {
379 type: "object",
380 properties: {
381 owner: { type: "string", description: "Repository owner username" },
382 repo: { type: "string", description: "Repository name" },
383 branch: {
384 type: "string",
385 description: "Branch name. Defaults to the repository default branch.",
386 },
387 limit: {
388 type: "number",
389 description: "Maximum number of commits to return (1–100). Defaults to 20.",
390 minimum: 1,
391 maximum: 100,
392 },
393 },
394 required: ["owner", "repo"],
395 },
396 async handler(args, user) {
397 const owner = args.owner as string;
398 const repoName = args.repo as string;
399 if (!owner || !repoName) invalidParams("owner and repo are required");
400
401 const repo = await loadAndCheckRepo(owner, repoName, user);
402 const branch = (args.branch as string | undefined) || repo.defaultBranch;
403 const limit = Math.min(100, Math.max(1, (args.limit as number | undefined) ?? 20));
404
405 const commits = await listCommits(owner, repoName, branch, limit);
406
407 return commits.map((c) => ({
408 sha: c.sha,
409 message: c.message,
410 author: c.author,
411 authorEmail: c.authorEmail,
412 date: c.date,
413 parentShas: c.parentShas,
414 }));
415 },
416};
417
418// --------------------------------------------------------------------------
419// Tool: get_gate_status
420// --------------------------------------------------------------------------
421
422const getGateStatusTool: McpTool = {
423 name: "get_gate_status",
424 description:
425 "Get the current gate status for a repository — the latest result for each configured gate (GateTest, AI Review, Secret Scan, etc.).",
426 inputSchema: {
427 type: "object",
428 properties: {
429 owner: { type: "string", description: "Repository owner username" },
430 repo: { type: "string", description: "Repository name" },
431 },
432 required: ["owner", "repo"],
433 },
434 async handler(args, user) {
435 const owner = args.owner as string;
436 const repoName = args.repo as string;
437 if (!owner || !repoName) invalidParams("owner and repo are required");
438
439 const repo = await loadAndCheckRepo(owner, repoName, user);
440
441 // Get all gate runs ordered newest-first and deduplicate by gate name
442 const runs = await db
443 .select()
444 .from(gateRuns)
445 .where(eq(gateRuns.repositoryId, repo.id))
446 .orderBy(desc(gateRuns.createdAt))
447 .limit(200);
448
449 // Latest run per gate name
450 const latestByGate = new Map<string, typeof runs[0]>();
451 for (const run of runs) {
452 if (!latestByGate.has(run.gateName)) {
453 latestByGate.set(run.gateName, run);
454 }
455 }
456
457 const gates = Array.from(latestByGate.values()).map((r) => ({
458 name: r.gateName,
459 status: r.status,
460 summary: r.summary ?? null,
461 commitSha: r.commitSha,
462 updatedAt: r.completedAt ?? r.createdAt,
463 }));
464
465 // Derive overall status
466 let overall: "green" | "red" | "pending" = "green";
467 for (const g of gates) {
468 if (g.status === "failed") { overall = "red"; break; }
469 if (g.status === "pending" || g.status === "running") overall = "pending";
470 }
471 if (gates.length === 0) overall = "pending";
472
473 return { overall, gates };
474 },
475};
476
477// --------------------------------------------------------------------------
478// Tool: list_pull_requests
479// --------------------------------------------------------------------------
480
481const listPullRequestsTool: McpTool = {
482 name: "list_pull_requests",
483 description: "List pull requests for a repository, optionally filtered by state.",
484 inputSchema: {
485 type: "object",
486 properties: {
487 owner: { type: "string", description: "Repository owner username" },
488 repo: { type: "string", description: "Repository name" },
489 state: {
490 type: "string",
491 enum: ["open", "closed", "merged"],
492 description: "Filter by state. Defaults to 'open'.",
493 },
494 },
495 required: ["owner", "repo"],
496 },
497 async handler(args, user) {
498 const owner = args.owner as string;
499 const repoName = args.repo as string;
500 if (!owner || !repoName) invalidParams("owner and repo are required");
501
502 const repo = await loadAndCheckRepo(owner, repoName, user);
503 const state = (args.state as string | undefined) || "open";
504
505 const prs = await db
506 .select({
507 id: pullRequests.id,
508 number: pullRequests.number,
509 title: pullRequests.title,
510 state: pullRequests.state,
511 authorId: pullRequests.authorId,
512 baseBranch: pullRequests.baseBranch,
513 headBranch: pullRequests.headBranch,
514 createdAt: pullRequests.createdAt,
515 mergedAt: pullRequests.mergedAt,
516 })
517 .from(pullRequests)
518 .where(
519 and(
520 eq(pullRequests.repositoryId, repo.id),
521 eq(pullRequests.state, state)
522 )
523 )
524 .orderBy(desc(pullRequests.createdAt))
525 .limit(50);
526
527 if (prs.length === 0) return [];
528
529 // Collect author user data
530 const authorIds = [...new Set(prs.map((p) => p.authorId))];
531 const authorRows = await db
532 .select({ id: users.id, username: users.username })
533 .from(users)
534 .where(inArray(users.id, authorIds));
535 const authorMap = new Map(authorRows.map((u) => [u.id, u.username]));
536
537 // Latest AI review comment per PR
538 const prIds = prs.map((p) => p.id);
539 const aiComments = await db
540 .select({
541 pullRequestId: prComments.pullRequestId,
542 body: prComments.body,
543 createdAt: prComments.createdAt,
544 })
545 .from(prComments)
546 .where(
547 and(
548 inArray(prComments.pullRequestId, prIds),
549 eq(prComments.isAiReview, true)
550 )
551 )
552 .orderBy(desc(prComments.createdAt));
553
554 // Latest AI comment per PR
555 const aiReviewMap = new Map<string, string>();
556 for (const c of aiComments) {
557 if (!aiReviewMap.has(c.pullRequestId)) {
558 aiReviewMap.set(c.pullRequestId, c.body);
559 }
560 }
561
562 return prs.map((p) => ({
563 number: p.number,
564 title: p.title,
565 state: p.state,
566 author: authorMap.get(p.authorId) ?? null,
567 baseBranch: p.baseBranch,
568 headBranch: p.headBranch,
569 createdAt: p.createdAt,
570 mergedAt: p.mergedAt ?? null,
571 aiReviewSummary: aiReviewMap.get(p.id) ?? null,
572 }));
573 },
574};
575
576// --------------------------------------------------------------------------
577// Tool: list_issues
578// --------------------------------------------------------------------------
579
580const listIssuesTool: McpTool = {
581 name: "list_issues",
582 description: "List issues for a repository, optionally filtered by state.",
583 inputSchema: {
584 type: "object",
585 properties: {
586 owner: { type: "string", description: "Repository owner username" },
587 repo: { type: "string", description: "Repository name" },
588 state: {
589 type: "string",
590 enum: ["open", "closed"],
591 description: "Filter by state. Defaults to 'open'.",
592 },
593 },
594 required: ["owner", "repo"],
595 },
596 async handler(args, user) {
597 const owner = args.owner as string;
598 const repoName = args.repo as string;
599 if (!owner || !repoName) invalidParams("owner and repo are required");
600
601 const repo = await loadAndCheckRepo(owner, repoName, user);
602 const state = (args.state as string | undefined) || "open";
603
604 const issueRows = await db
605 .select({
606 id: issues.id,
607 number: issues.number,
608 title: issues.title,
609 state: issues.state,
610 authorId: issues.authorId,
611 createdAt: issues.createdAt,
612 })
613 .from(issues)
614 .where(
615 and(
616 eq(issues.repositoryId, repo.id),
617 eq(issues.state, state)
618 )
619 )
620 .orderBy(desc(issues.createdAt))
621 .limit(50);
622
623 if (issueRows.length === 0) return [];
624
625 // Collect author usernames
626 const authorIds = [...new Set(issueRows.map((i) => i.authorId))];
627 const authorRows = await db
628 .select({ id: users.id, username: users.username })
629 .from(users)
630 .where(inArray(users.id, authorIds));
631 const authorMap = new Map(authorRows.map((u) => [u.id, u.username]));
632
633 // Load labels for all issues in one join
634 const issueIds = issueRows.map((i) => i.id);
635 const labelJoinRows = await db
636 .select({
637 issueId: issueLabels.issueId,
638 labelName: labels.name,
639 labelColor: labels.color,
640 })
641 .from(issueLabels)
642 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
643 .where(inArray(issueLabels.issueId, issueIds));
644
645 // Group labels by issue
646 const labelsMap = new Map<string, Array<{ name: string; color: string }>>();
647 for (const row of labelJoinRows) {
648 const existing = labelsMap.get(row.issueId) ?? [];
649 existing.push({ name: row.labelName, color: row.labelColor });
650 labelsMap.set(row.issueId, existing);
651 }
652
653 return issueRows.map((i) => ({
654 number: i.number,
655 title: i.title,
656 state: i.state,
657 author: authorMap.get(i.authorId) ?? null,
658 labels: labelsMap.get(i.id) ?? [],
659 createdAt: i.createdAt,
660 }));
661 },
662};
663
664// --------------------------------------------------------------------------
665// Tool: create_issue
666// --------------------------------------------------------------------------
667
668const createIssueTool: McpTool = {
669 name: "create_issue",
670 description: "Create a new issue in a repository. Requires authentication.",
671 inputSchema: {
672 type: "object",
673 properties: {
674 owner: { type: "string", description: "Repository owner username" },
675 repo: { type: "string", description: "Repository name" },
676 title: { type: "string", description: "Issue title" },
677 body: { type: "string", description: "Issue body (Markdown)" },
678 labels: {
679 type: "array",
680 items: { type: "string" },
681 description: "Label names to apply to the issue",
682 },
683 },
684 required: ["owner", "repo", "title"],
685 },
686 async handler(args, user) {
687 if (!user) unauthorized("Creating issues requires authentication");
688
689 const owner = args.owner as string;
690 const repoName = args.repo as string;
691 const title = args.title as string;
692 if (!owner || !repoName || !title) invalidParams("owner, repo, and title are required");
693 if (title.trim().length === 0) invalidParams("title cannot be empty");
694
695 const repo = await loadAndCheckRepo(owner, repoName, user);
696 const body = (args.body as string | undefined) ?? null;
697 const labelNames = (args.labels as string[] | undefined) ?? [];
698
699 // Insert issue
700 const [newIssue] = await db
701 .insert(issues)
702 .values({
703 repositoryId: repo.id,
704 authorId: user.id,
705 title: title.trim(),
706 body,
707 state: "open",
708 })
709 .returning({ id: issues.id, number: issues.number });
710
711 if (!newIssue) throw new McpToolError(-32603, "Failed to create issue");
712
713 // Apply labels if any
714 if (labelNames.length > 0) {
715 const labelRows = await db
716 .select({ id: labels.id, name: labels.name })
717 .from(labels)
718 .where(
719 and(
720 eq(labels.repositoryId, repo.id),
721 inArray(labels.name, labelNames)
722 )
723 );
724
725 if (labelRows.length > 0) {
726 await db.insert(issueLabels).values(
727 labelRows.map((l) => ({ issueId: newIssue.id, labelId: l.id }))
728 );
729 }
730 }
731
732 // Bump issue count on repo (best-effort)
733 db.update(repositories)
734 .set({ issueCount: repo.issueCount + 1 })
735 .where(eq(repositories.id, repo.id))
736 .catch(() => {});
737
738 return {
739 number: newIssue.number,
740 url: `/${owner}/${repoName}/issues/${newIssue.number}`,
741 };
742 },
743};
744
745// --------------------------------------------------------------------------
746// Tool: explain_codebase
747// --------------------------------------------------------------------------
748
749const explainCodebaseTool: McpTool = {
750 name: "explain_codebase",
751 description:
752 "Generate (or return a cached) AI-powered explanation of what a repository does, its architecture, and key files. Results are cached per commit SHA.",
753 inputSchema: {
754 type: "object",
755 properties: {
756 owner: { type: "string", description: "Repository owner username" },
757 repo: { type: "string", description: "Repository name" },
758 },
759 required: ["owner", "repo"],
760 },
761 async handler(args, user) {
762 const owner = args.owner as string;
763 const repoName = args.repo as string;
764 if (!owner || !repoName) invalidParams("owner and repo are required");
765
766 const repo = await loadAndCheckRepo(owner, repoName, user);
767
768 // Get latest commit SHA on default branch
769 const commits = await listCommits(owner, repoName, repo.defaultBranch, 1);
770 const commitSha = commits[0]?.sha ?? "HEAD";
771
772 // Check cache first
773 const cached = await getCachedExplanation(repo.id, commitSha);
774 if (cached) {
775 return {
776 explanation: cached.markdown,
777 summary: cached.summary,
778 generatedAt: new Date().toISOString(),
779 cached: true,
780 };
781 }
782
783 // Generate fresh
784 const result = await explainCodebase({
785 owner,
786 repo: repoName,
787 repositoryId: repo.id,
788 commitSha,
789 });
790
791 return {
792 explanation: result.markdown,
793 summary: result.summary,
794 generatedAt: new Date().toISOString(),
795 cached: result.cached,
796 };
797 },
798};
799
800// --------------------------------------------------------------------------
801// Tool: get_branch_diff
802// --------------------------------------------------------------------------
803
804const getBranchDiffTool: McpTool = {
805 name: "get_branch_diff",
806 description:
807 "Get the diff between two branches or refs, including per-file addition/deletion stats and the raw diff (truncated to 50 KB).",
808 inputSchema: {
809 type: "object",
810 properties: {
811 owner: { type: "string", description: "Repository owner username" },
812 repo: { type: "string", description: "Repository name" },
813 base: { type: "string", description: "Base branch or ref" },
814 head: { type: "string", description: "Head branch or ref" },
815 },
816 required: ["owner", "repo", "base", "head"],
817 },
818 async handler(args, user) {
819 const owner = args.owner as string;
820 const repoName = args.repo as string;
821 const base = args.base as string;
822 const head = args.head as string;
823 if (!owner || !repoName || !base || !head) {
824 invalidParams("owner, repo, base, and head are required");
825 }
826
827 const repo = await loadAndCheckRepo(owner, repoName, user);
828 const repoDir = getRepoPath(owner, repoName);
829
830 // Raw diff
831 const diffProc = Bun.spawn(["git", "diff", `${base}...${head}`], {
832 cwd: repoDir,
833 stdout: "pipe",
834 stderr: "pipe",
835 });
836 const rawDiffFull = await new Response(diffProc.stdout).text();
837 await diffProc.exited;
838
839 // Numstat for per-file summary
840 const statProc = Bun.spawn(
841 ["git", "diff", "--numstat", `${base}...${head}`],
842 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
843 );
844 const numstatRaw = await new Response(statProc.stdout).text();
845 await statProc.exited;
846
847 const files = numstatRaw
848 .trim()
849 .split("\n")
850 .filter(Boolean)
851 .map((line) => {
852 const [add, del, filePath] = line.split("\t");
853 return {
854 path: filePath ?? "",
855 additions: add === "-" ? 0 : parseInt(add, 10) || 0,
856 deletions: del === "-" ? 0 : parseInt(del, 10) || 0,
857 };
858 });
859
860 const MAX_DIFF_BYTES = 50 * 1024; // 50 KB
861 const rawDiff =
862 rawDiffFull.length > MAX_DIFF_BYTES
863 ? rawDiffFull.slice(0, MAX_DIFF_BYTES) + "\n... (diff truncated at 50 KB)"
864 : rawDiffFull;
865
866 return { files, rawDiff };
867 },
868};
869
870// --------------------------------------------------------------------------
871// Tool: trigger_ai_review
872// --------------------------------------------------------------------------
873
874const triggerAiReviewTool: McpTool = {
875 name: "trigger_ai_review",
876 description:
877 "Trigger an AI code review for a pull request. The caller must be authenticated and must be the repository owner or the PR author.",
878 inputSchema: {
879 type: "object",
880 properties: {
881 owner: { type: "string", description: "Repository owner username" },
882 repo: { type: "string", description: "Repository name" },
883 prNumber: { type: "number", description: "Pull request number" },
884 },
885 required: ["owner", "repo", "prNumber"],
886 },
887 async handler(args, user) {
888 if (!user) unauthorized("Triggering AI review requires authentication");
889
890 const owner = args.owner as string;
891 const repoName = args.repo as string;
892 const prNumber = args.prNumber as number;
893 if (!owner || !repoName || !prNumber) {
894 invalidParams("owner, repo, and prNumber are required");
895 }
896
897 const repo = await loadAndCheckRepo(owner, repoName, user);
898
899 // Load PR
900 const [pr] = await db
901 .select()
902 .from(pullRequests)
903 .where(
904 and(
905 eq(pullRequests.repositoryId, repo.id),
906 eq(pullRequests.number, prNumber)
907 )
908 )
909 .limit(1);
910
911 if (!pr) notFound(`Pull request #${prNumber} not found`);
912
913 // Authorization: repo owner or PR author
914 if (user.id !== repo.ownerId && user.id !== pr.authorId) {
915 unauthorized("Only the repository owner or PR author can trigger an AI review");
916 }
917
918 // Fire-and-forget
919 triggerAiReview(
920 owner,
921 repoName,
922 pr.id,
923 pr.title,
924 pr.body ?? "",
925 pr.baseBranch,
926 pr.headBranch
927 ).catch((err) => {
928 console.error("[mcp] triggerAiReview error:", err);
929 });
930
931 return { queued: true, prId: pr.id };
932 },
933};
934
935// --------------------------------------------------------------------------
936// Tool registry
937// --------------------------------------------------------------------------
938
939export const MCP_TOOLS: McpTool[] = [
940 listRepositoriesTool,
941 getRepositoryTool,
942 getFileContentsTool,
943 listFilesTool,
944 searchCodeTool,
945 listCommitsTool,
946 getGateStatusTool,
947 listPullRequestsTool,
948 listIssuesTool,
949 createIssueTool,
950 explainCodebaseTool,
951 getBranchDiffTool,
952 triggerAiReviewTool,
953];
954
955export const MCP_TOOL_MAP = new Map<string, McpTool>(
956 MCP_TOOLS.map((t) => [t.name, t])
957);
958
959/**
960 * Serialise a tool for the tools/list response.
961 */
962export function serializeTool(tool: McpTool) {
963 return {
964 name: tool.name,
965 description: tool.description,
966 inputSchema: tool.inputSchema,
967 };
968}