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.
| 2c2163e | 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 | ||
| 22 | import type { McpToolHandler, McpTool } from "./mcp-tools"; | |
| 23 | ||
| 24 | export const MCP_PROTOCOL_VERSION = "2025-06-18"; | |
| 25 | export const MCP_SERVER_NAME = "gluecron"; | |
| 26 | export const MCP_SERVER_VERSION = "1.0"; | |
| 27 | ||
| 28 | export type JsonRpcRequest = { | |
| 29 | jsonrpc: "2.0"; | |
| 30 | id?: string | number | null; | |
| 31 | method: string; | |
| 32 | params?: unknown; | |
| 33 | }; | |
| 34 | ||
| 35 | export 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 | |
| 48 | export const ERR_PARSE = -32700; | |
| 49 | export const ERR_INVALID_REQUEST = -32600; | |
| 50 | export const ERR_METHOD_NOT_FOUND = -32601; | |
| 51 | export const ERR_INVALID_PARAMS = -32602; | |
| 52 | export const ERR_INTERNAL = -32603; | |
| 53 | ||
| 54 | export type McpContext = { | |
| 55 | /** Authenticated user id; null for anonymous (only allowed for some calls). */ | |
| 56 | userId: string | null; | |
| 57 | }; | |
| 58 | ||
| 59 | export type McpRouterArgs = { | |
| 60 | ctx: McpContext; | |
| 61 | tools: Record<string, McpToolHandler>; | |
| 62 | }; | |
| 63 | ||
| 64 | function 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 | */ | |
| 78 | export 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 | ||
| 117 | export 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 | ||
| 127 | async 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 | ||
| 152 | function 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 | ||
| 165 | function handleToolsList( | |
| 166 | tools: Record<string, McpToolHandler> | |
| 167 | ): { tools: McpTool[] } { | |
| 168 | return { | |
| 169 | tools: Object.values(tools).map((t) => t.tool), | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | async 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 | } |