Skip to content

Commit c722121

Browse files
committed
Stop logging an error when the standalone SSE stream closes mid-listen
Transport teardown closes the standalone stream's send side first, so a writer parked in receive() ends on a clean end-of-stream; but when teardown lands while the writer is between dequeues, the next receive() raises ClosedResourceError, which fell into the catch-all and logged a traceback at ERROR level for a routine disconnect. Catch it and end quietly. A new test pins the close ordering that keeps the parked path clean.
1 parent eb3fff4 commit c722121

2 files changed

Lines changed: 48 additions & 0 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,12 @@ async def standalone_sse_writer():
717717
# Send the message via SSE
718718
event_data = self._create_event_data(event_message)
719719
await sse_stream_writer.send(event_data)
720+
except anyio.ClosedResourceError: # pragma: lax no cover
721+
# Teardown completed while the writer was between dequeues:
722+
# the next receive() hits the closed stream. A writer parked
723+
# in receive() instead sees a clean end-of-stream (cleanup
724+
# closes the send side first), so this arm is timing-dependent.
725+
pass
720726
except Exception:
721727
logger.exception("Error in standalone SSE writer") # pragma: no cover
722728
finally:

tests/shared/test_streamable_http.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
2929
from mcp.server import Server, ServerRequestContext
3030
from mcp.server.streamable_http import (
31+
GET_STREAM_KEY,
3132
MCP_PROTOCOL_VERSION_HEADER,
3233
MCP_SESSION_ID_HEADER,
3334
SESSION_ID_PATTERN,
@@ -2224,3 +2225,44 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(context_
22242225

22252226
assert "content-type" in headers_data
22262227
assert headers_data["content-type"] == "application/json"
2228+
2229+
2230+
@pytest.mark.anyio
2231+
async def test_standalone_stream_teardown_mid_listen_is_not_an_error(caplog: pytest.LogCaptureFixture) -> None:
2232+
"""Tearing down the standalone stream under its parked writer produces no error log.
2233+
2234+
Cleanup closes the send side first, so a writer parked in receive() ends on a clean
2235+
end-of-stream. This pins that close ordering: reversing it would wake the parked writer
2236+
with ClosedResourceError on every disconnect. (The timing window where teardown lands
2237+
between dequeues is handled by the writer's ClosedResourceError arm, which cannot be
2238+
forced deterministically from the public surface.)
2239+
"""
2240+
session_manager = StreamableHTTPSessionManager(
2241+
app=_create_server(),
2242+
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
2243+
)
2244+
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
2245+
notified = anyio.Event()
2246+
2247+
async def message_handler(
2248+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
2249+
) -> None:
2250+
if isinstance(message, types.ResourceUpdatedNotification):
2251+
notified.set()
2252+
2253+
async with session_manager.run():
2254+
async with (
2255+
make_client(app) as http_client,
2256+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
2257+
ClientSession(read_stream, write_stream, message_handler=message_handler) as session,
2258+
):
2259+
await session.initialize()
2260+
# Prove the standalone GET writer is live: a notification with no
2261+
# related request rides the GET stream to the client.
2262+
await session.call_tool("test_tool_with_standalone_notification", {})
2263+
with anyio.fail_after(5):
2264+
await notified.wait()
2265+
# Tear the standalone stream down while the writer is parked on it.
2266+
(transport,) = session_manager._server_instances.values() # pyright: ignore[reportPrivateUsage]
2267+
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
2268+
assert "Error in standalone SSE writer" not in caplog.text

0 commit comments

Comments
 (0)