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:
parent
c154f421db
commit
daf1c33a47
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@
|
|||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue