Skip to content

handle_get_stream reconnects indefinitely when the server ends the GET stream normally (attempt = 0 defeats MAX_RECONNECTION_ATTEMPTS) #3257

Description

@luiz00martins

Initial Checks

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

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-25ClientSession.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:

  1. 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.

  2. 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:

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

  • 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.

    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.py above 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:

    • 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 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions