Skip to content

Commit 6537096

Browse files
author
Jianke LIN
committed
test(streamable-http): cover disconnect without resumption anchor
1 parent 4ea297c commit 6537096

2 files changed

Lines changed: 36 additions & 13 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -459,18 +459,15 @@ async def _handle_reconnection(
459459
"""Reconnect with Last-Event-ID to resume stream after server disconnect."""
460460
# Bail if max retries exceeded
461461
if attempt >= MAX_RECONNECTION_ATTEMPTS:
462-
original_request_id = None
463-
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
464-
original_request_id = ctx.session_message.message.id
465-
466-
if original_request_id is not None:
467-
error_data = ErrorData(
468-
code=CONNECTION_CLOSED,
469-
message="SSE stream disconnected and could not be resumed",
470-
data={"last_event_id": last_event_id},
471-
)
472-
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
473-
await ctx.read_stream_writer.send(error_msg)
462+
assert isinstance(ctx.session_message.message, JSONRPCRequest)
463+
original_request_id = ctx.session_message.message.id
464+
error_data = ErrorData(
465+
code=CONNECTION_CLOSED,
466+
message="SSE stream disconnected and could not be resumed",
467+
data={"last_event_id": last_event_id},
468+
)
469+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
470+
await ctx.read_stream_writer.send(error_msg)
474471
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
475472
return
476473

tests/client/test_streamable_http.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from mcp_types import (
1919
CLIENT_CAPABILITIES_META_KEY,
2020
CLIENT_INFO_META_KEY,
21+
CONNECTION_CLOSED,
2122
METHOD_NOT_FOUND,
2223
PROTOCOL_VERSION_META_KEY,
2324
JSONRPCError,
@@ -28,10 +29,11 @@
2829
from mcp_types.version import LATEST_MODERN_VERSION
2930
from starlette.types import Receive, Scope, Send
3031

31-
from mcp.client.streamable_http import streamable_http_client
32+
from mcp.client.streamable_http import RequestContext, StreamableHTTPTransport, streamable_http_client
3233
from mcp.server import Server
3334
from mcp.server._streamable_http_modern import handle_modern_request
3435
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
36+
from mcp.shared._context_streams import create_context_streams
3537
from mcp.shared.dispatcher import CallOptions, DispatchContext
3638
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
3739
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
@@ -73,6 +75,30 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field
7375
assert encoded == raw
7476

7577

78+
@pytest.mark.anyio
79+
async def test_sse_response_disconnect_before_any_event_id_fails_request() -> None:
80+
transport = StreamableHTTPTransport("http://example.com/mcp")
81+
async with httpx.AsyncClient() as client:
82+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](1)
83+
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "noop", "arguments": {}})
84+
ctx = RequestContext(
85+
client=client,
86+
session_id=None,
87+
session_message=SessionMessage(request),
88+
metadata=None,
89+
read_stream_writer=read_stream_writer,
90+
)
91+
response = httpx.Response(200, headers={"content-type": "text/event-stream"}, content=b"")
92+
93+
async with read_stream_writer, read_stream:
94+
await transport._handle_sse_response(response, ctx)
95+
message = await read_stream.receive()
96+
97+
assert isinstance(message.message, JSONRPCError)
98+
assert message.message.id == 1
99+
assert message.message.error.code == CONNECTION_CLOSED
100+
101+
76102
@pytest.mark.anyio
77103
async def test_post_request_merges_per_message_metadata_headers() -> None:
78104
"""`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers

0 commit comments

Comments
 (0)