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; | |
| 0feb720 | 57 | /** |
| 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[]; | |
| 2c2163e | 64 | }; |
| 65 | ||
| 66 | export type McpRouterArgs = { | |
| 67 | ctx: McpContext; | |
| 68 | tools: Record<string, McpToolHandler>; | |
| 69 | }; | |
| 70 | ||
| 71 | function 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 | */ | |
| 85 | export 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 | ||
| 124 | export 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 | ||
| 134 | async 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 | ||
| 159 | function 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 | ||
| 172 | function handleToolsList( | |
| 173 | tools: Record<string, McpToolHandler> | |
| 174 | ): { tools: McpTool[] } { | |
| 175 | return { | |
| 176 | tools: Object.values(tools).map((t) => t.tool), | |
| 177 | }; | |
| 178 | } | |
| 179 | ||
| 180 | async 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 | } |