Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.types import (
CONNECTION_CLOSED,
INVALID_REQUEST,
PARSE_ERROR,
ErrorData,
Expand Down Expand Up @@ -357,12 +358,24 @@ async def _handle_sse_response(
await response.aclose()
return # Normal completion, no reconnect needed
except Exception:
logger.debug("SSE stream ended", exc_info=True) # pragma: no cover
logger.debug("SSE stream ended", exc_info=True)

# Stream ended without response - reconnect if we received an event with ID
if last_event_id is not None: # pragma: no branch
# Stream ended without response - reconnect if we have an event ID,
# otherwise notify the session that the connection is dead (#1811)
if last_event_id is not None:
logger.info("SSE stream disconnected, reconnecting...")
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms)
else:
logger.warning("SSE stream closed without response or event ID, cannot reconnect")
error = JSONRPCError(
jsonrpc="2.0",
id=original_request_id,
error=ErrorData(
code=CONNECTION_CLOSED,
message="SSE stream closed without response",
),
)
await ctx.read_stream_writer.send(SessionMessage(error))

async def _handle_reconnection(
self,
Expand Down
50 changes: 49 additions & 1 deletion tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@
)
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import InitializeResult, JSONRPCRequest, TextContent, TextResourceContents, Tool
from mcp.types import (
CONNECTION_CLOSED,
InitializeResult,
JSONRPCError,
JSONRPCRequest,
TextContent,
TextResourceContents,
Tool,
)
from tests.test_helpers import wait_for_server

# Test constants
Expand Down Expand Up @@ -2239,3 +2247,43 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(

assert "content-type" in headers_data
assert headers_data["content-type"] == "application/json"


@pytest.mark.anyio
async def test_handle_sse_response_sends_error_when_stream_closes_without_event_id():
"""SSE stream closing without event ID sends CONNECTION_CLOSED error (#1811)."""
from mcp.client.streamable_http import RequestContext as _TransportRequestContext

transport = StreamableHTTPTransport(url="http://localhost:8000/mcp")
write_stream, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](1)

request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
ctx = _TransportRequestContext(
client=MagicMock(),
session_id=None,
session_message=SessionMessage(message=request),
metadata=None,
read_stream_writer=write_stream,
)

# Mock response whose SSE stream yields zero events (simulates abrupt close)
mock_response = MagicMock()

async def _empty_aiter_lines(): # pragma: no cover
return
yield

mock_response.aiter_lines = _empty_aiter_lines

try:
await transport._handle_sse_response(mock_response, ctx)

result = await read_stream.receive()
assert isinstance(result, SessionMessage)
assert isinstance(result.message, JSONRPCError)
assert result.message.error.code == CONNECTION_CLOSED
assert "SSE stream closed without response" in result.message.error.message
assert result.message.id == 1
finally:
await write_stream.aclose()
await read_stream.aclose()