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.
| 2c2163e | 1 | /** |
| 2 | * MCP tool handlers — read-only v1 set. | |
| 3 | * | |
| 4 | * Each handler returns either a string (auto-wrapped to text content) | |
| 5 | * or the full MCP `{content: [...]}` shape. Errors throw `McpError` from | |
| 6 | * `mcp.ts` so the router can surface them as JSON-RPC -32xxx codes. | |
| 7 | * | |
| 8 | * Tool surface (v1, all read-only): | |
| 9 | * - gluecron_repo_search — search public repos by keyword | |
| 10 | * - gluecron_repo_read_file — read a file from a repo at a ref | |
| 11 | * - gluecron_repo_list_issues — list open issues for a repo | |
| 12 | * - gluecron_repo_explain_codebase — return cached AI explanation | |
| 13 | * | |
| 14 | * v2 will add write tools (create_issue, post_comment, run_workflow) | |
| 15 | * gated on `userId` + write-access on the target repo. | |
| 16 | */ | |
| 17 | ||
| 18 | import { and, asc, desc, eq, like, or } from "drizzle-orm"; | |
| 19 | import { db } from "../db"; | |
| 20 | import { | |
| 21 | issues, | |
| 22 | repositories, | |
| 23 | users, | |
| 24 | codebaseExplanations, | |
| 25 | } from "../db/schema"; | |
| f5a18f9 | 26 | import { getBlob, repoExists } from "../git/repository"; |
| 27 | import { computeHealthScore } from "./intelligence"; | |
| 2c2163e | 28 | import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp"; |
| 29 | import type { McpContext } from "./mcp"; | |
| 30 | ||
| 31 | export type McpTool = { | |
| 32 | name: string; | |
| 33 | description: string; | |
| 34 | inputSchema: { | |
| 35 | type: "object"; | |
| 36 | properties: Record<string, { type: string; description?: string }>; | |
| 37 | required?: string[]; | |
| 38 | }; | |
| 39 | }; | |
| 40 | ||
| 41 | export type McpToolHandler = { | |
| 42 | tool: McpTool; | |
| 43 | run: ( | |
| 44 | args: Record<string, unknown>, | |
| 45 | ctx: McpContext | |
| 46 | ) => Promise<unknown>; | |
| 47 | }; | |
| 48 | ||
| 49 | const argString = ( | |
| 50 | args: Record<string, unknown>, | |
| 51 | key: string, | |
| 52 | fallback?: string | |
| 53 | ): string => { | |
| 54 | const v = args[key]; | |
| 55 | if (typeof v === "string" && v.trim().length > 0) return v.trim(); | |
| 56 | if (fallback !== undefined) return fallback; | |
| 57 | throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' is required`); | |
| 58 | }; | |
| 59 | ||
| 60 | const argNumber = ( | |
| 61 | args: Record<string, unknown>, | |
| 62 | key: string, | |
| 63 | fallback?: number | |
| 64 | ): number => { | |
| 65 | const v = args[key]; | |
| 66 | if (typeof v === "number" && Number.isFinite(v)) return v; | |
| 67 | if (typeof v === "string" && /^\d+$/.test(v)) return Number.parseInt(v, 10); | |
| 68 | if (fallback !== undefined) return fallback; | |
| 69 | throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`); | |
| 70 | }; | |
| 71 | ||
| 72 | // --------------------------------------------------------------------------- | |
| 73 | // gluecron_repo_search | |
| 74 | // --------------------------------------------------------------------------- | |
| 75 | ||
| 76 | const repoSearch: McpToolHandler = { | |
| 77 | tool: { | |
| 78 | name: "gluecron_repo_search", | |
| 79 | description: | |
| 80 | "Search public Gluecron repositories by keyword. Matches against name + description. Returns up to 20 results.", | |
| 81 | inputSchema: { | |
| 82 | type: "object", | |
| 83 | properties: { | |
| 84 | query: { type: "string", description: "Search keyword (1-100 chars)" }, | |
| 85 | limit: { type: "number", description: "Max results, default 20" }, | |
| 86 | }, | |
| 87 | required: ["query"], | |
| 88 | }, | |
| 89 | }, | |
| 90 | async run(args) { | |
| 91 | const q = argString(args, "query"); | |
| 92 | if (q.length > 100) { | |
| 93 | throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)"); | |
| 94 | } | |
| 95 | const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20))); | |
| 96 | const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`; | |
| 97 | const rows = await db | |
| 98 | .select({ | |
| 99 | id: repositories.id, | |
| 100 | name: repositories.name, | |
| 101 | description: repositories.description, | |
| 102 | ownerName: users.username, | |
| 103 | stars: repositories.starCount, | |
| 104 | }) | |
| 105 | .from(repositories) | |
| 106 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 107 | .where( | |
| 108 | and( | |
| 109 | eq(repositories.isPrivate, false), | |
| 110 | or( | |
| 111 | like(repositories.name, pattern), | |
| 112 | like(repositories.description, pattern) | |
| 113 | ) | |
| 114 | ) | |
| 115 | ) | |
| 116 | .orderBy(desc(repositories.starCount)) | |
| 117 | .limit(limit); | |
| 118 | return { | |
| 119 | total: rows.length, | |
| 120 | repos: rows.map((r) => ({ | |
| 121 | fullName: `${r.ownerName}/${r.name}`, | |
| 122 | description: r.description || "", | |
| 123 | stars: r.stars, | |
| 124 | })), | |
| 125 | }; | |
| 126 | }, | |
| 127 | }; | |
| 128 | ||
| 129 | // --------------------------------------------------------------------------- | |
| 130 | // gluecron_repo_read_file | |
| 131 | // --------------------------------------------------------------------------- | |
| 132 | ||
| 133 | const repoReadFile: McpToolHandler = { | |
| 134 | tool: { | |
| 135 | name: "gluecron_repo_read_file", | |
| 136 | description: | |
| 137 | "Read a single file from a public repository at a given ref (branch / tag / commit). Returns the text content (binary files rejected).", | |
| 138 | inputSchema: { | |
| 139 | type: "object", | |
| 140 | properties: { | |
| 141 | owner: { type: "string", description: "Repo owner username" }, | |
| 142 | repo: { type: "string", description: "Repo name" }, | |
| 143 | ref: { type: "string", description: "Branch / tag / commit (default: main)" }, | |
| 144 | path: { type: "string", description: "File path within the repo" }, | |
| 145 | }, | |
| 146 | required: ["owner", "repo", "path"], | |
| 147 | }, | |
| 148 | }, | |
| 149 | async run(args) { | |
| 150 | const owner = argString(args, "owner"); | |
| 151 | const repo = argString(args, "repo"); | |
| 152 | const ref = argString(args, "ref", "main"); | |
| 153 | const path = argString(args, "path"); | |
| 154 | ||
| 155 | // Visibility check — public-only for v1. (Authed users see private | |
| 156 | // repos in v2 once we extend the args.) | |
| 157 | const [r] = await db | |
| 158 | .select({ isPrivate: repositories.isPrivate }) | |
| 159 | .from(repositories) | |
| 160 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 161 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 162 | .limit(1); | |
| 163 | if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`); | |
| 164 | if (r.isPrivate) { | |
| 165 | throw new McpError( | |
| 166 | ERR_METHOD_NOT_FOUND, | |
| 167 | `${owner}/${repo} is private; v1 MCP read tool is public-only` | |
| 168 | ); | |
| 169 | } | |
| 170 | ||
| 171 | const blob = await getBlob(owner, repo, ref, path); | |
| 172 | if (!blob) { | |
| 173 | throw new McpError( | |
| 174 | ERR_METHOD_NOT_FOUND, | |
| 175 | `path not found: ${owner}/${repo}@${ref}:${path}` | |
| 176 | ); | |
| 177 | } | |
| 178 | return { | |
| 179 | content: [ | |
| 180 | { | |
| 181 | type: "text", | |
| 182 | text: blob.content, | |
| 183 | }, | |
| 184 | ], | |
| 185 | }; | |
| 186 | }, | |
| 187 | }; | |
| 188 | ||
| 189 | // --------------------------------------------------------------------------- | |
| 190 | // gluecron_repo_list_issues | |
| 191 | // --------------------------------------------------------------------------- | |
| 192 | ||
| 193 | const repoListIssues: McpToolHandler = { | |
| 194 | tool: { | |
| 195 | name: "gluecron_repo_list_issues", | |
| 196 | description: | |
| 197 | "List open issues for a public repository. Returns up to 50 ordered by most-recent.", | |
| 198 | inputSchema: { | |
| 199 | type: "object", | |
| 200 | properties: { | |
| 201 | owner: { type: "string", description: "Repo owner username" }, | |
| 202 | repo: { type: "string", description: "Repo name" }, | |
| 203 | limit: { type: "number", description: "Max results, default 25" }, | |
| 204 | }, | |
| 205 | required: ["owner", "repo"], | |
| 206 | }, | |
| 207 | }, | |
| 208 | async run(args) { | |
| 209 | const owner = argString(args, "owner"); | |
| 210 | const repo = argString(args, "repo"); | |
| 211 | const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25))); | |
| 212 | ||
| 213 | const [r] = await db | |
| 214 | .select({ id: repositories.id, isPrivate: repositories.isPrivate }) | |
| 215 | .from(repositories) | |
| 216 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 217 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 218 | .limit(1); | |
| 219 | if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`); | |
| 220 | if (r.isPrivate) { | |
| 221 | throw new McpError( | |
| 222 | ERR_METHOD_NOT_FOUND, | |
| 223 | `${owner}/${repo} is private; v1 MCP read tool is public-only` | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | const rows = await db | |
| 228 | .select({ | |
| 229 | number: issues.number, | |
| 230 | title: issues.title, | |
| 231 | body: issues.body, | |
| 232 | state: issues.state, | |
| 233 | createdAt: issues.createdAt, | |
| 234 | }) | |
| 235 | .from(issues) | |
| 236 | .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open"))) | |
| 237 | .orderBy(desc(issues.createdAt)) | |
| 238 | .limit(limit); | |
| 239 | return { | |
| 240 | total: rows.length, | |
| 241 | issues: rows.map((i) => ({ | |
| 242 | number: i.number, | |
| 243 | title: i.title, | |
| 244 | body: i.body || "", | |
| 245 | state: i.state, | |
| 246 | createdAt: i.createdAt, | |
| 247 | })), | |
| 248 | }; | |
| 249 | }, | |
| 250 | }; | |
| 251 | ||
| 252 | // --------------------------------------------------------------------------- | |
| 253 | // gluecron_repo_explain_codebase | |
| 254 | // --------------------------------------------------------------------------- | |
| 255 | ||
| 256 | const repoExplain: McpToolHandler = { | |
| 257 | tool: { | |
| 258 | name: "gluecron_repo_explain_codebase", | |
| 259 | description: | |
| 260 | "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.", | |
| 261 | inputSchema: { | |
| 262 | type: "object", | |
| 263 | properties: { | |
| 264 | owner: { type: "string", description: "Repo owner username" }, | |
| 265 | repo: { type: "string", description: "Repo name" }, | |
| 266 | }, | |
| 267 | required: ["owner", "repo"], | |
| 268 | }, | |
| 269 | }, | |
| 270 | async run(args) { | |
| 271 | const owner = argString(args, "owner"); | |
| 272 | const repo = argString(args, "repo"); | |
| 273 | const [r] = await db | |
| 274 | .select({ id: repositories.id, isPrivate: repositories.isPrivate }) | |
| 275 | .from(repositories) | |
| 276 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 277 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 278 | .limit(1); | |
| 279 | if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`); | |
| 280 | if (r.isPrivate) { | |
| 281 | throw new McpError( | |
| 282 | ERR_METHOD_NOT_FOUND, | |
| 283 | `${owner}/${repo} is private; v1 MCP read tool is public-only` | |
| 284 | ); | |
| 285 | } | |
| 286 | const [row] = await db | |
| 287 | .select({ | |
| 288 | commitSha: codebaseExplanations.commitSha, | |
| 289 | markdown: codebaseExplanations.markdown, | |
| 290 | createdAt: codebaseExplanations.createdAt, | |
| 291 | }) | |
| 292 | .from(codebaseExplanations) | |
| 293 | .where(eq(codebaseExplanations.repositoryId, r.id)) | |
| 294 | .orderBy(desc(codebaseExplanations.createdAt)) | |
| 295 | .limit(1); | |
| 296 | if (!row) { | |
| 297 | return { explanation: null }; | |
| 298 | } | |
| 299 | return { | |
| 300 | commitSha: row.commitSha, | |
| 301 | generatedAt: row.createdAt, | |
| 302 | markdown: row.markdown, | |
| 303 | }; | |
| 304 | }, | |
| 305 | }; | |
| 306 | ||
| f5a18f9 | 307 | // --------------------------------------------------------------------------- |
| 308 | // gluecron_repo_health | |
| 309 | // --------------------------------------------------------------------------- | |
| 310 | ||
| 311 | const repoHealth: McpToolHandler = { | |
| 312 | tool: { | |
| 313 | name: "gluecron_repo_health", | |
| 314 | description: | |
| 315 | "Compute the current health report for a public repo: overall score (0-100), letter grade, per-category breakdown (security/testing/complexity/dependencies/documentation/activity), and a list of insights to fix next. Backed by computeHealthScore in src/lib/intelligence.ts.", | |
| 316 | inputSchema: { | |
| 317 | type: "object", | |
| 318 | properties: { | |
| 319 | owner: { type: "string", description: "Repo owner username" }, | |
| 320 | repo: { type: "string", description: "Repo name" }, | |
| 321 | }, | |
| 322 | required: ["owner", "repo"], | |
| 323 | }, | |
| 324 | }, | |
| 325 | async run(args) { | |
| 326 | const owner = argString(args, "owner"); | |
| 327 | const repo = argString(args, "repo"); | |
| 328 | ||
| 329 | const [r] = await db | |
| 330 | .select({ id: repositories.id, isPrivate: repositories.isPrivate }) | |
| 331 | .from(repositories) | |
| 332 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 333 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 334 | .limit(1); | |
| 335 | if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`); | |
| 336 | if (r.isPrivate) { | |
| 337 | throw new McpError( | |
| 338 | ERR_METHOD_NOT_FOUND, | |
| 339 | `${owner}/${repo} is private; v1 MCP tools are public-only` | |
| 340 | ); | |
| 341 | } | |
| 342 | if (!(await repoExists(owner, repo))) { | |
| 343 | throw new McpError( | |
| 344 | ERR_METHOD_NOT_FOUND, | |
| 345 | `${owner}/${repo} has no on-disk git data yet` | |
| 346 | ); | |
| 347 | } | |
| 348 | const report = await computeHealthScore(owner, repo); | |
| 349 | return { | |
| 350 | score: report.score, | |
| 351 | grade: report.grade, | |
| 352 | breakdown: report.breakdown, | |
| 353 | insights: report.insights, | |
| 354 | generatedAt: report.generatedAt, | |
| 355 | }; | |
| 356 | }, | |
| 357 | }; | |
| 358 | ||
| 2c2163e | 359 | // --------------------------------------------------------------------------- |
| 360 | // Default tool registry | |
| 361 | // --------------------------------------------------------------------------- | |
| 362 | ||
| 363 | export function defaultTools(): Record<string, McpToolHandler> { | |
| 364 | return { | |
| 365 | [repoSearch.tool.name]: repoSearch, | |
| 366 | [repoReadFile.tool.name]: repoReadFile, | |
| 367 | [repoListIssues.tool.name]: repoListIssues, | |
| 368 | [repoExplain.tool.name]: repoExplain, | |
| f5a18f9 | 369 | [repoHealth.tool.name]: repoHealth, |
| 2c2163e | 370 | }; |
| 371 | } | |
| 372 | ||
| 373 | /** Test-only export of internal helpers + per-tool handlers. */ | |
| 374 | export const __test = { | |
| 375 | argString, | |
| 376 | argNumber, | |
| 377 | repoSearch, | |
| 378 | repoReadFile, | |
| 379 | repoListIssues, | |
| 380 | repoExplain, | |
| f5a18f9 | 381 | repoHealth, |
| 2c2163e | 382 | }; |