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.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.tsBlame245 lines · 2 contributors
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
3a15219ccantynz-alt66export type McpResourceProvider = {
67 list: (ctx: McpContext) => Promise<{ resources: unknown[] }>;
68 read: (uri: string, ctx: McpContext) => Promise<{ contents: unknown[] }>;
69};
70
2c2163eClaude71export type McpRouterArgs = {
72 ctx: McpContext;
73 tools: Record<string, McpToolHandler>;
3a15219ccantynz-alt74 /** Optional resource provider — enables resources/list + resources/read. */
75 resources?: McpResourceProvider;
2c2163eClaude76};
77
78function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
79 return (
80 !!v &&
81 typeof v === "object" &&
82 (v as JsonRpcRequest).jsonrpc === "2.0" &&
83 typeof (v as JsonRpcRequest).method === "string"
84 );
85}
86
87/**
88 * Route a single JSON-RPC request. Returns the response shape (or null
89 * for notifications, which have no `id`). Never throws — internal
90 * exceptions are caught and re-shaped as `-32603 internal error`.
91 */
92export async function routeMcpRequest(
93 req: unknown,
94 args: McpRouterArgs
95): Promise<JsonRpcResponse | null> {
96 if (!isJsonRpcRequest(req)) {
97 return {
98 jsonrpc: "2.0",
99 id: null,
100 error: { code: ERR_INVALID_REQUEST, message: "Invalid JSON-RPC request" },
101 };
102 }
103
104 const id = req.id ?? null;
105 const isNotification = req.id === undefined;
106
107 try {
108 const result = await dispatch(req.method, req.params, args);
109 if (isNotification) return null;
110 return { jsonrpc: "2.0", id, result };
111 } catch (err) {
112 if (isNotification) return null;
113 if (err instanceof McpError) {
114 return {
115 jsonrpc: "2.0",
116 id,
117 error: { code: err.code, message: err.message, data: err.data },
118 };
119 }
120 return {
121 jsonrpc: "2.0",
122 id,
123 error: {
124 code: ERR_INTERNAL,
125 message: err instanceof Error ? err.message : "internal error",
126 },
127 };
128 }
129}
130
131export class McpError extends Error {
132 code: number;
133 data?: unknown;
134 constructor(code: number, message: string, data?: unknown) {
135 super(message);
136 this.code = code;
137 this.data = data;
138 }
139}
140
141async function dispatch(
142 method: string,
143 params: unknown,
144 args: McpRouterArgs
145): Promise<unknown> {
146 switch (method) {
147 case "initialize":
3a15219ccantynz-alt148 return handleInitialize(args);
2c2163eClaude149 case "notifications/initialized":
150 // Client → server notification, no response.
151 return null;
152 case "tools/list":
153 return handleToolsList(args.tools);
154 case "tools/call":
155 return handleToolsCall(params, args);
3a15219ccantynz-alt156 case "resources/list":
157 if (!args.resources) return { resources: [] };
158 return args.resources.list(args.ctx);
159 case "resources/read": {
160 if (!args.resources) {
161 throw new McpError(ERR_METHOD_NOT_FOUND, "resources not supported");
162 }
163 const uri =
164 params && typeof params === "object"
165 ? (params as { uri?: unknown }).uri
166 : undefined;
167 if (typeof uri !== "string" || !uri) {
168 throw new McpError(ERR_INVALID_PARAMS, "resources/read requires a string 'uri'");
169 }
170 return args.resources.read(uri, args.ctx);
171 }
2c2163eClaude172 case "ping":
173 return {};
174 default:
175 throw new McpError(
176 ERR_METHOD_NOT_FOUND,
177 `Method not supported: ${method}`
178 );
179 }
180}
181
3a15219ccantynz-alt182function handleInitialize(args: McpRouterArgs) {
183 const capabilities: Record<string, unknown> = {
184 tools: { listChanged: false },
185 };
186 // Only advertise resources when a provider is wired, so clients (Claude
187 // Desktop) show the repo picker exactly when we can actually serve it.
188 if (args.resources) {
189 capabilities.resources = { subscribe: false, listChanged: false };
190 }
2c2163eClaude191 return {
192 protocolVersion: MCP_PROTOCOL_VERSION,
3a15219ccantynz-alt193 capabilities,
2c2163eClaude194 serverInfo: {
195 name: MCP_SERVER_NAME,
196 version: MCP_SERVER_VERSION,
197 },
198 };
199}
200
201function handleToolsList(
202 tools: Record<string, McpToolHandler>
203): { tools: McpTool[] } {
204 return {
205 tools: Object.values(tools).map((t) => t.tool),
206 };
207}
208
209async function handleToolsCall(
210 params: unknown,
211 args: McpRouterArgs
212): Promise<unknown> {
213 if (!params || typeof params !== "object") {
214 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires {name, arguments}");
215 }
216 const p = params as { name?: unknown; arguments?: unknown };
217 const name = typeof p.name === "string" ? p.name : "";
218 if (!name) {
219 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires `name`");
220 }
221 const handler = args.tools[name];
222 if (!handler) {
223 throw new McpError(ERR_METHOD_NOT_FOUND, `Unknown tool: ${name}`);
224 }
225 const toolArgs =
226 p.arguments && typeof p.arguments === "object"
227 ? (p.arguments as Record<string, unknown>)
228 : {};
229 // The MCP tools/call result shape is `{ content: [{type, text}], isError? }`.
230 // Tool handlers may either return that shape directly or just a value
231 // we wrap. Errors from handlers re-throw as McpError so the wrapper
232 // can re-shape them.
233 const out = await handler.run(toolArgs, args.ctx);
234 if (out && typeof out === "object" && Array.isArray((out as any).content)) {
235 return out;
236 }
237 return {
238 content: [
239 {
240 type: "text",
241 text: typeof out === "string" ? out : JSON.stringify(out, null, 2),
242 },
243 ],
244 };
245}