Skip to content

Commit aec8155

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 a30cdc3 commit aec8155

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,
@@ -2143,3 +2144,44 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(context_
21432144

21442145
assert "content-type" in headers_data
21452146
assert headers_data["content-type"] == "application/json"
2147+
2148+
2149+
@pytest.mark.anyio
2150+
async def test_standalone_stream_teardown_mid_listen_is_not_an_error(caplog: pytest.LogCaptureFixture) -> None:
2151+
"""Tearing down the standalone stream under its parked writer produces no error log.
2152+
2153+
Cleanup closes the send side first, so a writer parked in receive() ends on a clean
2154+
end-of-stream. This pins that close ordering: reversing it would wake the parked writer
2155+
with ClosedResourceError on every disconnect. (The timing window where teardown lands
2156+
between dequeues is handled by the writer's ClosedResourceError arm, which cannot be
2157+
forced deterministically from the public surface.)
2158+
"""
2159+
session_manager = StreamableHTTPSessionManager(
2160+
app=_create_server(),
2161+
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
2162+
)
2163+
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
2164+
notified = anyio.Event()
2165+
2166+
async def message_handler(
2167+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
2168+
) -> None:
2169+
if isinstance(message, types.ResourceUpdatedNotification):
2170+
notified.set()
2171+
2172+
async with session_manager.run():
2173+
async with (
2174+
make_client(app) as http_client,
2175+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
2176+
ClientSession(read_stream, write_stream, message_handler=message_handler) as session,
2177+
):
2178+
await session.initialize()
2179+
# Prove the standalone GET writer is live: a notification with no
2180+
# related request rides the GET stream to the client.
2181+
await session.call_tool("test_tool_with_standalone_notification", {})
2182+
with anyio.fail_after(5):
2183+
await notified.wait()
2184+
# Tear the standalone stream down while the writer is parked on it.
2185+
(transport,) = session_manager._server_instances.values() # pyright: ignore[reportPrivateUsage]
2186+
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
2187+
assert "Error in standalone SSE writer" not in caplog.text

0 commit comments

Comments
 (0)