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):
_handleSseStream handles a graceful server-side disconnect by calling
this._scheduleReconnection(options, 0) — the attempt count is hardcoded 0.
_scheduleReconnection only 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
_handleSseStream and schedules with 0 once more.
attemptCount therefore never grows past 0 across repeated idle-close/reopen
cycles; maxRetries and reconnectionDelayGrowFactor are effectively dead
for this case, and the transport loops at initialReconnectionDelay (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/sdk@1.29.0; 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
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 isreset to
0on every graceful disconnect instead of persisting for the stream'slifetime.
Root cause (
packages/client/src/client/streamableHttp.ts,main):_handleSseStreamhandles a graceful server-side disconnect by callingthis._scheduleReconnection(options, 0)— the attempt count is hardcoded0._scheduleReconnectiononly advances the counter inside the failed-attempt.catch()(this._scheduleReconnection(options, attemptCount + 1))._handleSseStreamand schedules with0once more.attemptCounttherefore never grows past 0 across repeated idle-close/reopencycles;
maxRetriesandreconnectionDelayGrowFactorare effectively deadfor this case, and the transport loops at
initialReconnectionDelay(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/sdk@1.29.0; confirmed still present onmain/ 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. AftermaxRetriesconsecutive 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
SDK version
No response
Area
Client