Skip to content

Commit 86418f0

Browse files
committed
fix(server): guard error-path send into closed session stream
Closes #2741. When a stateful Streamable HTTP session task crashes, it tears down its read stream before the concurrent POST handler reaches its except block. The trailing `await writer.send(Exception(err))` then targets an already closed/broken stream and raises a secondary ClosedResourceError that escapes the ASGI app ("Exception in ASGI application"), masking the original failure. Guard the trailing send with except (ClosedResourceError, BrokenResourceError): pass, mirroring the existing closed-stream guards in this module. The 500 response is already delivered before this send, so swallowing the secondary error preserves client behavior while removing the noisy, misleading traceback. Regression test models the crashed-session teardown by pre-closing the read stream; it fails without the guard (ClosedResourceError out of handle_request) and passes with it.
1 parent 603342f commit 86418f0

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

src/mcp/server/streamable_http.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,14 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
646646
INTERNAL_ERROR,
647647
)
648648
await response(scope, receive, send)
649-
await writer.send(Exception(err))
649+
# The session's read stream may already be closed (e.g. the session task
650+
# crashed and tore down its streams before this handler ran). Sending into a
651+
# closed/broken stream here would raise a secondary error that masks the
652+
# original one and surfaces as "Exception in ASGI application". Guard it.
653+
try:
654+
await writer.send(Exception(err))
655+
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: lax no cover
656+
pass
650657
return
651658

652659
async def _handle_get_request(self, request: Request, send: Send) -> None:

tests/server/test_streamable_http_router.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,59 @@ async def asgi_send(message: Message) -> None:
114114
assert sent[0]["status"] == 500
115115
body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
116116
assert b"backend unavailable" not in body
117+
118+
119+
@pytest.mark.anyio
120+
async def test_post_error_path_tolerates_closed_session_stream() -> None:
121+
"""The error path must not raise a secondary error when the read stream is gone (#2741).
122+
123+
When a session task crashes it tears down its read stream before the concurrent
124+
POST handler reaches its `except` block. The trailing ``writer.send(Exception(err))``
125+
then targets a closed/broken stream. Without a guard that raises a secondary
126+
``ClosedResourceError``/``BrokenResourceError`` out of the ASGI app, masking the
127+
original error and surfacing as "Exception in ASGI application". The 500 response
128+
must already be delivered and ``handle_request`` must return cleanly.
129+
"""
130+
transport = StreamableHTTPServerTransport(
131+
mcp_session_id=None,
132+
is_json_response_enabled=False,
133+
event_store=_PrimingFailingStore(),
134+
)
135+
136+
body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
137+
scope: Scope = {
138+
"type": "http",
139+
"method": "POST",
140+
"path": "/",
141+
"query_string": b"",
142+
"headers": [
143+
(b"accept", b"application/json, text/event-stream"),
144+
(b"content-type", b"application/json"),
145+
(b"mcp-protocol-version", b"2025-11-25"),
146+
],
147+
}
148+
body_sent = False
149+
150+
async def receive() -> Message:
151+
nonlocal body_sent
152+
if not body_sent:
153+
body_sent = True
154+
return {"type": "http.request", "body": body, "more_body": False}
155+
raise NotImplementedError
156+
157+
sent: list[Message] = []
158+
159+
async def asgi_send(message: Message) -> None:
160+
sent.append(message)
161+
162+
async with transport.connect() as (read_stream, _write_stream):
163+
# Model the crashed-session teardown: the read stream the POST handler would
164+
# send the wrapped error into is already closed before the handler runs.
165+
await read_stream.aclose()
166+
# Must not raise out of the ASGI app despite the closed stream.
167+
await transport.handle_request(scope, receive, asgi_send)
168+
169+
assert sent[0]["type"] == "http.response.start"
170+
assert sent[0]["status"] == 500
171+
body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
172+
assert b"backend unavailable" not in body

0 commit comments

Comments
 (0)