modelcontextprotocol / modelcontextprotocol/python-sdk
`handle_get_stream` reconnects indefinitely when the server ends the GET stream normally (`attempt = 0` defeats `MAX_RECONNECTION_ATTEMPTS`)
還沒有人認領這個 Issue。
- 主要語言
- Python
- 星號
- 24.3k
- 分支
- 4k
- 平均合併
- 1 天 1 小時
- 30 天內合併 PR
- 31
描述
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
1.x (maintenance line, v1.x branch)
Description
Summary
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.
Minimal reproduction (single stdlib file + the mcp client, public API only):
https://github.com/luiz00martins/mcp-get-stream-reconnect-loop
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)
anyio4.14.2- On 2.0.0 the HTTP stack is
httpx2/httpcore22.9.1; on the 1.2x line it is
httpx0.28.1 withhttpx-sse0.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):
while attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch
try:
if not self.session_id:
return
async with client.sse(self.url, headers=headers) as event_source:
event_source.response.raise_for_status()
async for sse in event_source:
...
# Stream ended normally (server closed) - reset attempt counter
attempt = 0 # <-- defeats the loop bound
except Exception:
attempt += 1
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
return
await anyio.sleep(delay_ms / 1000.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-streamin 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 aretryfield 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 emptydatafield in order to prime the client to reconnect (using that
event ID asLast-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 withLast-Event-ID; with zero events received,
last_event_idisNone, so no such header is sent. It is the resumption request
minus the thing that makes it a resumption. 405is 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 with405— 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:
saw_event = False
async for sse in event_source:
saw_event = True
...
if saw_event:
attempt = 0
else:
attempt += 1
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:
if last_event_id is not None:
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
-
#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 with405, 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.The one maintainer comment on #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 inlinedrepro.pyabove is. -
modelcontextprotocol/typescript-sdk#2098 (open) — the sibling symptom in the TS
transport: same 2-retry ceiling, same dead-channel outcome as #1941. Worth reading
alongside this one because its two remedies diverge sharply here:- its Bug 1 proposal —
maxRetries: 10with 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 asFailed 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. - its Bug 1 proposal —
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 GET
and then ends the stream normally, because `attempt = 0` on a clean stream close
defeats 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 MCP
endpoint, 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__ import annotations
import argparse
import asyncio
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib.metadata import version
from mcp import ClientSession
from mcp.types import LATEST_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:
from mcp.client.streamable_http import streamable_http_client as http_client
except ImportError: # pragma: no cover
from mcp.client.streamable_http import streamablehttp_client as http_client
SESSION_ID = "repro-session-0001"
_lock = threading.Lock()
GET_TIMES: list[float] = []
MODE = "close"
class StubMCP(BaseHTTPRequestHandler):
"""Speaks just enough Streamable HTTP for the client to finish initialising."""
protocol_version = "HTTP/1.1"
def log_message(self, *_args) -> None: # keep stdout clean
pass
# -- POST: initialize handshake -----------------------------------------
def do_POST(self) -> None:
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
try:
msg = json.loads(raw or b"{}")
except ValueError:
msg = {}
method = msg.get("method", "")
# `notifications/initialized` is what makes the client open the GET
# stream (streamable_http.py, post_writer: `start_get_stream()`).
if method.startswith("notifications/"):
self.send_response(202)
self.send_header("Content-Length", "0")
self.end_headers()
return
if method == "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)
return
self.send_response(202)
self.send_header("Content-Length", "0")
self.end_headers()
# -- GET: the standalone server-initiated stream ------------------------
def do_GET(self) -> None:
with _lock:
GET_TIMES.append(time.perf_counter())
if MODE == "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 = True
def do_DELETE(self) -> None:
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
async def drive(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).
async with http_client(url) as streams:
read, write = streams[0], streams[1]
async with ClientSession(read, write) as session:
await session.initialize()
await asyncio.sleep(seconds)
async def main() -> int:
global MODE
ap = 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.mode
server = ThreadingHTTPServer(("127.0.0.1", 0), StubMCP)
server.daemon_threads = True
threading.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:
await asyncio.wait_for(drive(url, args.seconds), timeout=args.seconds + 20)
except asyncio.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)}")
if len(times) > 1:
gaps = [times[i + 1] - times[i] for i in range(len(times) - 1)]
gaps.sort()
print(f"inter-request gap (median): {gaps[len(gaps) // 2] * 1000:.0f}ms")
print()
if args.mode == "405":
ok = len(times) <= 2
print(
f"EXPECTED: at most 2 (MAX_RECONNECTION_ATTEMPTS)\n"
f"RESULT : {len(times)} -> {'correct' if ok else 'UNEXPECTED'}"
)
return 0 if ok else 1
bug = len(times) > 2
print(
f"EXPECTED: at most 2 (MAX_RECONNECTION_ATTEMPTS)\n"
f"RESULT : {len(times)} -> {'BUG REPRODUCED (unbounded)' if bug else 'no bug seen'}"
)
return 0 if bug else 1
if __name__ == "__main__":
raise SystemExit(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.
貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
研究方向
從 src/mcp/client/streamable_http.py 中的 StreamableHTTPTransport.handle_get_stream 開始,然後使用 mcp==2.0.0 執行連結的單檔案 repro.py。將其行為與 termination_modes.py 進行比較,重點關注乾淨的 EOF、Last-Event-ID 以及 MAX_RECONNECTION_ATTEMPTS 防護。完成標準是:正常終止的 GET 串流不再無限重連,同時可恢復的關閉仍保留其預期行為。
由索引模型根據 Issue 內容生成。
評估
- 技術堆疊
- python
- 領域
- api, networking
- Issue 類型
- 缺陷
- 難度
- 4/5
- 預估耗時
- 3-5 天
- 活躍度
- 冷清
- 描述清晰度
- 描述清楚
- 新手友好度
- 55/100