Summary
In stateless mode (new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })), the first POST that contains an initialize request succeeds, but every subsequent POST on the same transport instance (tools/list, tools/call, notifications/initialized, …) returns HTTP 500 with Content-Type: text/plain and an empty body. transport.onerror is never invoked, so the underlying exception is swallowed.
This is a regression compared to 1.24.3, where the same code path returns 200 with the expected SSE response.
Versions
- Broken:
@modelcontextprotocol/sdk@1.29.0 (also reproduces on the published 1.25.x and 1.26.x/1.27.x/1.28.x lines per source inspection)
- Last known good:
@modelcontextprotocol/sdk@1.24.3
- Node
v22.22.2, macOS
What changed
1.25.0 rewrote server/streamableHttp.js to be a thin wrapper around the new WebStandardStreamableHTTPServerTransport, bridged via @hono/node-server's getRequestListener. In 1.24.3 the Node transport implemented handleRequest directly. The new bridge constructs a fresh getRequestListener(...) per call and any exception that escapes inside that listener becomes a generic Hono 500 Internal Server Error with text/plain body — bypassing this.onerror?.(...) and the SDK's own createJsonErrorResponse.
The transport-reused stateless pattern (cache one transport per authenticated user, reuse it across that user's requests) was a documented and supported usage in 1.24.x. After 1.25.0 it appears to be broken: only the transport's first non-initialize POST returns a real response.
Minimal repro
// repro.mjs — run from a project with @modelcontextprotocol/sdk@1.29.0 installed
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import http from "node:http";
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
transport.onerror = (e) => console.error("[repro] transport.onerror:", e?.stack || e);
const server = new McpServer({ name: "repro", version: "0.0.1" });
server.registerTool(
"ping",
{ title: "ping", description: "ping", inputSchema: { x: z.string().optional() } },
async () => ({ content: [{ type: "text", text: "pong" }] }),
);
await server.connect(transport);
http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
try {
await transport.handleRequest(req, res, body);
} catch (e) {
console.error("[repro] caught from handleRequest:", e?.stack || e);
if (!res.headersSent) { res.statusCode = 599; res.end("caught: " + (e?.message || e)); }
}
}).listen(3099, () => console.log("repro listening on :3099"));
# 1) initialize — works
curl -s -o /tmp/init.body -w "init=%{http_code}\n" \
-X POST -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"r","version":"0"}}}' \
http://localhost:3099/
# 2) tools/list on the same transport — 500 with empty body, onerror never fires
curl -s -o /tmp/tl.body -w "tools=%{http_code}\n" \
-X POST -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
http://localhost:3099/
Observed (1.29.0)
init=200
event: message
data: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"repro","version":"0.0.1"}},"jsonrpc":"2.0","id":1}
tools=500
(body empty; Content-Type: text/plain; charset=UTF-8; no `[repro] transport.onerror` line; no `[repro] caught from handleRequest` line)
Expected (and observed in 1.24.3)
tools=200
event: message
data: {"result":{"tools":[ … ]},"jsonrpc":"2.0","id":2}
The same repro on @modelcontextprotocol/sdk@1.24.3 (no other code change, just downgrade and npm i) returns 200 and the registered tool list.
Why this matters
Per-user transport caching is a natural deployment pattern for any multi-tenant MCP server (one authenticated user → one cached McpServer + StreamableHTTPServerTransport keyed off the user's identity, reused across that user's requests so tool state, API clients, etc. live for the user's session). Under 1.25+ that pattern only handles the very first request per user — every subsequent tool call silently 500s.
Forcing a new transport per request is workable for tools/list but defeats the design entirely for any server that holds per-user state on the McpServer (cached upstream API clients, prompt context, in-flight subscriptions, etc.).
Suggested fixes (in order of likely effort)
- Within
server/streamableHttp.js, wrap the inner getRequestListener(...) callback in a try/catch that invokes this.onerror?.(...) and writes a JSON-RPC 500 so the failure is at least visible and parseable.
- Investigate why the
WebStandardStreamableHTTPServerTransport path throws on the second request when sessionIdGenerator: undefined and the transport instance is reused — _initialized is already true, validateSession short-circuits in stateless mode, and validateProtocolVersion accepts a missing mcp-protocol-version header. The thrown exception itself is swallowed by Hono and wasn't recoverable from onerror, so the root cause needs reproducing inside the SDK's own tests.
- Document the supported lifecycle for stateless transports (one per request vs. reusable across requests) in the JSDoc on
StreamableHTTPServerTransport. The current JSDoc (In stateless mode: – No Session ID is included in any responses – No session validation is performed) reads as if reuse is fine.
Happy to test patches against the repro.
Workaround
Pin to 1.24.3 until this is resolved.
Summary
In stateless mode (
new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })), the first POST that contains aninitializerequest succeeds, but every subsequent POST on the same transport instance (tools/list,tools/call,notifications/initialized, …) returnsHTTP 500withContent-Type: text/plainand an empty body.transport.onerroris never invoked, so the underlying exception is swallowed.This is a regression compared to
1.24.3, where the same code path returns200with the expected SSE response.Versions
@modelcontextprotocol/sdk@1.29.0(also reproduces on the published1.25.xand1.26.x/1.27.x/1.28.xlines per source inspection)@modelcontextprotocol/sdk@1.24.3v22.22.2, macOSWhat changed
1.25.0rewroteserver/streamableHttp.jsto be a thin wrapper around the newWebStandardStreamableHTTPServerTransport, bridged via@hono/node-server'sgetRequestListener. In1.24.3the Node transport implementedhandleRequestdirectly. The new bridge constructs a freshgetRequestListener(...)per call and any exception that escapes inside that listener becomes a generic Hono500 Internal Server Errorwithtext/plainbody — bypassingthis.onerror?.(...)and the SDK's owncreateJsonErrorResponse.The transport-reused stateless pattern (cache one transport per authenticated user, reuse it across that user's requests) was a documented and supported usage in
1.24.x. After1.25.0it appears to be broken: only the transport's first non-initializePOST returns a real response.Minimal repro
Observed (1.29.0)
Expected (and observed in 1.24.3)
The same repro on
@modelcontextprotocol/sdk@1.24.3(no other code change, just downgrade andnpm i) returns200and the registered tool list.Why this matters
Per-user transport caching is a natural deployment pattern for any multi-tenant MCP server (one authenticated user → one cached
McpServer+StreamableHTTPServerTransportkeyed off the user's identity, reused across that user's requests so tool state, API clients, etc. live for the user's session). Under1.25+that pattern only handles the very first request per user — every subsequent tool call silently 500s.Forcing a new transport per request is workable for
tools/listbut defeats the design entirely for any server that holds per-user state on theMcpServer(cached upstream API clients, prompt context, in-flight subscriptions, etc.).Suggested fixes (in order of likely effort)
server/streamableHttp.js, wrap the innergetRequestListener(...)callback in atry/catchthat invokesthis.onerror?.(...)and writes a JSON-RPC500so the failure is at least visible and parseable.WebStandardStreamableHTTPServerTransportpath throws on the second request whensessionIdGenerator: undefinedand the transport instance is reused —_initializedis alreadytrue,validateSessionshort-circuits in stateless mode, andvalidateProtocolVersionaccepts a missingmcp-protocol-versionheader. The thrown exception itself is swallowed by Hono and wasn't recoverable fromonerror, so the root cause needs reproducing inside the SDK's own tests.StreamableHTTPServerTransport. The current JSDoc (In stateless mode: – No Session ID is included in any responses – No session validation is performed) reads as if reuse is fine.Happy to test patches against the repro.
Workaround
Pin to
1.24.3until this is resolved.