Skip to content

Commit 75cd668

Browse files
committed
fix: propagate error when SSE stream ends without a response
When an SSE stream ends prematurely (e.g. due to a read timeout), the client would hang forever waiting for a response that will never arrive. Now _handle_sse_response checks the return value of _handle_reconnection and, if reconnection did not deliver a response, sends a JSONRPCError with INTERNAL_ERROR to the read stream. This unblocks the waiting request and surfaces the failure as an MCPError to the caller. Github-Issue: #1401
1 parent ddd2a37 commit 75cd668

File tree

2 files changed

+29
-4
lines changed

2 files changed

+29
-4
lines changed

src/mcp/client/streamable_http.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,12 +364,18 @@ async def _handle_sse_response(
364364
await response.aclose()
365365
return # Normal completion, no reconnect needed
366366
except Exception:
367-
logger.debug("SSE stream ended", exc_info=True) # pragma: no cover
367+
logger.debug("SSE stream error", exc_info=True)
368368

369-
# Stream ended without response - reconnect if we received an event with ID
370-
if last_event_id is not None: # pragma: no branch
369+
# Stream ended without a complete response — attempt reconnection if possible
370+
if last_event_id is not None:
371371
logger.info("SSE stream disconnected, reconnecting...")
372-
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms)
372+
if await self._handle_reconnection(ctx, last_event_id, retry_interval_ms):
373+
return # Reconnection delivered the response
374+
375+
# No response delivered — unblock the waiting request with an error
376+
error_data = ErrorData(code=INTERNAL_ERROR, message="SSE stream ended without a response")
377+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
378+
await ctx.read_stream_writer.send(error_msg)
373379

374380
async def _handle_reconnection(
375381
self,

tests/shared/test_streamable_http.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,25 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
22562256
assert headers_data["content-type"] == "application/json"
22572257

22582258

2259+
@pytest.mark.anyio
2260+
async def test_sse_read_timeout_propagates_error(basic_server: None, basic_server_url: str):
2261+
"""SSE read timeout should propagate MCPError instead of hanging."""
2262+
# Create client with very short SSE read timeout
2263+
short_timeout = httpx.Timeout(30.0, read=0.5)
2264+
async with httpx.AsyncClient(timeout=short_timeout, follow_redirects=True) as http_client:
2265+
async with streamable_http_client(f"{basic_server_url}/mcp", http_client=http_client) as (
2266+
read_stream,
2267+
write_stream,
2268+
):
2269+
async with ClientSession(read_stream, write_stream) as session:
2270+
await session.initialize()
2271+
2272+
# Read a "slow" resource that takes 2s — longer than our 0.5s read timeout
2273+
with pytest.raises(MCPError):
2274+
with anyio.fail_after(10):
2275+
await session.read_resource("slow://test")
2276+
2277+
22592278
@pytest.mark.anyio
22602279
async def test_handle_reconnection_returns_false_on_max_attempts():
22612280
"""_handle_reconnection returns False when max attempts exceeded."""

0 commit comments

Comments
 (0)