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
StreamableHTTPTransport.handle_get_stream reconnects forever — once per second,
for the lifetime of the session — when the server accepts the standalone GET and
then ends the SSE stream without error. MAX_RECONNECTION_ATTEMPTS is meant to
bound the retry loop, but a clean stream close resets the attempt counter, so the
bound is never reached.
The server that triggers this is spec-conformant, not misbehaving: terminating
the stream immediately is an explicit permission — "The server MAY close the SSE
stream at any time" (2025-11-25
§Transports,
the last revision defining this mechanism). The spec section below shows that the
revision distinguishes closing the connection from terminating the stream, and
that the loop cannot tell them apart.
It is a single self-contained file — stdlib stub server plus the mcp client,
no external server and no reach into transport internals. Inlined below so it can
be run without leaving this page:
uv run --no-project --with 'mcp==2.0.0' python repro.py # bug
uv run --no-project --with 'mcp==2.0.0' python repro.py --mode 405 # control
repro.py (single file, stdlib + mcp)
Affected versions
Reproduced via streamable_http_client + ClientSession.initialize(), 5s window:
mcp
GET requests
Verdict
1.22.0
1
clean (no retry loop in this release)
1.23.0
5
unbounded
1.24.0
5
unbounded
1.28.1
5
unbounded
1.29.0
5
unbounded
2.0.0
5
unbounded
Introduced in 1.23.0 with the auto-reconnect loop; still present in 2.0.0. The
count scales linearly with the observation window — it never terminates.
Environment
Python 3.13.14 (CPython), Linux x86_64 (Ubuntu 24.04)
anyio 4.14.2
On 2.0.0 the HTTP stack is httpx2 / httpcore2 2.9.1; on the 1.2x line it is httpx 0.28.1 with httpx-sse 0.4.3
Nothing in the reproduction is platform-specific — the loop is paced by anyio.sleep against a localhost stub server.
Root cause
src/mcp/client/streamable_http.py, StreamableHTTPTransport.handle_get_stream
(quoted from 2.0.0):
The 1.2x line is the same logic with different plumbing
(aconnect_sse(client, "GET", ...) and event_source.aiter_sse()).
A server returning 200 + Content-Type: text/event-stream and then closing
makes the event iterator complete without raising. That's treated as "ended
normally", so attempt resets to 0 and neither the while condition nor the
guard is ever satisfied.
The two coverage pragmas are themselves evidence: # pragma: no branch on the while and # pragma: no cover on the termination guard assert that the test
suite never sees this loop exit. Both have been present in every affected
release, 1.23.0 through 2.0.0.
The reset is sensible in isolation — a stream that ran a while shouldn't be
penalised for earlier failures. The bug is that a stream yielding zero events
and closing immediately is indistinguishable from a productive one.
Expected vs actual
Expected: at most MAX_RECONNECTION_ATTEMPTS (2) GETs, then the background
task gives up. Actual: one GET per second, indefinitely.
Which terminations trigger it — and which don't
Whether the loop terminates depends entirely on how the response body ended —
not on whether the server is healthy. termination_modes.py in the repo measures
all five cases (identical on 1.28.1 and 2.0.0, 5-second window):
GET response ends by
Client sees
Last-Event-ID sent on reconnects
Loop
event with an id, then connection close
clean EOF
yes (4 of 4)
unbounded — but correctly polling
connection close, no Content-Length, no events
clean EOF
no
unbounded (the bug)
chunked, terminating 0\r\n\r\n, no events
clean EOF
no
unbounded (the bug)
chunked, RST with no terminating chunk
truncation error
no
terminates (2 GETs)
405 Method Not Allowed
HTTP error
no
terminates (2 GETs)
The first three rows are behaviourally identical — 5 GETs, one per second — yet
only the first is a conformant resumption, and the sole thing distinguishing it is a
value the SDK already tracks. That is the defect in one line: last_event_id holds
the answer and the retry logic never consults it.
The retry bound itself works — both error rows increment attempt and stop
correctly after 2 tries. That isolates the defect to the attempt = 0 reset
rather than to retrying.
Worth flagging for reproduction: an intermediary that kills a connection abruptly produces the benign truncation path. So "a proxy timed out my SSE
stream" is not by itself enough to see the bug — the body has to end in a
well-formed way, which is what a server deliberately ending an idle stream
does.
The client also cannot distinguish the two clean-EOF rows from a productive
stream at the protocol layer: SSE responses carry no Content-Length, so "ended
after zero events" and "ended after a thousand" are the same shape of completion.
The only per-response signal available is malformedness, and that case is already
handled correctly.
The spec defines three server signals; the loop collapses two of them
Which revision applies. The standalone GET stream exists in protocol revisions 2025-03-26 through 2025-11-25, and was removed in the current revision 2026-07-28 ("Removal of the GET stream
endpoint").
That scopes the bug but does not shrink it: the SDK's handshake path still
negotiates 2025-11-25 — ClientSession.initialize() sends LATEST_HANDSHAKE_VERSION, which is 2025-11-25 on 2.0.0, confirmed on the wire —
so handle_get_stream runs for every session against a server of that era. Quotes
below are therefore from 2025-11-25
§Transports,
the last revision that defines this mechanism.
Listening for Messages from the Server:
The server MUST either return Content-Type: text/event-stream in response
to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that
the server does not offer an SSE stream at this endpoint.
If the server initiates an SSE stream:
[…]
The server MAY close the SSE stream at any time.
If the server closes the connection without terminating the stream, it SHOULD follow the same polling behavior as described for POST requests:
sending a retry field and allowing the client to reconnect.
And the POST rules that clause 4 defers to, which define the priming mechanism:
The server SHOULD immediately send an SSE event consisting of an event ID
and an empty data field in order to prime the client to reconnect (using that
event ID as Last-Event-ID).
After the server has sent an SSE event with an event ID to the client, the
server MAY close the connection (without terminating the SSE stream) at
any time in order to avoid holding a long-lived connection. The client SHOULD then "poll" the SSE stream by attempting to reconnect.
The spec therefore gives a server three distinct things to say:
Server intent
Spec signal
Correct client response
"I offer no stream at this endpoint"
405
never ask again
"Come back — I closed the connection, the stream is alive"
an event carrying an id (plus retry), then close the connection
reconnect, sending Last-Event-ID
"Done — I terminated the stream"
end the response with the stream terminated
do not reconnect
Rows 2 and 3 are both "200, then a closed connection." What distinguishes them is whether an event carrying an id was ever sent — exactly the priming mechanism
the POST clause describes. handle_get_stream already tracks that (last_event_id),
but does not condition reconnection on it. So it treats row 3 as if it were row 2,
forever.
Two consequences worth noting:
The reconnect it issues is not a conformant resumption. Clause 2 of Resumability
and Redelivery pairs resumption with Last-Event-ID; with zero events received, last_event_id is None, so no such header is sent. It is the resumption request
minus the thing that makes it a resumption.
405 is a statement about the endpoint, not about right now. A server with
intermittent server-initiated traffic cannot use it to decline a single GET without
misreporting its capabilities. Its conformant way to say "nothing for you, don't
wait on me" is row 3 — precisely the case that gets punished.
Steelmanning the current behaviour, since it isn't baseless: reconnecting after a
server-initiated closure is explicitly contemplated — 2025-11-25 broadened
resumption to cover "a disconnection (whether due to network failure or
server-initiated closure)". Row 2 is real and the loop serves it correctly. Browser EventSource likewise reconnects indefinitely. But EventSource uses exponential
backoff where this uses a flat 1 s; it reconnects a channel the page explicitly
opened, whereas this GET is opened unconditionally on notifications/initialized
(clause 1 makes it only a MAY); and decisively, the code declares a bound it
does not honour.MAX_RECONNECTION_ATTEMPTS = 2 and the two guards enforcing it
cannot be reconciled with never stopping. Either the bound is broken or it is dead
code — and the # pragma: no cover on the exit says the test suite has never
established which.
Upgrading the SDK is not an escape
Since 2026-07-28 deletes the GET stream, the natural assumption is that this ages
out on its own. It does not, because the deciding factor is the server's
revision, not the client's version.
mcp 2.x ships a high-level Client oriented at the new revision. Run against a
legacy-era server it probes with server/discover, takes a plain 400, and falls
back to initialize + notifications/initialized — which is precisely what calls start_get_stream(). Measured with modern_client_probe.py on 2.0.0:
POST server/discover
POST initialize
POST notifications/initialized
GET None
POST tools/list
GET None
GET None
GET None <- 4 GETs in 4s, no Last-Event-ID, still climbing
So the loop is reachable from both client APIs in the current release, and stays
reachable for as long as pre-2026 servers exist. Given that the 2024-11-05 transport
is still only deprecated and hosted for compatibility, that is likely to be a long
time.
Impact
handle_get_stream runs as a background task in the transport's task group, so
nothing surfaces the loop — no exception, and above DEBUG only a recurring GET stream disconnected, reconnecting in 1000ms... at INFO.
A long-lived process holding many sessions accumulates a permanent 1 req/s of
background traffic per affected session, plus a task that can't be reaped without
tearing the session down. Load grows linearly with sessions opened and is
invisible unless outbound request volume is correlated against session count.
Servers that behave this way aren't exotic, in rough order of how reliably they
trigger it:
A POST-oriented server that accepts the GET and immediately ends the stream
instead of refusing it with 405 — up, routing correctly, with nothing to
push, forever and identically. The unambiguous trigger.
A server that closes idle streams by design, expecting reconnection on
demand.
An intermediary (nginx, Cloudflare, an ALB) timing out an idle SSE
response — but only when it terminates the response gracefully; an abrupt
severance lands on the benign path above.
In the intermediary case the origin never learns the stream ended and never needs
to: the proxy ended the response toward the client while the origin may still
consider its stream open. Nothing is coordinating.
Suggested fix
Reset only when the stream actually delivered something:
That keeps the intent while restoring MAX_RECONNECTION_ATTEMPTS as a real bound.
This is not an arbitrary heuristic — it is the spec's own reconnect signal. Per
the priming clause quoted above, a server that intends to be polled again SHOULD first send an event carrying an id. "An event was seen" is therefore
very close to "the server primed me to reconnect," and the tighter variant needs no
new state at all, because the loop already tracks it:
iflast_event_idisnotNone:
attempt=0# server primed a reconnect (row 2)else:
attempt+=1# nothing was ever delivered (row 3)
saw_event is the looser form, and is the safer default of the two: it also resets
for a server that delivered real events without ids — productive, though not
resumable.
The underlying conflation: attempt is meant to count consecutive failures, but
it is updated from protocol outcomes. A request that returns 200 and delivers
nothing is a protocol success and a functional failure, and the current code can
only see the former.
It doesn't break healthy idle streams. A server holding a stream open for
hours without sending anything never reaches the reset line — it blocks inside the async for, holding the response open, for as long as the server keeps it open.
Only a terminated stream falls through to attempt = 0. So "terminated AND
delivered nothing" has no false positives against the case people would worry
about.
The trade-off, stated honestly:saw_event would make the client give up on a
server that legitimately cycles short empty streams and expects reconnection. If
that pattern should stay supported, an overall reconnect cap with exponential
backoff is the better fix — it bounds the runaway case without abandoning such a
server, and softens the fixed 1s interval as well.
Possibly related
streamable_http client hangs indefinitely when connecting to POST-only MCP servers (ex: GitHub MCP) #1941 (closed as not planned) — the mirror image of this report, in the same
eight lines. There the bound is too aggressive: a POST-only server answers the
GET with 405, the loop correctly gives up after 2 attempts, and the now-dead
background task leaves later POST/SSE responses unable to arrive. Here the bound is unreachable.
Both symptoms being real at once is itself the diagnosis: the retry policy keys off
the protocol outcome of each attempt rather than whether the channel is
functioning. That makes it simultaneously too quick to abandon a stream that
matters and unable to abandon one that never will. A fix aimed only at the retry count will shuttle the problem between these two reports instead of resolving
either.
its Bug 1 proposal — maxRetries: 10 with the existing 1.5× backoff capped at
30 s, so ~5 minutes of retries and then stop — would address that report and
leave this case bounded;
its Bug 3 option (C) — "keep retrying SSE indefinitely with the existing
exponential backoff" — would entrench this case instead. Backoff lowers the
request rate but not the non-termination, and against a server that will never
send anything, unbounded is unbounded at any interval.
So for both reports the decisive question is whether an overall cap exists, not the
shape of the delay curve.
Its trigger also corroborates the termination table above: a CloudFlare Tunnel idle
timeout severing the stream surfaced there as Failed to open SSE stream — the error path. That is why that report sees premature give-up where this one sees no
give-up at all.
Example Code
https://github.com/luiz00martins/mcp-get-stream-reconnect-loop#!/usr/bin/env python3"""Minimal reproduction: StreamableHTTPTransport.handle_get_stream never terminates.The standalone GET/SSE stream reconnects forever when the server accepts the GETand then ends the stream normally, because `attempt = 0` on a clean stream closedefeats the `MAX_RECONNECTION_ATTEMPTS` bound that guards the retry loop.Everything is driven through the public API (`streamablehttp_client` +`ClientSession.initialize`). The only stdlib-served pieces are a stub MCPendpoint, so the behaviour observed is entirely the SDK client's.Usage: uv run repro.py # bug: server accepts GET, ends stream uv run repro.py --mode 405 # control: server rejects GET (correct)"""from __future__ importannotationsimportargparseimportasyncioimportjsonimportthreadingimporttimefromhttp.serverimportBaseHTTPRequestHandler, ThreadingHTTPServerfromimportlib.metadataimportversionfrommcpimportClientSessionfrommcp.typesimportLATEST_PROTOCOL_VERSION# The transport factory is spelled both ways across releases# (`streamablehttp_client` in the 1.2x line, `streamable_http_client` in 2.x),# so accept either and one file reproduces across the whole range.try:
frommcp.client.streamable_httpimportstreamable_http_clientashttp_clientexceptImportError: # pragma: no coverfrommcp.client.streamable_httpimportstreamablehttp_clientashttp_clientSESSION_ID="repro-session-0001"_lock=threading.Lock()
GET_TIMES: list[float] = []
MODE="close"classStubMCP(BaseHTTPRequestHandler):
"""Speaks just enough Streamable HTTP for the client to finish initialising."""protocol_version="HTTP/1.1"deflog_message(self, *_args) ->None: # keep stdout cleanpass# -- POST: initialize handshake -----------------------------------------defdo_POST(self) ->None:
raw=self.rfile.read(int(self.headers.get("Content-Length") or0))
try:
msg=json.loads(raworb"{}")
exceptValueError:
msg= {}
method=msg.get("method", "")
# `notifications/initialized` is what makes the client open the GET# stream (streamable_http.py, post_writer: `start_get_stream()`).ifmethod.startswith("notifications/"):
self.send_response(202)
self.send_header("Content-Length", "0")
self.end_headers()
returnifmethod=="initialize":
# Echo back whatever version the client asked for. A real server# negotiates, and echoing keeps this file working across releases# whose supported-version tables differ.requested= (msg.get("params") or {}).get(
"protocolVersion", LATEST_PROTOCOL_VERSION
)
body=json.dumps(
{
"jsonrpc": "2.0",
"id": msg.get("id", 1),
"result": {
"protocolVersion": requested,
"capabilities": {},
"serverInfo": {"name": "stub", "version": "0.0.0"},
},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
# Without a session id the client returns from handle_get_stream# immediately, so a real session is required to see the bug.self.send_header("mcp-session-id", SESSION_ID)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
returnself.send_response(202)
self.send_header("Content-Length", "0")
self.end_headers()
# -- GET: the standalone server-initiated stream ------------------------defdo_GET(self) ->None:
with_lock:
GET_TIMES.append(time.perf_counter())
ifMODE=="405":
# Spec-legal for a server that offers no GET stream. The client# treats this as an error, so the attempt counter is NOT reset and# the loop correctly stops after MAX_RECONNECTION_ATTEMPTS.self.send_response(405)
self.send_header("Content-Length", "0")
self.end_headers()
return# A valid but empty SSE stream, closed immediately. This is what an# intermediary (nginx / Cloudflare / ALB) or a POST-oriented server# does. `aiter_sse()` completes without error => "ended normally".self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Connection", "close")
self.end_headers()
self.close_connection=Truedefdo_DELETE(self) ->None:
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
asyncdefdrive(url: str, seconds: float) ->None:
"""Initialise a session over the public API, then idle."""# 1.x yields (read, write, get_session_id); 2.x yields (read, write).asyncwithhttp_client(url) asstreams:
read, write=streams[0], streams[1]
asyncwithClientSession(read, write) assession:
awaitsession.initialize()
awaitasyncio.sleep(seconds)
asyncdefmain() ->int:
globalMODEap=argparse.ArgumentParser()
ap.add_argument("--mode", choices=("close", "405"), default="close")
ap.add_argument("--seconds", type=float, default=8.0)
args=ap.parse_args()
MODE=args.modeserver=ThreadingHTTPServer(("127.0.0.1", 0), StubMCP)
server.daemon_threads=Truethreading.Thread(target=server.serve_forever, daemon=True).start()
url=f"http://127.0.0.1:{server.server_port}/mcp"print(f"mcp version : {version('mcp')}")
print(f"server mode : {args.mode}")
print(f"observing : {args.seconds:.0f}s after initialize()\n")
try:
awaitasyncio.wait_for(drive(url, args.seconds), timeout=args.seconds+20)
exceptasyncio.TimeoutError:
print("client did not shut down within the grace period")
finally:
server.shutdown()
with_lock:
times=list(GET_TIMES)
print(f"GET /mcp requests observed: {len(times)}")
iflen(times) >1:
gaps= [times[i+1] -times[i] foriinrange(len(times) -1)]
gaps.sort()
print(f"inter-request gap (median): {gaps[len(gaps) //2] *1000:.0f}ms")
print()
ifargs.mode=="405":
ok=len(times) <=2print(
f"EXPECTED: at most 2 (MAX_RECONNECTION_ATTEMPTS)\n"f"RESULT : {len(times)} -> {'correct'ifokelse'UNEXPECTED'}"
)
return0ifokelse1bug=len(times) >2print(
f"EXPECTED: at most 2 (MAX_RECONNECTION_ATTEMPTS)\n"f"RESULT : {len(times)} -> {'BUG REPRODUCED (unbounded)'ifbugelse'no bug seen'}"
)
return0ifbugelse1if__name__=="__main__":
raiseSystemExit(asyncio.run(main()))
Python & MCP Python SDK
Production Environment — Python 3.13.14, mcp 1.28.1, httpx 0.28.1, httpx-sse 0.4.3, anyio 4.11.0.
Reproduction env — Python 3.13.14 (CPython), Linux x86_64 / Ubuntu 24.04, anyio 4.14.2. HTTP stack varies across the range: httpx2/httpcore2 2.9.1 on mcp 2.0.0, httpx 0.28.1 + httpx-sse 0.4.3 on the 1.2x line. Bug present either way.
Initial Checks
Release line
1.x (maintenance line, v1.x branch)
Description
Summary
StreamableHTTPTransport.handle_get_streamreconnects forever — once per second,for the lifetime of the session — when the server accepts the standalone GET and
then ends the SSE stream without error.
MAX_RECONNECTION_ATTEMPTSis meant tobound the retry loop, but a clean stream close resets the attempt counter, so the
bound is never reached.
The server that triggers this is spec-conformant, not misbehaving: terminating
the stream immediately is an explicit permission — "The server MAY close the SSE
stream at any time" (2025-11-25
§Transports,
the last revision defining this mechanism). The spec section below shows that the
revision distinguishes closing the connection from terminating the stream, and
that the loop cannot tell them apart.
Minimal reproduction (single stdlib file + the
mcpclient, public API only):https://github.com/luiz00martins/mcp-get-stream-reconnect-loop
It is a single self-contained file — stdlib stub server plus the
mcpclient,no external server and no reach into transport internals. Inlined below so it can
be run without leaving this page:
repro.py(single file, stdlib +mcp)Affected versions
Reproduced via
streamable_http_client+ClientSession.initialize(), 5s window:mcpIntroduced in 1.23.0 with the auto-reconnect loop; still present in 2.0.0. The
count scales linearly with the observation window — it never terminates.
Environment
anyio4.14.2httpx2/httpcore22.9.1; on the 1.2x line it ishttpx0.28.1 withhttpx-sse0.4.3Nothing in the reproduction is platform-specific — the loop is paced by
anyio.sleepagainst a localhost stub server.Root cause
src/mcp/client/streamable_http.py,StreamableHTTPTransport.handle_get_stream(quoted from 2.0.0):
The 1.2x line is the same logic with different plumbing
(
aconnect_sse(client, "GET", ...)andevent_source.aiter_sse()).A server returning
200+Content-Type: text/event-streamand then closingmakes the event iterator complete without raising. That's treated as "ended
normally", so
attemptresets to0and neither thewhilecondition nor theguard is ever satisfied.
The two coverage pragmas are themselves evidence:
# pragma: no branchon thewhileand# pragma: no coveron the termination guard assert that the testsuite never sees this loop exit. Both have been present in every affected
release, 1.23.0 through 2.0.0.
The reset is sensible in isolation — a stream that ran a while shouldn't be
penalised for earlier failures. The bug is that a stream yielding zero events
and closing immediately is indistinguishable from a productive one.
Expected vs actual
Expected: at most
MAX_RECONNECTION_ATTEMPTS(2) GETs, then the backgroundtask gives up.
Actual: one GET per second, indefinitely.
Which terminations trigger it — and which don't
Whether the loop terminates depends entirely on how the response body ended —
not on whether the server is healthy.
termination_modes.pyin the repo measuresall five cases (identical on 1.28.1 and 2.0.0, 5-second window):
Last-Event-IDsent on reconnectsid, then connection closeContent-Length, no events0\r\n\r\n, no events405 Method Not AllowedThe first three rows are behaviourally identical — 5 GETs, one per second — yet
only the first is a conformant resumption, and the sole thing distinguishing it is a
value the SDK already tracks. That is the defect in one line:
last_event_idholdsthe answer and the retry logic never consults it.
The retry bound itself works — both error rows increment
attemptand stopcorrectly after 2 tries. That isolates the defect to the
attempt = 0resetrather than to retrying.
Worth flagging for reproduction: an intermediary that kills a connection
abruptly produces the benign truncation path. So "a proxy timed out my SSE
stream" is not by itself enough to see the bug — the body has to end in a
well-formed way, which is what a server deliberately ending an idle stream
does.
The client also cannot distinguish the two clean-EOF rows from a productive
stream at the protocol layer: SSE responses carry no
Content-Length, so "endedafter zero events" and "ended after a thousand" are the same shape of completion.
The only per-response signal available is malformedness, and that case is already
handled correctly.
The spec defines three server signals; the loop collapses two of them
Which revision applies. The standalone GET stream exists in protocol revisions
2025-03-26through2025-11-25, and was removed in the current revision2026-07-28("Removal of the GET streamendpoint").
That scopes the bug but does not shrink it: the SDK's handshake path still
negotiates
2025-11-25—ClientSession.initialize()sendsLATEST_HANDSHAKE_VERSION, which is2025-11-25on 2.0.0, confirmed on the wire —so
handle_get_streamruns for every session against a server of that era. Quotesbelow are therefore from 2025-11-25
§Transports,
the last revision that defines this mechanism.
Listening for Messages from the Server:
And the POST rules that clause 4 defers to, which define the priming mechanism:
The spec therefore gives a server three distinct things to say:
405id(plusretry), then close the connectionLast-Event-IDRows 2 and 3 are both "
200, then a closed connection." What distinguishes them iswhether an event carrying an
idwas ever sent — exactly the priming mechanismthe POST clause describes.
handle_get_streamalready tracks that (last_event_id),but does not condition reconnection on it. So it treats row 3 as if it were row 2,
forever.
Two consequences worth noting:
and Redelivery pairs resumption with
Last-Event-ID; with zero events received,last_event_idisNone, so no such header is sent. It is the resumption requestminus the thing that makes it a resumption.
405is a statement about the endpoint, not about right now. A server withintermittent server-initiated traffic cannot use it to decline a single GET without
misreporting its capabilities. Its conformant way to say "nothing for you, don't
wait on me" is row 3 — precisely the case that gets punished.
Steelmanning the current behaviour, since it isn't baseless: reconnecting after a
server-initiated closure is explicitly contemplated — 2025-11-25 broadened
resumption to cover "a disconnection (whether due to network failure or
server-initiated closure)". Row 2 is real and the loop serves it correctly. Browser
EventSourcelikewise reconnects indefinitely. ButEventSourceuses exponentialbackoff where this uses a flat 1 s; it reconnects a channel the page explicitly
opened, whereas this GET is opened unconditionally on
notifications/initialized(clause 1 makes it only a MAY); and decisively, the code declares a bound it
does not honour.
MAX_RECONNECTION_ATTEMPTS = 2and the two guards enforcing itcannot be reconciled with never stopping. Either the bound is broken or it is dead
code — and the
# pragma: no coveron the exit says the test suite has neverestablished which.
Upgrading the SDK is not an escape
Since
2026-07-28deletes the GET stream, the natural assumption is that this agesout on its own. It does not, because the deciding factor is the server's
revision, not the client's version.
mcp2.x ships a high-levelClientoriented at the new revision. Run against alegacy-era server it probes with
server/discover, takes a plain400, and fallsback to
initialize+notifications/initialized— which is precisely what callsstart_get_stream(). Measured withmodern_client_probe.pyon 2.0.0:So the loop is reachable from both client APIs in the current release, and stays
reachable for as long as pre-2026 servers exist. Given that the 2024-11-05 transport
is still only deprecated and hosted for compatibility, that is likely to be a long
time.
Impact
handle_get_streamruns as a background task in the transport's task group, sonothing surfaces the loop — no exception, and above
DEBUGonly a recurringGET stream disconnected, reconnecting in 1000ms...atINFO.A long-lived process holding many sessions accumulates a permanent 1 req/s of
background traffic per affected session, plus a task that can't be reaped without
tearing the session down. Load grows linearly with sessions opened and is
invisible unless outbound request volume is correlated against session count.
Servers that behave this way aren't exotic, in rough order of how reliably they
trigger it:
instead of refusing it with
405— up, routing correctly, with nothing topush, forever and identically. The unambiguous trigger.
demand.
response — but only when it terminates the response gracefully; an abrupt
severance lands on the benign path above.
In the intermediary case the origin never learns the stream ended and never needs
to: the proxy ended the response toward the client while the origin may still
consider its stream open. Nothing is coordinating.
Suggested fix
Reset only when the stream actually delivered something:
That keeps the intent while restoring
MAX_RECONNECTION_ATTEMPTSas a real bound.This is not an arbitrary heuristic — it is the spec's own reconnect signal. Per
the priming clause quoted above, a server that intends to be polled again
SHOULD first send an event carrying an
id. "An event was seen" is thereforevery close to "the server primed me to reconnect," and the tighter variant needs no
new state at all, because the loop already tracks it:
saw_eventis the looser form, and is the safer default of the two: it also resetsfor a server that delivered real events without ids — productive, though not
resumable.
The underlying conflation:
attemptis meant to count consecutive failures, butit is updated from protocol outcomes. A request that returns
200and deliversnothing is a protocol success and a functional failure, and the current code can
only see the former.
It doesn't break healthy idle streams. A server holding a stream open for
hours without sending anything never reaches the reset line — it blocks inside the
async for, holding the response open, for as long as the server keeps it open.Only a terminated stream falls through to
attempt = 0. So "terminated ANDdelivered nothing" has no false positives against the case people would worry
about.
The trade-off, stated honestly:
saw_eventwould make the client give up on aserver that legitimately cycles short empty streams and expects reconnection. If
that pattern should stay supported, an overall reconnect cap with exponential
backoff is the better fix — it bounds the runaway case without abandoning such a
server, and softens the fixed 1s interval as well.
Possibly related
streamable_http client hangs indefinitely when connecting to POST-only MCP servers (ex: GitHub MCP) #1941 (closed as not planned) — the mirror image of this report, in the same
eight lines. There the bound is too aggressive: a POST-only server answers the
GET with
405, the loop correctly gives up after 2 attempts, and the now-deadbackground task leaves later POST/SSE responses unable to arrive. Here the bound is
unreachable.
Both symptoms being real at once is itself the diagnosis: the retry policy keys off
the protocol outcome of each attempt rather than whether the channel is
functioning. That makes it simultaneously too quick to abandon a stream that
matters and unable to abandon one that never will. A fix aimed only at the retry
count will shuttle the problem between these two reports instead of resolving
either.
The one maintainer comment on streamable_http client hangs indefinitely when connecting to POST-only MCP servers (ex: GitHub MCP) #1941 asked for "a single self-contained script that
reproduces this locally… A minimal server + client in one file… rather than an
external repo." That is exactly what the inlined
repro.pyabove is.StreamableHTTPClientTransport: 2-retry SSE reconnect ceiling + silent-success after exhaustion typescript-sdk#2098 (open) — the sibling symptom in the TS
transport: same 2-retry ceiling, same dead-channel outcome as streamable_http client hangs indefinitely when connecting to POST-only MCP servers (ex: GitHub MCP) #1941. Worth reading
alongside this one because its two remedies diverge sharply here:
maxRetries: 10with the existing 1.5× backoff capped at30 s, so ~5 minutes of retries and then stop — would address that report and
leave this case bounded;
exponential backoff" — would entrench this case instead. Backoff lowers the
request rate but not the non-termination, and against a server that will never
send anything, unbounded is unbounded at any interval.
So for both reports the decisive question is whether an overall cap exists, not the
shape of the delay curve.
Its trigger also corroborates the termination table above: a CloudFlare Tunnel idle
timeout severing the stream surfaced there as
Failed to open SSE stream— theerror path. That is why that report sees premature give-up where this one sees no
give-up at all.
Example Code
Python & MCP Python SDK