Guards the stateful Streamable HTTP POST error path so it never raises a secondary ClosedResourceError out of the ASGI app when the session's read stream is already gone.
User-facing impact: the original failure is no longer masked by a misleading Exception in ASGI application traceback.
In stateful Streamable HTTP (stateless_http=False), when a session's app.run() raises, the session task logs Session <id> crashed and tears down its memory streams. The concurrent _handle_post_request then hits its except block, sends the 500 response, and finally runs await writer.send(Exception(err)) — but writer (the session read stream) is already closed, so this send raises a secondary anyio.ClosedResourceError that escapes the ASGI app as Exception in ASGI application, burying the real cause.
Note: this PR deliberately does not change the client-facing 500 message. Since #2741 was filed, main reworked this path to intentionally log the exception server-side and return a generic "Error handling POST request" to the client (the original PR #2742 was closed by its author for exactly this reason). This PR addresses only the remaining robustness gap from the issue — point (3) in the issue's root-cause analysis: the unguarded trailing send.
Root Cause
Symptom — Every POST after a session crash produces a noisy Exception in ASGI application / anyio.ClosedResourceError traceback that is unrelated to and hides the real error.
Root cause — In StreamableHTTPServerTransport._handle_post_request (streamable_http.py), the except Exception as err block ends with await writer.send(Exception(err)). writer is self._read_stream_writer. When the session task has already crashed and connect() has torn down the streams, that stream is closed, so the send raises ClosedResourceError. Nothing catches it, so it propagates out of handle_request → out of the ASGI app.
Evidence — streamable_http.py line ~649 (await writer.send(Exception(err))) has no guard, unlike the module's other stream sends (lines 301, 723, 1014, 1023 already catch ClosedResourceError/BrokenResourceError). The added regression test pre-closes the read stream to model the crashed-session teardown and reproduces the escaping ClosedResourceError.
Fix + why this level — Wrap the trailing send in try/except (anyio.ClosedResourceError, anyio.BrokenResourceError): pass. This is the correct layer: the 500 response is already delivered to the client before this send, so the wrapped-error forwarding is best-effort internal signaling; swallowing the secondary error when the stream is gone is exactly the right semantics and matches the existing closed-stream guards in this file.
Scope / risk — Touches only the except block of _handle_post_request. Does not change the client response, status code, or the server-side logger.exception("Error handling POST request"). When the stream is still open (normal in-flight error), behavior is unchanged.
Verification
uv run pytest tests/server/test_streamable_http_router.py tests/server/test_streamable_http_manager.py -q — 23 passed
uv run ruff check / ruff format --check — clean
uv run pyright src/mcp/server/streamable_http.py — 0 errors
Real behavior proof
Regression test: tests/server/test_streamable_http_router.py::test_post_error_path_tolerates_closed_session_stream. It models the crashed-session teardown by pre-closing the read stream before handle_request runs.
What was NOT tested: the full uvicorn end-to-end repro from the issue (the router-level test pins the exact failing call site directly, matching the existing _PrimingFailingStore test pattern in this file).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Motivation
Closes #2741.
In stateful Streamable HTTP (stateless_http=False), when a session's app.run() raises, the session task logs Session <id> crashed and tears down its memory streams. The concurrent _handle_post_request then hits its except block, sends the 500 response, and finally runs await writer.send(Exception(err)) — but writer (the session read stream) is already closed, so this send raises a secondary anyio.ClosedResourceError that escapes the ASGI app as Exception in ASGI application, burying the real cause.
Note: this PR deliberately does not change the client-facing 500 message. Since #2741 was filed, main reworked this path to intentionally log the exception server-side and return a generic "Error handling POST request" to the client (the original PR #2742 was closed by its author for exactly this reason). This PR addresses only the remaining robustness gap from the issue — point (3) in the issue's root-cause analysis: the unguarded trailing send.
Root Cause
Symptom — Every POST after a session crash produces a noisy Exception in ASGI application / anyio.ClosedResourceError traceback that is unrelated to and hides the real error.
Root cause — In StreamableHTTPServerTransport._handle_post_request (streamable_http.py), the except Exception as err block ends with await writer.send(Exception(err)). writer is self._read_stream_writer. When the session task has already crashed and connect() has torn down the streams, that stream is closed, so the send raises ClosedResourceError. Nothing catches it, so it propagates out of handle_request → out of the ASGI app.
Evidence — streamable_http.py line ~649 (await writer.send(Exception(err))) has no guard, unlike the module's other stream sends (lines 301, 723, 1014, 1023 already catch ClosedResourceError/BrokenResourceError). The added regression test pre-closes the read stream to model the crashed-session teardown and reproduces the escaping ClosedResourceError.
Fix + why this level — Wrap the trailing send in try/except (anyio.ClosedResourceError, anyio.BrokenResourceError): pass. This is the correct layer: the 500 response is already delivered to the client before this send, so the wrapped-error forwarding is best-effort internal signaling; swallowing the secondary error when the stream is gone is exactly the right semantics and matches the existing closed-stream guards in this file.
Scope / risk — Touches only the except block of _handle_post_request. Does not change the client response, status code, or the server-side logger.exception("Error handling POST request"). When the stream is still open (normal in-flight error), behavior is unchanged.
Verification
Real behavior proof
Results (0.02s): 3 passed