Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit2c2163eunknown_key

feat(mcp): Model Context Protocol server (move #9 from STRATEGY.md)

feat(mcp): Model Context Protocol server (move #9 from STRATEGY.md)

Closes the third strategic move from docs/STRATEGY.md §3 by adding a
spec-compliant MCP HTTP transport so any MCP client (Claude Desktop,
Claude Code, Cursor) can introspect + drive Gluecron repositories.

Routes:
- GET  /mcp — discovery: protocolVersion, serverInfo, toolCount.
- POST /mcp — JSON-RPC 2.0 endpoint. Single requests + batched arrays
  are both supported; notifications return 204.

Pure router (src/lib/mcp.ts):
- routeMcpRequest({jsonrpc, method, id?, params?}, {ctx, tools})
- Methods: initialize, notifications/initialized, ping, tools/list,
  tools/call. Unknown methods → -32601 Method not found.
- Standard JSON-RPC error codes exported (PARSE / INVALID_REQUEST /
  METHOD_NOT_FOUND / INVALID_PARAMS / INTERNAL).
- McpError class for typed throws from tool handlers.
- Never throws into the route — internal exceptions become -32603.

v1 tool surface (src/lib/mcp-tools.ts), all read-only + public-only:
- gluecron_repo_search        — keyword search across public repos
- gluecron_repo_read_file     — fetch a file at a ref
- gluecron_repo_list_issues   — open issues for a public repo
- gluecron_repo_explain_codebase — return cached AI explanation

Tools degrade cleanly when called against a private repo
(`-32601 not found` per spec — visibility info itself is private).
Each handler validates inputs via argString / argNumber helpers
that throw McpError(-32602) on missing or wrong-typed args.

Mounting: src/app.tsx imports mcpRoutes and mounts at root, alongside
the existing graphqlRoutes / liveEventsRoutes.

v2 (next move): write tools (create_issue, post_comment, run_workflow)
gated on userId + write-access. SSE-based notifications for log
streaming. OAuth pre-auth flow. None of those break the v1 envelope —
the router is already extension-friendly.

Tests: +21 (initialize handshake, batched requests, notifications,
parse error, unknown method, tools/list manifest shape, tools/call
validation, every default tool's manifest correctness, argString /
argNumber edge cases, GET /mcp discovery, POST /mcp end-to-end on
single + notification + batched envelopes). Total suite 1213 pass /
8 skip / 0 fail (was 1192 / 8 / 0).

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: 22693d7
5 files changed+83302c2163e35da3c5b155abc0f810b42e735f8cad16
5 changed files+833−0
Addedsrc/__tests__/mcp.test.ts+212−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/mcp.ts (router) + src/lib/mcp-tools.ts (tools).
3 *
4 * The DB-touching tool runs require live Postgres; we focus on:
5 * - JSON-RPC envelope shape (initialize / tools/list / unknown method)
6 * - Notification handling (no `id` → no response)
7 * - tools/call validation (missing name, unknown tool)
8 * - Tool manifest shape (every default tool has a name + inputSchema)
9 * - Pure helper edge cases (argString / argNumber)
10 * - The HTTP route's discovery GET + JSON-RPC POST shape
11 */
12
13import { describe, it, expect } from "bun:test";
14import {
15 routeMcpRequest,
16 MCP_PROTOCOL_VERSION,
17 MCP_SERVER_NAME,
18 ERR_METHOD_NOT_FOUND,
19 ERR_INVALID_REQUEST,
20 ERR_INVALID_PARAMS,
21 McpError,
22} from "../lib/mcp";
23import { defaultTools, __test } from "../lib/mcp-tools";
24import app from "../app";
25
26const tools = defaultTools();
27const ctx = { userId: null };
28
29describe("routeMcpRequest — initialize", () => {
30 it("returns the protocol version + server info", async () => {
31 const r = await routeMcpRequest(
32 { jsonrpc: "2.0", id: 1, method: "initialize" },
33 { ctx, tools }
34 );
35 expect(r).not.toBeNull();
36 if (!r || "error" in r) throw new Error("expected success");
37 const result = r.result as any;
38 expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
39 expect(result.serverInfo.name).toBe(MCP_SERVER_NAME);
40 expect(result.capabilities.tools).toBeDefined();
41 });
42});
43
44describe("routeMcpRequest — invalid input", () => {
45 it("rejects a non-JSON-RPC envelope", async () => {
46 const r = await routeMcpRequest({ method: "x" } as any, { ctx, tools });
47 if (!r || "result" in r) throw new Error("expected error");
48 expect(r.error.code).toBe(ERR_INVALID_REQUEST);
49 });
50
51 it("rejects an unknown method", async () => {
52 const r = await routeMcpRequest(
53 { jsonrpc: "2.0", id: 1, method: "no_such_method" },
54 { ctx, tools }
55 );
56 if (!r || "result" in r) throw new Error("expected error");
57 expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
58 });
59
60 it("returns null for a notification (no `id`)", async () => {
61 const r = await routeMcpRequest(
62 { jsonrpc: "2.0", method: "notifications/initialized" },
63 { ctx, tools }
64 );
65 expect(r).toBeNull();
66 });
67});
68
69describe("routeMcpRequest — tools/list", () => {
70 it("returns the full tool manifest", async () => {
71 const r = await routeMcpRequest(
72 { jsonrpc: "2.0", id: 2, method: "tools/list" },
73 { ctx, tools }
74 );
75 if (!r || "error" in r) throw new Error("expected success");
76 const result = r.result as { tools: Array<{ name: string; inputSchema: any }> };
77 expect(Array.isArray(result.tools)).toBe(true);
78 expect(result.tools.length).toBeGreaterThanOrEqual(4);
79 for (const t of result.tools) {
80 expect(typeof t.name).toBe("string");
81 expect(t.name).toMatch(/^gluecron_/);
82 expect(t.inputSchema.type).toBe("object");
83 }
84 });
85});
86
87describe("routeMcpRequest — tools/call validation", () => {
88 it("rejects calls without a name", async () => {
89 const r = await routeMcpRequest(
90 { jsonrpc: "2.0", id: 3, method: "tools/call", params: {} },
91 { ctx, tools }
92 );
93 if (!r || "result" in r) throw new Error("expected error");
94 expect(r.error.code).toBe(ERR_INVALID_PARAMS);
95 });
96
97 it("rejects calls with an unknown tool name", async () => {
98 const r = await routeMcpRequest(
99 {
100 jsonrpc: "2.0",
101 id: 4,
102 method: "tools/call",
103 params: { name: "no_such_tool", arguments: {} },
104 },
105 { ctx, tools }
106 );
107 if (!r || "result" in r) throw new Error("expected error");
108 expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
109 });
110});
111
112describe("argString / argNumber pure helpers", () => {
113 it("returns the trimmed string when present", () => {
114 expect(__test.argString({ x: " hi " }, "x")).toBe("hi");
115 });
116
117 it("falls back when missing", () => {
118 expect(__test.argString({}, "x", "default")).toBe("default");
119 });
120
121 it("throws McpError when missing without fallback", () => {
122 expect(() => __test.argString({}, "x")).toThrow(McpError);
123 });
124
125 it("argNumber accepts numeric strings", () => {
126 expect(__test.argNumber({ n: "42" }, "n")).toBe(42);
127 });
128
129 it("argNumber falls back on non-numeric input", () => {
130 expect(__test.argNumber({ n: "abc" }, "n", 7)).toBe(7);
131 });
132});
133
134describe("tool manifest shape", () => {
135 for (const handler of Object.values(tools)) {
136 it(`${handler.tool.name} — required fields populated`, () => {
137 expect(handler.tool.name).toMatch(/^gluecron_/);
138 expect(typeof handler.tool.description).toBe("string");
139 expect(handler.tool.description.length).toBeGreaterThan(10);
140 expect(handler.tool.inputSchema.type).toBe("object");
141 expect(typeof handler.tool.inputSchema.properties).toBe("object");
142 });
143 }
144});
145
146describe("HTTP route — GET /mcp discovery", () => {
147 it("returns server info + tool count", async () => {
148 const res = await app.request("/mcp");
149 expect(res.status).toBe(200);
150 const body = (await res.json()) as any;
151 expect(body.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
152 expect(body.serverInfo.name).toBe(MCP_SERVER_NAME);
153 expect(body.toolCount).toBeGreaterThanOrEqual(4);
154 });
155});
156
157describe("HTTP route — POST /mcp JSON-RPC", () => {
158 it("answers initialize over the wire", async () => {
159 const res = await app.request("/mcp", {
160 method: "POST",
161 headers: { "content-type": "application/json" },
162 body: JSON.stringify({
163 jsonrpc: "2.0",
164 id: 1,
165 method: "initialize",
166 }),
167 });
168 expect(res.status).toBe(200);
169 const body = (await res.json()) as any;
170 expect(body.jsonrpc).toBe("2.0");
171 expect(body.id).toBe(1);
172 expect(body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
173 });
174
175 it("returns 400 on invalid JSON", async () => {
176 const res = await app.request("/mcp", {
177 method: "POST",
178 headers: { "content-type": "application/json" },
179 body: "not json",
180 });
181 expect(res.status).toBe(400);
182 });
183
184 it("returns 204 for a single notification (no `id`)", async () => {
185 const res = await app.request("/mcp", {
186 method: "POST",
187 headers: { "content-type": "application/json" },
188 body: JSON.stringify({
189 jsonrpc: "2.0",
190 method: "notifications/initialized",
191 }),
192 });
193 expect(res.status).toBe(204);
194 });
195
196 it("supports a batched request", async () => {
197 const res = await app.request("/mcp", {
198 method: "POST",
199 headers: { "content-type": "application/json" },
200 body: JSON.stringify([
201 { jsonrpc: "2.0", id: 1, method: "initialize" },
202 { jsonrpc: "2.0", id: 2, method: "tools/list" },
203 ]),
204 });
205 expect(res.status).toBe(200);
206 const body = (await res.json()) as any[];
207 expect(body.length).toBe(2);
208 expect(body[0].id).toBe(1);
209 expect(body[1].id).toBe(2);
210 expect(body[1].result.tools.length).toBeGreaterThanOrEqual(4);
211 });
212});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
7272import gatesRoutes from "./routes/gates";
7373import gistsRoutes from "./routes/gists";
7474import graphqlRoutes from "./routes/graphql";
75import mcpRoutes from "./routes/mcp";
7576import marketplaceRoutes from "./routes/marketplace";
7677import mergeQueueRoutes from "./routes/merge-queue";
7778import mirrorsRoutes from "./routes/mirrors";
282283app.route("/", gatesRoutes);
283284app.route("/", gistsRoutes);
284285app.route("/", graphqlRoutes);
286app.route("/", mcpRoutes);
285287app.route("/", marketplaceRoutes);
286288app.route("/", mergeQueueRoutes);
287289app.route("/", mirrorsRoutes);
Addedsrc/lib/mcp-tools.ts+327−0View fileUnifiedSplit
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
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};
Addedsrc/lib/mcp.ts+209−0View fileUnifiedSplit
1/**
2 * Model Context Protocol (MCP) server — minimal JSON-RPC 2.0 router.
3 *
4 * MCP: https://spec.modelcontextprotocol.io/
5 *
6 * v1 scope (this file):
7 * - Streamable HTTP transport at POST /mcp
8 * - initialize / initialized handshake
9 * - tools/list (static manifest)
10 * - tools/call dispatching to a small set of read-only tools
11 *
12 * Out of scope for v1 (future moves):
13 * - resources/list + resources/read (the bible files, repo READMEs)
14 * - prompts/* (saved-replies as MCP prompts)
15 * - server-sent notifications (logs streaming)
16 * - oauth flow + per-call auth (we accept PAT + OAuth bearer for now)
17 *
18 * Pure JSON-RPC routing here — the per-tool handlers live in
19 * `mcp-tools.ts`. This split lets the router be tested without DB.
20 */
21
22import type { McpToolHandler, McpTool } from "./mcp-tools";
23
24export const MCP_PROTOCOL_VERSION = "2025-06-18";
25export const MCP_SERVER_NAME = "gluecron";
26export const MCP_SERVER_VERSION = "1.0";
27
28export type JsonRpcRequest = {
29 jsonrpc: "2.0";
30 id?: string | number | null;
31 method: string;
32 params?: unknown;
33};
34
35export type JsonRpcResponse =
36 | {
37 jsonrpc: "2.0";
38 id: string | number | null;
39 result: unknown;
40 }
41 | {
42 jsonrpc: "2.0";
43 id: string | number | null;
44 error: { code: number; message: string; data?: unknown };
45 };
46
47// JSON-RPC standard error codes
48export const ERR_PARSE = -32700;
49export const ERR_INVALID_REQUEST = -32600;
50export const ERR_METHOD_NOT_FOUND = -32601;
51export const ERR_INVALID_PARAMS = -32602;
52export const ERR_INTERNAL = -32603;
53
54export type McpContext = {
55 /** Authenticated user id; null for anonymous (only allowed for some calls). */
56 userId: string | null;
57};
58
59export type McpRouterArgs = {
60 ctx: McpContext;
61 tools: Record<string, McpToolHandler>;
62};
63
64function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
65 return (
66 !!v &&
67 typeof v === "object" &&
68 (v as JsonRpcRequest).jsonrpc === "2.0" &&
69 typeof (v as JsonRpcRequest).method === "string"
70 );
71}
72
73/**
74 * Route a single JSON-RPC request. Returns the response shape (or null
75 * for notifications, which have no `id`). Never throws — internal
76 * exceptions are caught and re-shaped as `-32603 internal error`.
77 */
78export async function routeMcpRequest(
79 req: unknown,
80 args: McpRouterArgs
81): Promise<JsonRpcResponse | null> {
82 if (!isJsonRpcRequest(req)) {
83 return {
84 jsonrpc: "2.0",
85 id: null,
86 error: { code: ERR_INVALID_REQUEST, message: "Invalid JSON-RPC request" },
87 };
88 }
89
90 const id = req.id ?? null;
91 const isNotification = req.id === undefined;
92
93 try {
94 const result = await dispatch(req.method, req.params, args);
95 if (isNotification) return null;
96 return { jsonrpc: "2.0", id, result };
97 } catch (err) {
98 if (isNotification) return null;
99 if (err instanceof McpError) {
100 return {
101 jsonrpc: "2.0",
102 id,
103 error: { code: err.code, message: err.message, data: err.data },
104 };
105 }
106 return {
107 jsonrpc: "2.0",
108 id,
109 error: {
110 code: ERR_INTERNAL,
111 message: err instanceof Error ? err.message : "internal error",
112 },
113 };
114 }
115}
116
117export class McpError extends Error {
118 code: number;
119 data?: unknown;
120 constructor(code: number, message: string, data?: unknown) {
121 super(message);
122 this.code = code;
123 this.data = data;
124 }
125}
126
127async function dispatch(
128 method: string,
129 params: unknown,
130 args: McpRouterArgs
131): Promise<unknown> {
132 switch (method) {
133 case "initialize":
134 return handleInitialize();
135 case "notifications/initialized":
136 // Client → server notification, no response.
137 return null;
138 case "tools/list":
139 return handleToolsList(args.tools);
140 case "tools/call":
141 return handleToolsCall(params, args);
142 case "ping":
143 return {};
144 default:
145 throw new McpError(
146 ERR_METHOD_NOT_FOUND,
147 `Method not supported: ${method}`
148 );
149 }
150}
151
152function handleInitialize() {
153 return {
154 protocolVersion: MCP_PROTOCOL_VERSION,
155 capabilities: {
156 tools: { listChanged: false },
157 },
158 serverInfo: {
159 name: MCP_SERVER_NAME,
160 version: MCP_SERVER_VERSION,
161 },
162 };
163}
164
165function handleToolsList(
166 tools: Record<string, McpToolHandler>
167): { tools: McpTool[] } {
168 return {
169 tools: Object.values(tools).map((t) => t.tool),
170 };
171}
172
173async function handleToolsCall(
174 params: unknown,
175 args: McpRouterArgs
176): Promise<unknown> {
177 if (!params || typeof params !== "object") {
178 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires {name, arguments}");
179 }
180 const p = params as { name?: unknown; arguments?: unknown };
181 const name = typeof p.name === "string" ? p.name : "";
182 if (!name) {
183 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires `name`");
184 }
185 const handler = args.tools[name];
186 if (!handler) {
187 throw new McpError(ERR_METHOD_NOT_FOUND, `Unknown tool: ${name}`);
188 }
189 const toolArgs =
190 p.arguments && typeof p.arguments === "object"
191 ? (p.arguments as Record<string, unknown>)
192 : {};
193 // The MCP tools/call result shape is `{ content: [{type, text}], isError? }`.
194 // Tool handlers may either return that shape directly or just a value
195 // we wrap. Errors from handlers re-throw as McpError so the wrapper
196 // can re-shape them.
197 const out = await handler.run(toolArgs, args.ctx);
198 if (out && typeof out === "object" && Array.isArray((out as any).content)) {
199 return out;
200 }
201 return {
202 content: [
203 {
204 type: "text",
205 text: typeof out === "string" ? out : JSON.stringify(out, null, 2),
206 },
207 ],
208 };
209}
Addedsrc/routes/mcp.ts+83−0View fileUnifiedSplit
1/**
2 * Model Context Protocol HTTP transport.
3 *
4 * POST /mcp — JSON-RPC 2.0 requests; body is a single request
5 * or an array (batch). Response shape mirrors.
6 * GET /mcp — Lightweight discovery: returns server info +
7 * protocol version + tool count.
8 *
9 * Auth: softAuth — `userId` in the McpContext is the cookie/PAT/OAuth
10 * user when present, null otherwise. v1 tools are read-only and public-
11 * only, so anonymous works; write tools (v2) will require requireAuth +
12 * write-access on the target repo.
13 *
14 * Streamable-HTTP-mode is the recommended MCP transport for stateless
15 * cloud servers. We don't emit server-sent notifications yet, so the
16 * route is plain JSON in / JSON out.
17 */
18
19import { Hono } from "hono";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import {
23 routeMcpRequest,
24 MCP_PROTOCOL_VERSION,
25 MCP_SERVER_NAME,
26 MCP_SERVER_VERSION,
27} from "../lib/mcp";
28import { defaultTools } from "../lib/mcp-tools";
29
30const mcp = new Hono<AuthEnv>();
31
32mcp.use("*", softAuth);
33
34mcp.get("/mcp", (c) => {
35 const tools = defaultTools();
36 return c.json({
37 protocolVersion: MCP_PROTOCOL_VERSION,
38 serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
39 transport: "http",
40 toolCount: Object.keys(tools).length,
41 docs:
42 "POST /mcp with a JSON-RPC 2.0 envelope to call. See https://spec.modelcontextprotocol.io/",
43 });
44});
45
46mcp.post("/mcp", async (c) => {
47 const user = c.get("user") ?? null;
48 const ctx = { userId: user?.id ?? null };
49 const tools = defaultTools();
50
51 let body: unknown;
52 try {
53 body = await c.req.json();
54 } catch {
55 return c.json(
56 {
57 jsonrpc: "2.0",
58 id: null,
59 error: { code: -32700, message: "Parse error" },
60 },
61 400
62 );
63 }
64
65 if (Array.isArray(body)) {
66 // Batched request — pass each through, drop nulls (notifications).
67 const out = await Promise.all(
68 body.map((entry) => routeMcpRequest(entry, { ctx, tools }))
69 );
70 const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
71 if (filtered.length === 0) return c.body(null, 204);
72 return c.json(filtered);
73 }
74
75 const result = await routeMcpRequest(body, { ctx, tools });
76 if (result === null) {
77 // Notification — no response body, 204.
78 return c.body(null, 204);
79 }
80 return c.json(result);
81});
82
83export default mcp;
084