modelcontextprotocol / modelcontextprotocol/typescript-sdk

[v2] StreamableHTTPClientTransport standby GET/SSE stream reconnects forever when the server gracefully idle-closes it ( maxRetries  never trips)

Open
#2,682 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

v1 v2
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

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):

  1. _handleSseStream handles a graceful server-side disconnect by calling
    this._scheduleReconnection(options, 0) — the attempt count is hardcoded 0.
  2. _scheduleReconnection only advances the counter inside the failed-attempt
    .catch() (this._scheduleReconnection(options, attemptCount + 1)).
  3. So a successful reconnect that the server then idle-closes again re-enters
    _handleSseStream and schedules with 0 once more.
  4. 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/client/src/client/streamableHttp.ts, reading _handleSseStream and _scheduleReconnection together. Run the provided reproduction against a server that gracefully idle-closes the standby GET/SSE stream. Done means repeated graceful-close/reconnect cycles advance the per-stream attempt count, stop at maxRetries, and surface the documented onerror instead of reconnecting indefinitely.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.