From 775e6a3f65edd826831fde4cb6b88c7af9265b25 Mon Sep 17 00:00:00 2001 From: Conal Mullan Date: Tue, 11 Aug 2026 16:43:16 +0100 Subject: [PATCH] fix: return 404 for unknown session IDs so clients re-initialize The MCP Streamable HTTP spec requires 404 for a request carrying an unknown or expired Mcp-Session-Id: it is the client's cue to start a new session with a fresh InitializeRequest. We returned 400, which clients treat as a hard error, leaving them wedged until a human reconnects. Hit this immediately after the dev deploy: the container restarted, and the connected claude.ai client could not recover on its own. It matters more now that idle sessions are swept - every session idle past SESSION_IDLE_TIMEOUT_MS hits this path, so without the correct status code the sweep would strand clients rather than let them reconnect transparently. A missing (rather than unknown) session ID still returns 400, since no re-initialize cue applies. Verified against a locally built server: POST unknown session -> 404 POST no session, non-init -> 400 GET unknown session -> 404 GET no session -> 400 --- src/transports/http.ts | 53 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/transports/http.ts b/src/transports/http.ts index eeadccb..db9ff27 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -627,15 +627,28 @@ export async function startHttpServer( await server.connect(transport); logger.debug("Server connected to transport"); + } else if (mcpSessionId) { + // Unknown or expired session. The spec requires 404 here: it is the + // client's cue to re-initialize with a fresh session. Returning 400 + // wedges the client until a human reconnects it, which matters now + // that idle sessions are swept (see session-registry.ts). + res.status(404).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found. Session may have expired.", + }, + id: null, + }); + return; } else { - // Invalid request - no session ID and not an initialize request + // No session ID and not an initialize request res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, - message: mcpSessionId - ? "Session not found. Session may have expired." - : "Invalid request. First request must be an initialize request.", + message: + "Invalid request. First request must be an initialize request.", }, id: null, }); @@ -668,7 +681,7 @@ export async function startHttpServer( const handleMcpGet = async (req: Request, res: Response) => { const sessionId = req.headers["mcp-session-id"] as string; - if (!sessionId || !transports.has(sessionId)) { + if (!sessionId) { res.status(400).json({ jsonrpc: "2.0", error: { @@ -680,6 +693,20 @@ export async function startHttpServer( return; } + if (!transports.has(sessionId)) { + // 404 tells the client to re-initialize rather than treating this as a + // hard failure - see the POST handler for why this matters. + res.status(404).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found. Session may have expired.", + }, + id: null, + }); + return; + } + const transport = transports.get(sessionId)!; // An SSE stream can stay open for hours with no other traffic. Count it as @@ -703,7 +730,7 @@ export async function startHttpServer( const handleMcpDelete = async (req: Request, res: Response) => { const sessionId = req.headers["mcp-session-id"] as string; - if (!sessionId || !transports.has(sessionId)) { + if (!sessionId) { res.status(400).json({ jsonrpc: "2.0", error: { @@ -715,6 +742,20 @@ export async function startHttpServer( return; } + if (!transports.has(sessionId)) { + // 404 tells the client to re-initialize rather than treating this as a + // hard failure - see the POST handler for why this matters. + res.status(404).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found. Session may have expired.", + }, + id: null, + }); + return; + } + const transport = transports.get(sessionId)!; transports.touch(sessionId); const apiKey = (req as any).apiKey;