modelcontextprotocol / modelcontextprotocol/typescript-sdk
Stateless StreamableHTTPServerTransport: non-initialize requests return 500 with empty body when transport is reused (regression vs 1.24.3)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
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 published1.25.xand1.26.x/1.27.x/1.28.xlines 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 innergetRequestListener(...)callback in atry/catchthat invokesthis.onerror?.(...)and writes a JSON-RPC500so the failure is at least visible and parseable. - Investigate why the
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. - 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with server/streamableHttp.js and the WebStandardStreamableHTTPServerTransport bridge, then run the provided minimal repro against the current SDK and 1.24.3. Trace the reused stateless transport's second POST and reproduce the failure in the SDK's own tests. Done means subsequent requests return the expected 200 SSE response and transport errors are no longer swallowed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100