Uh oh!
There was an error while loading. Please reload this page.
fix: cancel in-flight handlers when transport closes in server.run() - #2306
Conversation
When the transport closes (stdin EOF, client disconnect) while a request handler is still running, server.run()'s task group joins on the handler instead of cancelling it. The handler eventually finishes, tries to send its response through a write stream that _receive_loop already closed, and server.run() crashes with ClosedResourceError wrapped in a triple-nested ExceptionGroup. The fix cancels the task group when the incoming_messages loop ends. Handlers receive CancelledError and can clean up in finally blocks. The existing CancelledError catch in _handle_request (added for CancelledNotification handling in #1153) now distinguishes the two cancellation sources: responder.cancel() already sent an error response and we skip the duplicate; transport-close cancellation is re-raised so the task group swallows it. Github-Issue: #526
There was a problem hiding this comment.
No bugs found, but this touches core server infrastructure (the main server.run() loop and the CancelledError handling in _handle_request), with subtle cancel-scope semantics distinguishing two cancellation sources — worth a human confirming the anyio scope behavior is correct.
Extended reasoning...
Overview
This PR fixes a real bug where server.run() would crash with ClosedResourceError when the transport closes while a handler is still running. It makes three changes: (1) adds tg.cancel_scope.cancel() in a finally block in run() so in-flight handlers are cancelled instead of joined, (2) distinguishes CancelledNotification cancellation from TG/transport-close cancellation in _handle_request via message.cancelled (which checks the responder's own cancel scope, not the TG's outer scope), and (3) removes a # pragma: no branch that is no longer valid since _completed can now be False when a handler is cancelled before calling respond().
Security risks
No security risks identified. The change is purely about lifecycle/cleanup behavior.
Level of scrutiny
This modifies the core server.run() message processing loop and the cancellation handling in _handle_request — both critical code paths for every MCP server. The PR author notes this also affects streamable HTTP when terminate() fires mid-request. The cancel-scope distinction (responder's _cancel_scope.cancel_called vs the TG's outer cancel scope) is correct based on anyio semantics, but a human should verify this understanding since getting it wrong could cause handlers to swallow cancellation they should propagate, or vice versa.
Other factors
The PR includes a well-designed test that drives server.run() with raw memory streams to avoid InMemoryTransport masking the bug. The test verifies both that the handler gets cancelled and that server.run() returns cleanly. No CODEOWNERS file exists. The PR description is thorough and references related issues across multiple SDK implementations. No bugs were found by automated analysis.
Two additional races in the same transport-close window as the previous commit, both triggered when handlers are blocked on server-to-client requests (sampling, roots, elicitation) at the moment the transport closes: 1. _receive_loop's finally iterates _response_streams.items() with await checkpoints inside the loop. The woken handler's send_request finally pops from that dict before the iterator's next __next__(), raising RuntimeError: dictionary changed size during iteration. Fix: snapshot with list() before iterating. 2. The woken handler's send_request raises MCPError (CONNECTION_CLOSED), which _handle_request catches and converts to an error response. It then falls through to message.respond() against a write stream that _receive_loop already closed. Fix: catch ClosedResourceError and drop the response. Both reproduce deterministically with two handlers blocked on list_roots() when to_server is closed. Single test covers both: fails 20/20 with either fix reverted, passes 50/50 with both.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Python 3.14's compiler attributes the async trampoline's CLEANUP_THROW instructions (for the try-body's await) to the next physical line of code, which was the else body. coverage.py traced a phantom line event there, tripping strict-no-cover even though the else never runs. Moving the try/respond after the if/else avoids the misattribution and also deduplicates the two respond() calls.
streamable_http's terminate() closes _write_stream_reader (the receive end) before _write_stream (the send end). A handler reaching respond() between those two closes gets BrokenResourceError (peer end closed) rather than ClosedResourceError (our end closed). The stdio path only ever hits ClosedResourceError because _receive_loop's async-with closes the send end.
Uh oh!
There was an error while loading. Please reload this page.
…ct fix (#600) * fix(nix): pin mcp >= 1.27.0 so the deployed closure gets the disconnect fix The live deployment logs an unhandled ClosedResourceError traceback roughly 0.7x/day. Root cause is upstream: mcp < 1.27.0 lets that error escape the SDK's own task group when a client disconnects mid-request. Fixed upstream in 1.27.0 (modelcontextprotocol/python-sdk#2306) by cancelling the task group and treating a closed write stream as normal termination. uv.lock already pins 1.28.1 -- but uv.lock is not what runs. nix/package.nix and nix/packages.nix take mcp from `python.pkgs`, i.e. from nixpkgs, and the deploy tracks nixos-26.05 which ships 1.26.0. 26.05's last mcp change was 1.25 -> 1.26 in January and neither 1.27 bump was backported, so waiting for the channel is not a plan. The override lives in nix/packages.nix because that is the ONE expression both consumers evaluate: the flake for CI/VM tests, and nix/home-manager-module.nix with the CONSUMER's pkgs for production. Pinning in either alone leaves the other on whatever its channel ships -- which is exactly how 1.26.0 ended up deployed while the lockfile said 1.28.1. It is version-GATED, so it retires itself the moment a consumer's channel ships >= 1.27.0. The gate reads pkgs.python312Packages.mcp.version from the untouched package set: testing pyprev.mcp.version inside packageOverrides is the obvious spelling and hits infinite recursion, because the condition deciding the overlay would have to evaluate through the fixpoint the overlay defines. Comment left in place so it is not "simplified" back. pyproject.toml's floor moves 1.8.1 -> 1.27.0 in the SAME commit: nixpkgs applies pythonRuntimeDepsCheckHook by default, so a floor the pinned channel cannot satisfy would red the Nix build. Also lists sse-starlette explicitly in the dependency list. It is declared in pyproject.toml but was reaching the closure only as a transitive dep of mcp -- a latent break waiting for mcp to drop it. Verified by building: the closure carries python3.12-mcp-1.27.1, and both halves of the upstream fix are present in it (server.py:690 tg.cancel_scope.cancel(), server.py:801 except BrokenResourceError, ClosedResourceError). doCheck is off on the override: 1.27.1's own suite wants a newer pytest/inline-snapshot than 26.05 ships (upstream needed test-side workarounds). We pin for a runtime fix, not to vendor their test matrix; our own suite exercises this path in CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(deps): sync uv.lock with the raised mcp floor `uv export --locked` compares pyproject.toml against the constraints recorded in uv.lock, so raising the mcp floor without re-locking reds the Dependency-audit gate (it did, in 8s). One line changes -- the recorded specifier -- and the resolved version stays 1.28.1, which already satisfied both the old and the new floor, so there is no resolution churn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
When the transport closes mid-request,
server.run()now cancels in-flight handlers instead of waiting for them.Motivation and Context
Discovered while investigating #2231. That issue's stated symptom (server survives parent death with no tool running) does not reproduce — stdin EOF propagates cleanly through
anyio.wrap_file+TextIOWrapperon all tested Python versions (3.10–3.14). But the investigation surfaced a real adjacent bug:When stdin closes while a tool handler is running,
server.run()'s task group joins on the handler rather than cancelling it. The handler eventually finishes, tries to send its response through a_write_streamthat_receive_loopalready closed, andserver.run()crashes withClosedResourceErrorwrapped in a triple-nestedExceptionGroup.In practice: a stdio server with a 3-second tool gets SIGTERM'd at 2s by the client's graceful-shutdown escalation (
PROCESS_TERMINATION_TIMEOUT), sofinallyblocks never run. With this fix, the handler getsCancelledErrorimmediately and cleanup runs.This is the same bug class as #2257 (merged last week), which fixed one specific write-after-close in
_handle_message. This closes the general case.The existing
CancelledErrorcatch in_handle_request(added in #1153 forCancelledNotification) now distinguishes the two cancellation sources viamessage.cancelled— which checks the responder's own cancel scope, set only byresponder.cancel(). TG cancellation is an outer scope and doesn't set it.May help some #526 cases, but the long-lived orphans reported there are more consistent with the client never closing stdin (client-side bug).
Go SDK has the same wait-then-crash behavior (
conn.go:167); C# fixed it in modelcontextprotocol/csharp-sdk#226.How Has This Been Tested?
tests/server/test_cancel_handling.pydrivesserver.run()with raw memory streams (can't useInMemoryTransport— it wrapsserver.run()in its ownfinally: tg.cancel_scope.cancel()which masks the bug). Fails withTimeoutErroron main, passes with fix.test_server_remains_functional_after_cancelcovers theCancelledNotificationpath unchanged.kill -9parent with tool in progress).Breaking Changes
None. Handlers already needed to be cancel-safe for
CancelledNotification.Types of changes
Checklist
Additional context
session.py:108pragma removed:RequestResponder.__exit__now sees_completed == Falsewhen a handler is cancelled before reachingrespond().Note for reviewers: this also affects streamable HTTP when
terminate()fires mid-request (stateless mode afterhandle_request()returns early, or session DELETE). In-flight tools are now cancelled rather than drained. Since they were previously crashing on the closed write stream anyway, this is strictly better.