Skip to content

Commit b3a8a7e

Browse files
committed
fix: stop infinite GET stream reconnection on repeated empty connections
Only reset the attempt counter when events were actually received during the connection. Connections that close immediately without delivering events now count toward MAX_RECONNECTION_ATTEMPTS. Github-Issue:#1401
1 parent 1216c53 commit b3a8a7e

2 files changed

Lines changed: 32 additions & 3 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:
214214
event_source.response.raise_for_status()
215215
logger.debug("GET SSE connection established")
216216

217+
received_events = False
217218
async for sse in event_source.aiter_sse():
219+
received_events = True
218220
# Track last event ID for reconnection
219221
if sse.id:
220222
last_event_id = sse.id
@@ -224,14 +226,18 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:
224226

225227
await self._handle_sse_event(sse, read_stream_writer)
226228

227-
# Stream ended normally (server closed) - reset attempt counter
228-
attempt = 0
229+
# Only reset attempts if we actually received events;
230+
# empty connections count toward MAX_RECONNECTION_ATTEMPTS
231+
if received_events:
232+
attempt = 0
233+
else:
234+
attempt += 1
229235

230236
except Exception:
231237
logger.debug("GET stream error", exc_info=True)
232238
attempt += 1
233239

234-
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
240+
if attempt >= MAX_RECONNECTION_ATTEMPTS:
235241
logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
236242
return
237243

tests/client/test_streamable_http.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,3 +748,26 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain
748748
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
749749
)
750750
send.close()
751+
752+
753+
@pytest.mark.anyio
754+
async def test_get_stream_gives_up_after_repeated_empty_connections(monkeypatch: pytest.MonkeyPatch) -> None:
755+
"""A GET stream that keeps opening but closing with no events counts each empty connection toward
756+
the reconnection budget, so the loop terminates instead of reconnecting forever."""
757+
monkeypatch.setattr("mcp.client.streamable_http.DEFAULT_RECONNECTION_DELAY_MS", 0)
758+
get_requests = 0
759+
760+
def handler(request: httpx.Request) -> httpx.Response:
761+
nonlocal get_requests
762+
get_requests += 1
763+
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=b"")
764+
765+
transport = StreamableHTTPTransport("http://test/mcp")
766+
transport.session_id = "sess-1"
767+
send, receive = create_context_streams[SessionMessage | Exception](1)
768+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
769+
with anyio.fail_after(5):
770+
await transport.handle_get_stream(http, send)
771+
assert get_requests == MAX_RECONNECTION_ATTEMPTS
772+
send.close()
773+
receive.close()

0 commit comments

Comments
 (0)