Repository navigation
Description
What happened?
StreamableHTTPClientTransport's standby GET/SSE stream reconnects forever
(~once per second, indefinitely) when the upstream server gracefully idle-closes
the stream — which is spec-compliant server behavior, not an error.
maxRetries (default 2) never trips because the reconnection attempt counter is
reset to 0 on every graceful disconnect instead of persisting for the stream's
lifetime.
Root cause (packages/client/src/client/streamableHttp.ts, main):
_handleSseStreamhandles a graceful server-side disconnect by calling
this._scheduleReconnection(options, 0)— the attempt count is hardcoded0._scheduleReconnectiononly advances the counter inside the failed-attempt
.catch()(this._scheduleReconnection(options, attemptCount + 1)).- So a successful reconnect that the server then idle-closes again re-enters
_handleSseStreamand schedules with0once more. attemptCounttherefore never grows past 0 across repeated idle-close/reopen
cycles;maxRetriesandreconnectionDelayGrowFactorare effectively dead
for this case, and the transport loops atinitialReconnectionDelay(1000ms)
for the life of the process.
Any upstream MCP server whose transport idle-closes the standby SSE/GET stream
triggers this. Each reconnect re-runs the authenticated-fetch path, so it also
produces continuous auth/token churn for no functional benefit.
Reproduced on @modelcontextprotocol/[email protected]; confirmed still present on main / V2 SDK.
What did you expect?
The attempt counter should persist per-stream-lifetime so that repeated
graceful-close/reopen cycles are bounded by maxRetries. After maxRetries
consecutive idle-close reconnects with no useful traffic, the transport should
stop reconnecting and surface onerror ("Maximum reconnection attempts exceeded")
rather than reconnecting indefinitely.
Equivalently: a successful-connect-then-graceful-idle-close should not be treated
as identical to a brand-new first connection for retry-accounting purposes.
Code to reproduce
Point a `StreamableHTTPClientTransport` at any MCP server that opens the standby
GET/SSE stream and then gracefully closes it when idle (spec-compliant), and
observe the reconnect loop:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(new URL("https://your-mcp-server/mcp"));
const client = new Client({ name: "repro", version: "1.0.0" });
transport.onerror = (e) => console.log("onerror:", e.message);
transport.onclose = () => console.log("onclose");
await client.connect(transport);
// Leave the process idle. The server gracefully closes the standby GET/SSE
// stream when idle; the SDK reconnects at ~initialReconnectionDelay (1000ms)
// and repeats forever. `maxRetries` (default 2) is never reached and
// `onerror`/`onclose` are never called, because each successful reconnect
// resets the attempt count to 0 via _scheduleReconnection(options, 0).SDK version
No response
Area
Client
Activity
Ben-Home commentedon Aug 17, 2026
Ben-Home commentedon Aug 18, 2026
Ben-Home commentedon Aug 20, 2026
Impactfulcanine commentedon Nov 3, 2026
Real-world impact report, plus a server pattern that triggers this immediately rather than on idle-close.
Trigger. A stateless Streamable HTTP server (
sessionIdGenerator: undefined,enableJsonResponse: true) that creates a transport per request and closes it in afinallyblock, with GET routed to the same handler. GET returns 200text/event-streamand the stream ends right away, so the client hits the graceful-close path described above on every cycle.Impact. The client was Claude Code (user-agent
claude-code/2.1.309 (claude-desktop, agent-sdk/0.3.309)) with this server configured at user scope, so every open session and sub-agent appeared to hold its own standby GET loop. Over 7 days the serverless host recorded about 5.7 million GETs to the MCP endpoint that returned 200, all from a single machine, versus a few hundred POSTs. Each GET is a function invocation plus log events. We didn't see any error or warning in the client UI.Local repro (
@modelcontextprotocol/sdk1.32.1, one client, 15 s window after connect, tools listed and one tool called in both runs):Allow: POST, DELETEWorkaround for server authors. If the server never sends server-initiated messages, answer GET with 405. The client already treats 405 as "no SSE stream" and stops. Client-side, the persistent per-stream attempt counter proposed above would have capped this at
maxRetries.