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.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.tsBlame382 lines · 1 contributor
2c2163eClaude1/**
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
18import { and, asc, desc, eq, like, or } from "drizzle-orm";
19import { db } from "../db";
20import {
21 issues,
22 repositories,
23 users,
24 codebaseExplanations,
25} from "../db/schema";
f5a18f9Claude26import { getBlob, repoExists } from "../git/repository";
27import { computeHealthScore } from "./intelligence";
2c2163eClaude28import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
29import type { McpContext } from "./mcp";
30
31export 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
41export type McpToolHandler = {
42 tool: McpTool;
43 run: (
44 args: Record<string, unknown>,
45 ctx: McpContext
46 ) => Promise<unknown>;
47};
48
49const 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
60const 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
76const 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
133const 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
193const 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
256const 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,
2316901Claude290 generatedAt: codebaseExplanations.generatedAt,
2c2163eClaude291 })
292 .from(codebaseExplanations)
293 .where(eq(codebaseExplanations.repositoryId, r.id))
2316901Claude294 .orderBy(desc(codebaseExplanations.generatedAt))
2c2163eClaude295 .limit(1);
296 if (!row) {
297 return { explanation: null };
298 }
299 return {
300 commitSha: row.commitSha,
2316901Claude301 generatedAt: row.generatedAt,
2c2163eClaude302 markdown: row.markdown,
303 };
304 },
305};
306
f5a18f9Claude307// ---------------------------------------------------------------------------
308// gluecron_repo_health
309// ---------------------------------------------------------------------------
310
311const 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
2c2163eClaude359// ---------------------------------------------------------------------------
360// Default tool registry
361// ---------------------------------------------------------------------------
362
363export 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,
f5a18f9Claude369 [repoHealth.tool.name]: repoHealth,
2c2163eClaude370 };
371}
372
373/** Test-only export of internal helpers + per-tool handlers. */
374export const __test = {
375 argString,
376 argNumber,
377 repoSearch,
378 repoReadFile,
379 repoListIssues,
380 repoExplain,
f5a18f9Claude381 repoHealth,
2c2163eClaude382};