diff --git a/mcp/README.md b/mcp/README.md index fcbad17c..89bfa0a0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -93,8 +93,9 @@ bunx mcp-remote http://127.0.0.1:3000 \ --header "Authorization:Bearer " ``` -Auth is the `Authorization: Bearer` header (same as the Worker). Optional -`X-Honcho-Workspace-ID` fills `workspace_id` when the tool argument is omitted. +Auth is the `Authorization: Bearer` header (same as the Worker). Established +sessions still require that same bearer. Optional `X-Honcho-Workspace-ID` +fills `workspace_id` when the tool argument is omitted. `HOST` defaults to `0.0.0.0`, `PORT` to `3000`. `GET /health` is unauthenticated. MCP is served at `/` and `/mcp`. Idle sessions expire after diff --git a/mcp/src/http.test.ts b/mcp/src/http.test.ts new file mode 100644 index 00000000..385f84eb --- /dev/null +++ b/mcp/src/http.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { fetch } from "./http.ts"; + +const origin = "http://127.0.0.1:3000"; + +const initializeBody = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.0" }, + }, +}; + +const pingBody = { jsonrpc: "2.0", id: 2, method: "ping" }; + +function mcpPost(headers: Record, body: unknown) { + return fetch( + new Request(`${origin}/mcp`, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }), + ); +} + +test("established sessions require the initialize bearer", async () => { + const init = await mcpPost( + { Authorization: "Bearer key-a" }, + initializeBody, + ); + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const missing = await mcpPost({ "mcp-session-id": sessionId! }, pingBody); + expect(missing.status).toBe(401); + + const wrong = await mcpPost( + { Authorization: "Bearer key-b", "mcp-session-id": sessionId! }, + pingBody, + ); + expect(wrong.status).toBe(401); + + const ok = await mcpPost( + { Authorization: "Bearer key-a", "mcp-session-id": sessionId! }, + pingBody, + ); + expect(ok.status).toBe(200); +}); diff --git a/mcp/src/http.ts b/mcp/src/http.ts index 8675074d..a33bd4a8 100644 --- a/mcp/src/http.ts +++ b/mcp/src/http.ts @@ -5,6 +5,7 @@ import { createUnscopedClient, parseConfig, type Env, + type HonchoConfig, } from "./config.js"; import { createServer } from "./server.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -40,6 +41,7 @@ type Session = { transport: WebStandardStreamableHTTPServerTransport; server: McpServer; lastSeen: number; + apiKey: string; }; const sessions = new Map(); @@ -106,6 +108,15 @@ function configForRequest(request: Request) { return parseConfig(request, envBindings()); } +function configOrUnauthorized(request: Request): HonchoConfig | Response { + try { + return configForRequest(request); + } catch (e) { + const message = e instanceof Error ? e.message : "Invalid request"; + return unauthorized(request, message); + } +} + function unauthorized(request: Request, message: string): Response { const resourceMetadata = `${new URL(request.url).origin}${PROTECTED_RESOURCE_PATH}`; return jsonResponse( @@ -123,6 +134,14 @@ async function handleMcp(request: Request): Promise { if (sessionId) { const existing = sessions.get(sessionId); if (existing) { + const config = configOrUnauthorized(request); + if (config instanceof Response) return config; + if (config.apiKey !== existing.apiKey) { + return unauthorized( + request, + "Authorization does not match this session.", + ); + } existing.lastSeen = Date.now(); return withCors(await existing.transport.handleRequest(request)); } @@ -171,13 +190,8 @@ async function handleMcp(request: Request): Promise { ); } - let config; - try { - config = configForRequest(request); - } catch (e) { - const message = e instanceof Error ? e.message : "Invalid request"; - return unauthorized(request, message); - } + const config = configOrUnauthorized(request); + if (config instanceof Response) return config; const server = createServer({ config, @@ -203,7 +217,12 @@ async function handleMcp(request: Request): Promise { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (id) => { - sessions.set(id, { transport, server, lastSeen: Date.now() }); + sessions.set(id, { + transport, + server, + lastSeen: Date.now(), + apiKey: config.apiKey, + }); }, }); transport.onclose = () => { diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json index 8ca7bfae..8bebbfa9 100644 --- a/mcp/tsconfig.json +++ b/mcp/tsconfig.json @@ -10,5 +10,5 @@ "types": ["@cloudflare/workers-types"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/**/*.test.ts"] }