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.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.tsBlame216 lines · 1 contributor
2c2163eClaude1/**
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;
0feb720Claude57 /**
58 * Scopes carried by the auth credential (PAT, OAuth token, session). For
59 * session-cookie users this is `["repo","user","admin"]`. For PATs it's
60 * whatever the token was issued for. Empty array when anonymous.
61 * Optional for back-compat — handlers default to permissive when undefined.
62 */
63 scopes?: string[];
2c2163eClaude64};
65
66export type McpRouterArgs = {
67 ctx: McpContext;
68 tools: Record<string, McpToolHandler>;
69};
70
71function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
72 return (
73 !!v &&
74 typeof v === "object" &&
75 (v as JsonRpcRequest).jsonrpc === "2.0" &&
76 typeof (v as JsonRpcRequest).method === "string"
77 );
78}
79
80/**
81 * Route a single JSON-RPC request. Returns the response shape (or null
82 * for notifications, which have no `id`). Never throws — internal
83 * exceptions are caught and re-shaped as `-32603 internal error`.
84 */
85export async function routeMcpRequest(
86 req: unknown,
87 args: McpRouterArgs
88): Promise<JsonRpcResponse | null> {
89 if (!isJsonRpcRequest(req)) {
90 return {
91 jsonrpc: "2.0",
92 id: null,
93 error: { code: ERR_INVALID_REQUEST, message: "Invalid JSON-RPC request" },
94 };
95 }
96
97 const id = req.id ?? null;
98 const isNotification = req.id === undefined;
99
100 try {
101 const result = await dispatch(req.method, req.params, args);
102 if (isNotification) return null;
103 return { jsonrpc: "2.0", id, result };
104 } catch (err) {
105 if (isNotification) return null;
106 if (err instanceof McpError) {
107 return {
108 jsonrpc: "2.0",
109 id,
110 error: { code: err.code, message: err.message, data: err.data },
111 };
112 }
113 return {
114 jsonrpc: "2.0",
115 id,
116 error: {
117 code: ERR_INTERNAL,
118 message: err instanceof Error ? err.message : "internal error",
119 },
120 };
121 }
122}
123
124export class McpError extends Error {
125 code: number;
126 data?: unknown;
127 constructor(code: number, message: string, data?: unknown) {
128 super(message);
129 this.code = code;
130 this.data = data;
131 }
132}
133
134async function dispatch(
135 method: string,
136 params: unknown,
137 args: McpRouterArgs
138): Promise<unknown> {
139 switch (method) {
140 case "initialize":
141 return handleInitialize();
142 case "notifications/initialized":
143 // Client → server notification, no response.
144 return null;
145 case "tools/list":
146 return handleToolsList(args.tools);
147 case "tools/call":
148 return handleToolsCall(params, args);
149 case "ping":
150 return {};
151 default:
152 throw new McpError(
153 ERR_METHOD_NOT_FOUND,
154 `Method not supported: ${method}`
155 );
156 }
157}
158
159function handleInitialize() {
160 return {
161 protocolVersion: MCP_PROTOCOL_VERSION,
162 capabilities: {
163 tools: { listChanged: false },
164 },
165 serverInfo: {
166 name: MCP_SERVER_NAME,
167 version: MCP_SERVER_VERSION,
168 },
169 };
170}
171
172function handleToolsList(
173 tools: Record<string, McpToolHandler>
174): { tools: McpTool[] } {
175 return {
176 tools: Object.values(tools).map((t) => t.tool),
177 };
178}
179
180async function handleToolsCall(
181 params: unknown,
182 args: McpRouterArgs
183): Promise<unknown> {
184 if (!params || typeof params !== "object") {
185 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires {name, arguments}");
186 }
187 const p = params as { name?: unknown; arguments?: unknown };
188 const name = typeof p.name === "string" ? p.name : "";
189 if (!name) {
190 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires `name`");
191 }
192 const handler = args.tools[name];
193 if (!handler) {
194 throw new McpError(ERR_METHOD_NOT_FOUND, `Unknown tool: ${name}`);
195 }
196 const toolArgs =
197 p.arguments && typeof p.arguments === "object"
198 ? (p.arguments as Record<string, unknown>)
199 : {};
200 // The MCP tools/call result shape is `{ content: [{type, text}], isError? }`.
201 // Tool handlers may either return that shape directly or just a value
202 // we wrap. Errors from handlers re-throw as McpError so the wrapper
203 // can re-shape them.
204 const out = await handler.run(toolArgs, args.ctx);
205 if (out && typeof out === "object" && Array.isArray((out as any).content)) {
206 return out;
207 }
208 return {
209 content: [
210 {
211 type: "text",
212 text: typeof out === "string" ? out : JSON.stringify(out, null, 2),
213 },
214 ],
215 };
216}