You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In StreamableHTTPTransport.handle_get_stream, when an SSE stream disconnects or closes (server-side EOF/backpressure), attempt was reset to 0 upon exit. If an upstream server disconnects continuously, the client bypasses MAX_RECONNECTION_ATTEMPTS and enters an infinite reconnection loop without backoff jitter.
Expected Behavior
The client should respect MAX_RECONNECTION_ATTEMPTS on stream disconnects and apply exponential backoff to prevent thundering-herd storms on upstream MCP servers.
Confirmed the same failure mode on our side: when the upstream closes the SSE stream (EOF or backpressure), the attempt counter resetting on exit makes MAX_RECONNECTION_ATTEMPTS a no-op for the stream path, and the client keeps hammering the server with no backoff.
The fix has two parts that should land together:
Attempt accounting on the disconnect path. Do not reset the counter when the stream closes; reset it only when a reconnection has established a healthy stream and received at least one message (or an initialize response, depending on the transport state). The current code resets on exit from handle_get_stream, which is exactly the wrong place. A useful invariant: the counter should survive every exit from the read loop and only be cleared by successful message receipt, not by connection teardown.
Jittered exponential backoff on every reconnect, including the first retry. If the upstream is down, the first reconnect attempt should still wait rather than firing immediately, otherwise a fleet of clients all observe the disconnect at once and reconnect in a thundering herd. Standard pattern: base * 2**attempt with full jitter (random between 0 and the computed delay) and a cap (30-60s) so a long outage does not produce absurd sleeps. The existing MAX_RECONNECTION_ATTEMPTS should bound the loop, and once exhausted the transport should surface a terminal error instead of silently retrying forever.
For the test: a server that accepts the stream then closes it immediately (or after N events) will reproduce this deterministically. Assert that the client stops after MAX_RECONNECTION_ATTEMPTS and that consecutive attempts are spaced by at least the jittered backoff minimum. That test would have caught this bug because it exercises the exit path rather than a timeout path.
One thing to watch in the implementation: if the transport is shared by multiple concurrent requests, the attempt counter and backoff state need to live on the transport, not on the request scope, otherwise a single disconnect can reset the state of the whole pool. We hit this exact pattern with pooled upstream connections in our connector runtime at corpusiq.io and the fix that held was per-connection reconnect state with a shared jittered scheduler, never per-request counters.
I can reproduce the reconnect-loop behavior described here. I’d be interested in working on a focused fix that keeps the reconnection attempt count bounded across SSE disconnects, applies the existing backoff behavior, and adds regression coverage for repeated disconnects. Happy to work on this if the approach aligns with what you have in mind.
I think this is the same bug as #3257. In handle_get_stream (src/mcp/client/streamable_http.py, around L252-253 on main and L310-311 on v1.x), a GET stream that ends without an exception sets attempt = 0. So a server that keeps closing the stream cleanly never pushes the counter toward MAX_RECONNECTION_ATTEMPTS, and the client reconnects forever. #3087 (currently a draft) already targets this by not resetting the counter on empty connections, so it may be worth tracking or reviewing that PR rather than opening a separate fix. @jagadeepmamidi, if you want to help, the regression test you described would probably be a useful addition there.
Activity
Ben-Home commentedon Aug 21, 2026
Confirmed the same failure mode on our side: when the upstream closes the SSE stream (EOF or backpressure), the attempt counter resetting on exit makes MAX_RECONNECTION_ATTEMPTS a no-op for the stream path, and the client keeps hammering the server with no backoff.
The fix has two parts that should land together:
Attempt accounting on the disconnect path. Do not reset the counter when the stream closes; reset it only when a reconnection has established a healthy stream and received at least one message (or an initialize response, depending on the transport state). The current code resets on exit from handle_get_stream, which is exactly the wrong place. A useful invariant: the counter should survive every exit from the read loop and only be cleared by successful message receipt, not by connection teardown.
Jittered exponential backoff on every reconnect, including the first retry. If the upstream is down, the first reconnect attempt should still wait rather than firing immediately, otherwise a fleet of clients all observe the disconnect at once and reconnect in a thundering herd. Standard pattern: base * 2**attempt with full jitter (random between 0 and the computed delay) and a cap (30-60s) so a long outage does not produce absurd sleeps. The existing MAX_RECONNECTION_ATTEMPTS should bound the loop, and once exhausted the transport should surface a terminal error instead of silently retrying forever.
For the test: a server that accepts the stream then closes it immediately (or after N events) will reproduce this deterministically. Assert that the client stops after MAX_RECONNECTION_ATTEMPTS and that consecutive attempts are spaced by at least the jittered backoff minimum. That test would have caught this bug because it exercises the exit path rather than a timeout path.
One thing to watch in the implementation: if the transport is shared by multiple concurrent requests, the attempt counter and backoff state need to live on the transport, not on the request scope, otherwise a single disconnect can reset the state of the whole pool. We hit this exact pattern with pooled upstream connections in our connector runtime at corpusiq.io and the fix that held was per-connection reconnect state with a shared jittered scheduler, never per-request counters.
jagadeepmamidi commentedon Sep 1, 2026
I can reproduce the reconnect-loop behavior described here. I’d be interested in working on a focused fix that keeps the reconnection attempt count bounded across SSE disconnects, applies the existing backoff behavior, and adds regression coverage for repeated disconnects. Happy to work on this if the approach aligns with what you have in mind.
tk1475 commentedon Sep 25, 2026
I think this is the same bug as #3257. In
handle_get_stream(src/mcp/client/streamable_http.py, around L252-253 on main and L310-311 on v1.x), a GET stream that ends without an exception setsattempt = 0. So a server that keeps closing the stream cleanly never pushes the counter towardMAX_RECONNECTION_ATTEMPTS, and the client reconnects forever. #3087 (currently a draft) already targets this by not resetting the counter on empty connections, so it may be worth tracking or reviewing that PR rather than opening a separate fix. @jagadeepmamidi, if you want to help, the regression test you described would probably be a useful addition there.Kludex commentedon Oct 14, 2026
Duplicate of #3257.