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.tsBlame327 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";
26import { getBlob } from "../git/repository";
27import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
28import type { McpContext } from "./mcp";
29
30export type McpTool = {
31 name: string;
32 description: string;
33 inputSchema: {
34 type: "object";
35 properties: Record<string, { type: string; description?: string }>;
36 required?: string[];
37 };
38};
39
40export type McpToolHandler = {
41 tool: McpTool;
42 run: (
43 args: Record<string, unknown>,
44 ctx: McpContext
45 ) => Promise<unknown>;
46};
47
48const argString = (
49 args: Record<string, unknown>,
50 key: string,
51 fallback?: string
52): string => {
53 const v = args[key];
54 if (typeof v === "string" && v.trim().length > 0) return v.trim();
55 if (fallback !== undefined) return fallback;
56 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' is required`);
57};
58
59const argNumber = (
60 args: Record<string, unknown>,
61 key: string,
62 fallback?: number
63): number => {
64 const v = args[key];
65 if (typeof v === "number" && Number.isFinite(v)) return v;
66 if (typeof v === "string" && /^\d+$/.test(v)) return Number.parseInt(v, 10);
67 if (fallback !== undefined) return fallback;
68 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`);
69};
70
71// ---------------------------------------------------------------------------
72// gluecron_repo_search
73// ---------------------------------------------------------------------------
74
75const repoSearch: McpToolHandler = {
76 tool: {
77 name: "gluecron_repo_search",
78 description:
79 "Search public Gluecron repositories by keyword. Matches against name + description. Returns up to 20 results.",
80 inputSchema: {
81 type: "object",
82 properties: {
83 query: { type: "string", description: "Search keyword (1-100 chars)" },
84 limit: { type: "number", description: "Max results, default 20" },
85 },
86 required: ["query"],
87 },
88 },
89 async run(args) {
90 const q = argString(args, "query");
91 if (q.length > 100) {
92 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
93 }
94 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
95 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
96 const rows = await db
97 .select({
98 id: repositories.id,
99 name: repositories.name,
100 description: repositories.description,
101 ownerName: users.username,
102 stars: repositories.starCount,
103 })
104 .from(repositories)
105 .innerJoin(users, eq(repositories.ownerId, users.id))
106 .where(
107 and(
108 eq(repositories.isPrivate, false),
109 or(
110 like(repositories.name, pattern),
111 like(repositories.description, pattern)
112 )
113 )
114 )
115 .orderBy(desc(repositories.starCount))
116 .limit(limit);
117 return {
118 total: rows.length,
119 repos: rows.map((r) => ({
120 fullName: `${r.ownerName}/${r.name}`,
121 description: r.description || "",
122 stars: r.stars,
123 })),
124 };
125 },
126};
127
128// ---------------------------------------------------------------------------
129// gluecron_repo_read_file
130// ---------------------------------------------------------------------------
131
132const repoReadFile: McpToolHandler = {
133 tool: {
134 name: "gluecron_repo_read_file",
135 description:
136 "Read a single file from a public repository at a given ref (branch / tag / commit). Returns the text content (binary files rejected).",
137 inputSchema: {
138 type: "object",
139 properties: {
140 owner: { type: "string", description: "Repo owner username" },
141 repo: { type: "string", description: "Repo name" },
142 ref: { type: "string", description: "Branch / tag / commit (default: main)" },
143 path: { type: "string", description: "File path within the repo" },
144 },
145 required: ["owner", "repo", "path"],
146 },
147 },
148 async run(args) {
149 const owner = argString(args, "owner");
150 const repo = argString(args, "repo");
151 const ref = argString(args, "ref", "main");
152 const path = argString(args, "path");
153
154 // Visibility check — public-only for v1. (Authed users see private
155 // repos in v2 once we extend the args.)
156 const [r] = await db
157 .select({ isPrivate: repositories.isPrivate })
158 .from(repositories)
159 .innerJoin(users, eq(repositories.ownerId, users.id))
160 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
161 .limit(1);
162 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
163 if (r.isPrivate) {
164 throw new McpError(
165 ERR_METHOD_NOT_FOUND,
166 `${owner}/${repo} is private; v1 MCP read tool is public-only`
167 );
168 }
169
170 const blob = await getBlob(owner, repo, ref, path);
171 if (!blob) {
172 throw new McpError(
173 ERR_METHOD_NOT_FOUND,
174 `path not found: ${owner}/${repo}@${ref}:${path}`
175 );
176 }
177 return {
178 content: [
179 {
180 type: "text",
181 text: blob.content,
182 },
183 ],
184 };
185 },
186};
187
188// ---------------------------------------------------------------------------
189// gluecron_repo_list_issues
190// ---------------------------------------------------------------------------
191
192const repoListIssues: McpToolHandler = {
193 tool: {
194 name: "gluecron_repo_list_issues",
195 description:
196 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
197 inputSchema: {
198 type: "object",
199 properties: {
200 owner: { type: "string", description: "Repo owner username" },
201 repo: { type: "string", description: "Repo name" },
202 limit: { type: "number", description: "Max results, default 25" },
203 },
204 required: ["owner", "repo"],
205 },
206 },
207 async run(args) {
208 const owner = argString(args, "owner");
209 const repo = argString(args, "repo");
210 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
211
212 const [r] = await db
213 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
214 .from(repositories)
215 .innerJoin(users, eq(repositories.ownerId, users.id))
216 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
217 .limit(1);
218 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
219 if (r.isPrivate) {
220 throw new McpError(
221 ERR_METHOD_NOT_FOUND,
222 `${owner}/${repo} is private; v1 MCP read tool is public-only`
223 );
224 }
225
226 const rows = await db
227 .select({
228 number: issues.number,
229 title: issues.title,
230 body: issues.body,
231 state: issues.state,
232 createdAt: issues.createdAt,
233 })
234 .from(issues)
235 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
236 .orderBy(desc(issues.createdAt))
237 .limit(limit);
238 return {
239 total: rows.length,
240 issues: rows.map((i) => ({
241 number: i.number,
242 title: i.title,
243 body: i.body || "",
244 state: i.state,
245 createdAt: i.createdAt,
246 })),
247 };
248 },
249};
250
251// ---------------------------------------------------------------------------
252// gluecron_repo_explain_codebase
253// ---------------------------------------------------------------------------
254
255const repoExplain: McpToolHandler = {
256 tool: {
257 name: "gluecron_repo_explain_codebase",
258 description:
259 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
260 inputSchema: {
261 type: "object",
262 properties: {
263 owner: { type: "string", description: "Repo owner username" },
264 repo: { type: "string", description: "Repo name" },
265 },
266 required: ["owner", "repo"],
267 },
268 },
269 async run(args) {
270 const owner = argString(args, "owner");
271 const repo = argString(args, "repo");
272 const [r] = await db
273 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
274 .from(repositories)
275 .innerJoin(users, eq(repositories.ownerId, users.id))
276 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
277 .limit(1);
278 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
279 if (r.isPrivate) {
280 throw new McpError(
281 ERR_METHOD_NOT_FOUND,
282 `${owner}/${repo} is private; v1 MCP read tool is public-only`
283 );
284 }
285 const [row] = await db
286 .select({
287 commitSha: codebaseExplanations.commitSha,
288 markdown: codebaseExplanations.markdown,
289 createdAt: codebaseExplanations.createdAt,
290 })
291 .from(codebaseExplanations)
292 .where(eq(codebaseExplanations.repositoryId, r.id))
293 .orderBy(desc(codebaseExplanations.createdAt))
294 .limit(1);
295 if (!row) {
296 return { explanation: null };
297 }
298 return {
299 commitSha: row.commitSha,
300 generatedAt: row.createdAt,
301 markdown: row.markdown,
302 };
303 },
304};
305
306// ---------------------------------------------------------------------------
307// Default tool registry
308// ---------------------------------------------------------------------------
309
310export function defaultTools(): Record<string, McpToolHandler> {
311 return {
312 [repoSearch.tool.name]: repoSearch,
313 [repoReadFile.tool.name]: repoReadFile,
314 [repoListIssues.tool.name]: repoListIssues,
315 [repoExplain.tool.name]: repoExplain,
316 };
317}
318
319/** Test-only export of internal helpers + per-tool handlers. */
320export const __test = {
321 argString,
322 argNumber,
323 repoSearch,
324 repoReadFile,
325 repoListIssues,
326 repoExplain,
327};