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.
| 9989d86 | 1 | /** |
| 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 | ||
| 14 | import { eq, and, desc, inArray } from "drizzle-orm"; | |
| 15 | import { db } from "../db"; | |
| 16 | import { | |
| 17 | repositories, | |
| 18 | users, | |
| 19 | issues, | |
| 20 | issueLabels, | |
| 21 | labels, | |
| 22 | pullRequests, | |
| 23 | prComments, | |
| 24 | gateRuns, | |
| 25 | } from "../db/schema"; | |
| 26 | import type { User } from "../db/schema"; | |
| 27 | import { | |
| 28 | getTree, | |
| 29 | getBlob, | |
| 30 | listCommits, | |
| 31 | searchCode, | |
| 32 | getRepoPath, | |
| 33 | } from "../git/repository"; | |
| 34 | import { loadRepoByPath } from "./namespace"; | |
| 35 | import { explainCodebase, getCachedExplanation } from "./ai-explain"; | |
| 36 | import { triggerAiReview } from "./ai-review"; | |
| 37 | ||
| 38 | // -------------------------------------------------------------------------- | |
| 39 | // Shared error helpers | |
| 40 | // -------------------------------------------------------------------------- | |
| 41 | ||
| 42 | export class McpToolError extends Error { | |
| 43 | constructor( | |
| 44 | public code: number, | |
| 45 | message: string | |
| 46 | ) { | |
| 47 | super(message); | |
| 48 | this.name = "McpToolError"; | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | function notFound(msg: string): never { | |
| 53 | throw new McpToolError(-32004, msg); | |
| 54 | } | |
| 55 | ||
| 56 | function unauthorized(msg = "Authentication required"): never { | |
| 57 | throw new McpToolError(-32001, msg); | |
| 58 | } | |
| 59 | ||
| 60 | function invalidParams(msg: string): never { | |
| 61 | throw new McpToolError(-32602, msg); | |
| 62 | } | |
| 63 | ||
| 64 | // -------------------------------------------------------------------------- | |
| 65 | // Helper: resolve owner username → user row | |
| 66 | // -------------------------------------------------------------------------- | |
| 67 | ||
| 68 | async 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 | ||
| 82 | async 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 | ||
| 103 | export 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 | ||
| 114 | const 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 | ||
| 179 | const 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 | ||
| 237 | const 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 | ||
| 289 | const 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 | ||
| 336 | const 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 | ||
| 375 | const 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 | ||
| 422 | const 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 | ||
| 481 | const 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 | ||
| 580 | const 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 | ||
| 668 | const 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 | ||
| 749 | const 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 | ||
| 804 | const 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 | ||
| 874 | const 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 | ||
| 939 | export 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 | ||
| 955 | export 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 | */ | |
| 962 | export function serializeTool(tool: McpTool) { | |
| 963 | return { | |
| 964 | name: tool.name, | |
| 965 | description: tool.description, | |
| 966 | inputSchema: tool.inputSchema, | |
| 967 | }; | |
| 968 | } |