Skip to content

Commit 35eff18

Browse files
committed
Fix infinite reconnection loop in StreamableHTTP client
_handle_reconnection() reset the attempt counter to 0 when the SSE stream ended without delivering a complete response (only priming events). This made MAX_RECONNECTION_ATTEMPTS ineffective—a server that accepts connections but drops streams caused the client to retry forever. The fix distinguishes productive reconnections (ones that delivered actual message data like notifications) from unproductive ones (only priming events or nothing). Productive reconnections reset the counter so legitimate multi-close patterns continue working. Unproductive reconnections increment the counter, and once MAX_RECONNECTION_ATTEMPTS is reached the client sends an error back to the caller instead of silently returning (which caused the caller to hang). Made-with: Cursor Github-Issue: #2393 Made-with: Cursor
1 parent d5b9155 commit 35eff18

File tree

2 files changed

+62
-9
lines changed

2 files changed

+62
-9
lines changed

src/mcp/client/streamable_http.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,19 @@ async def _handle_reconnection(
380380
) -> None:
381381
"""Reconnect with Last-Event-ID to resume stream after server disconnect."""
382382
# Bail if max retries exceeded
383-
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
384-
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
383+
if attempt >= MAX_RECONNECTION_ATTEMPTS:
384+
logger.warning(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
385+
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
386+
error_data = ErrorData(
387+
code=INTERNAL_ERROR,
388+
message=(
389+
f"SSE stream disconnected and max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded"
390+
),
391+
)
392+
error_msg = SessionMessage(
393+
JSONRPCError(jsonrpc="2.0", id=ctx.session_message.message.id, error=error_data)
394+
)
395+
await ctx.read_stream_writer.send(error_msg)
385396
return
386397

387398
# Always wait - use server value or default
@@ -404,6 +415,7 @@ async def _handle_reconnection(
404415
# Track for potential further reconnection
405416
reconnect_last_event_id: str = last_event_id
406417
reconnect_retry_ms = retry_interval_ms
418+
received_data = False
407419

408420
async for sse in event_source.aiter_sse():
409421
if sse.id: # pragma: no branch
@@ -421,9 +433,15 @@ async def _handle_reconnection(
421433
await event_source.response.aclose()
422434
return
423435

424-
# Stream ended again without response - reconnect again (reset attempt counter)
436+
if sse.data:
437+
received_data = True
438+
439+
# Stream ended without response - reset counter only if we received
440+
# actual message data (not just priming events), otherwise increment
441+
# to prevent infinite reconnection loops when the server always drops.
442+
next_attempt = 0 if received_data else attempt + 1
425443
logger.info("SSE stream disconnected, reconnecting...")
426-
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
444+
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, next_attempt)
427445
except Exception as e: # pragma: no cover
428446
logger.debug(f"Reconnection failed: {e}")
429447
# Try to reconnect again if we still have an event ID

tests/shared/test_streamable_http.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ async def _handle_list_tools( # pragma: no cover
225225
description="Tool that closes standalone GET stream mid-operation",
226226
input_schema={"type": "object", "properties": {}},
227227
),
228+
Tool(
229+
name="tool_with_perpetual_stream_close",
230+
description="Tool that always closes the stream without sending a response",
231+
input_schema={"type": "object", "properties": {}},
232+
),
228233
]
229234
)
230235

@@ -380,6 +385,16 @@ async def _handle_call_tool( # pragma: no cover
380385

381386
return CallToolResult(content=[TextContent(type="text", text="Standalone stream close test done")])
382387

388+
elif name == "tool_with_perpetual_stream_close":
389+
# Repeatedly close the stream without ever sending a response.
390+
# Used to verify that _handle_reconnection gives up after MAX_RECONNECTION_ATTEMPTS.
391+
for _ in range(10):
392+
if ctx.close_sse_stream:
393+
await ctx.close_sse_stream()
394+
await anyio.sleep(0.3)
395+
# This response should never be reached by the client because reconnection gives up
396+
return CallToolResult(content=[TextContent(type="text", text="Should not reach")])
397+
383398
return CallToolResult(content=[TextContent(type="text", text=f"Called {name}")])
384399

385400

@@ -1086,7 +1101,7 @@ async def test_streamable_http_client_tool_invocation(initialized_client_session
10861101
"""Test client tool invocation."""
10871102
# First list tools
10881103
tools = await initialized_client_session.list_tools()
1089-
assert len(tools.tools) == 10
1104+
assert len(tools.tools) == 11
10901105
assert tools.tools[0].name == "test_tool"
10911106

10921107
# Call the tool
@@ -1116,7 +1131,7 @@ async def test_streamable_http_client_session_persistence(basic_server: None, ba
11161131

11171132
# Make multiple requests to verify session persistence
11181133
tools = await session.list_tools()
1119-
assert len(tools.tools) == 10
1134+
assert len(tools.tools) == 11
11201135

11211136
# Read a resource
11221137
resource = await session.read_resource(uri="foobar://test-persist")
@@ -1138,7 +1153,7 @@ async def test_streamable_http_client_json_response(json_response_server: None,
11381153

11391154
# Check tool listing
11401155
tools = await session.list_tools()
1141-
assert len(tools.tools) == 10
1156+
assert len(tools.tools) == 11
11421157

11431158
# Call a tool and verify JSON response handling
11441159
result = await session.call_tool("test_tool", {})
@@ -1220,7 +1235,7 @@ async def test_streamable_http_client_session_termination(basic_server: None, ba
12201235

12211236
# Make a request to confirm session is working
12221237
tools = await session.list_tools()
1223-
assert len(tools.tools) == 10
1238+
assert len(tools.tools) == 11
12241239

12251240
async with create_mcp_http_client(headers=headers) as httpx_client2:
12261241
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client2) as (
@@ -1281,7 +1296,7 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt
12811296

12821297
# Make a request to confirm session is working
12831298
tools = await session.list_tools()
1284-
assert len(tools.tools) == 10
1299+
assert len(tools.tools) == 11
12851300

12861301
async with create_mcp_http_client(headers=headers) as httpx_client2:
12871302
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client2) as (
@@ -2318,3 +2333,23 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
23182333

23192334
assert "content-type" in headers_data
23202335
assert headers_data["content-type"] == "application/json"
2336+
2337+
2338+
@pytest.mark.anyio
2339+
async def test_reconnection_gives_up_after_max_attempts(
2340+
event_server: tuple[SimpleEventStore, str],
2341+
) -> None:
2342+
"""Client should stop reconnecting after MAX_RECONNECTION_ATTEMPTS and return an error.
2343+
2344+
Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/2393:
2345+
_handle_reconnection used to reset the attempt counter to 0 when the stream ended
2346+
without a response, causing an infinite retry loop.
2347+
"""
2348+
_, server_url = event_server
2349+
2350+
async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream):
2351+
async with ClientSession(read_stream, write_stream) as session:
2352+
await session.initialize()
2353+
2354+
with pytest.raises(MCPError), anyio.fail_after(30): # pragma: no branch
2355+
await session.call_tool("tool_with_perpetual_stream_close", {})

0 commit comments

Comments
 (0)