fix(mcp): re-check bearer on established HTTP sessions

Session lookup returned early without Authorization, so a missing or
wrong token still 200'd after initialize. Bind each session to the
init key and 401 on mismatch.
This commit is contained in:
Aakash Kattelu 2026-09-02 10:24:16 -04:00
parent c154f421db
commit daf1c33a47
4 changed files with 87 additions and 11 deletions

View File

@ -93,8 +93,9 @@ bunx mcp-remote http://127.0.0.1:3000 \
--header "Authorization:Bearer <key>"
```
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

56
mcp/src/http.test.ts Normal file
View File

@ -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<string, string>, 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);
});

View File

@ -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<string, Session>();
@ -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<Response> {
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<Response> {
);
}
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<Response> {
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 = () => {

View File

@ -10,5 +10,5 @@
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/**/*.test.ts"]
}