Skip to content

Stateless StreamableHTTPServerTransport: non-initialize requests return 500 with empty body when transport is reused (regression vs 1.24.3) #1994

@PhilBrowneDev

Description

@PhilBrowneDev

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)

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions