Skip to content

Commit 8650d5c

Browse files
author
Jianke LIN
committed
fix(streamable-http): tolerate closed read streams during SSE cleanup
1 parent 5bb74a3 commit 8650d5c

2 files changed

Lines changed: 25 additions & 8 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@
4444
StreamWriter = ContextSendStream[SessionMessageOrError]
4545
StreamReader = ContextReceiveStream[SessionMessage]
4646

47+
48+
async def _send_or_ignore_closed(read_stream_writer: StreamWriter, message: SessionMessageOrError) -> bool:
49+
try:
50+
await read_stream_writer.send(message)
51+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
52+
logger.debug("Read stream closed before Streamable HTTP message could be delivered", exc_info=True)
53+
return False
54+
return True
55+
56+
4757
MCP_SESSION_ID = "mcp-session-id"
4858
LAST_EVENT_ID = "last-event-id"
4959

@@ -157,17 +167,17 @@ async def _handle_sse_event(
157167
# Otherwise, return False to continue listening
158168
return isinstance(message, JSONRPCResponse | JSONRPCError)
159169

160-
# Forwarding to a closed read stream lands here when the caller cancels mid-SSE
161-
# (BrokenResourceError, not a parse failure); coverage is timing-dependent in the
162-
# streaming story's modern HTTP cancellation leg.
170+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
171+
logger.debug("Read stream closed while forwarding SSE message", exc_info=True)
172+
return True
163173
except Exception as exc: # pragma: lax no cover
164174
logger.exception("Error parsing SSE message")
165175
if original_request_id is not None:
166176
error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}")
167177
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
168-
await read_stream_writer.send(error_msg)
178+
await _send_or_ignore_closed(read_stream_writer, error_msg)
169179
return True
170-
await read_stream_writer.send(exc)
180+
await _send_or_ignore_closed(read_stream_writer, exc)
171181
return False
172182
else: # pragma: no cover
173183
logger.warning(f"Unknown SSE event: {sse.event}")
@@ -383,7 +393,7 @@ async def _handle_sse_response(
383393
if last_event_id is None:
384394
error_data = ErrorData(code=CONNECTION_CLOSED, message="SSE stream disconnected before response completed")
385395
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
386-
await ctx.read_stream_writer.send(error_msg)
396+
await _send_or_ignore_closed(ctx.read_stream_writer, error_msg)
387397
return
388398

389399
logger.info("SSE stream disconnected, reconnecting...")
@@ -407,7 +417,7 @@ async def _handle_reconnection(
407417
data={"last_event_id": last_event_id},
408418
)
409419
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
410-
await ctx.read_stream_writer.send(error_msg)
420+
await _send_or_ignore_closed(ctx.read_stream_writer, error_msg)
411421
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
412422
return
413423

tests/client/test_streamable_http.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
import httpx
1414
import pytest
1515
from inline_snapshot import snapshot
16-
from mcp_types import CONNECTION_CLOSED, METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
16+
from mcp_types import (
17+
CONNECTION_CLOSED,
18+
METHOD_NOT_FOUND,
19+
JSONRPCError,
20+
JSONRPCNotification,
21+
JSONRPCRequest,
22+
JSONRPCResponse,
23+
)
1724

1825
from mcp.client.streamable_http import RequestContext, StreamableHTTPTransport, streamable_http_client
1926
from mcp.shared._context_streams import create_context_streams

0 commit comments

Comments
 (0)