Skip to content

fix: cancel in-flight handlers when transport closes in server.run() - #2306

Merged
maxisbey merged 4 commits into
mainfrom
fix/cancel-in-flight-on-transport-close
Mar 20, 2026
Merged

fix: cancel in-flight handlers when transport closes in server.run()#2306
maxisbey merged 4 commits into
mainfrom
fix/cancel-in-flight-on-transport-close

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

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 + TextIOWrapper on 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_stream that _receive_loop already closed, and server.run() crashes with ClosedResourceError wrapped in a triple-nested ExceptionGroup.

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), so finally blocks never run. With this fix, the handler gets CancelledError immediately 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 CancelledError catch in _handle_request (added in #1153 for CancelledNotification) now distinguishes the two cancellation sources via message.cancelled — which checks the responder's own cancel scope, set only by responder.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?

  • New test in tests/server/test_cancel_handling.py drives server.run() with raw memory streams (can't use InMemoryTransport — it wraps server.run() in its own finally: tg.cancel_scope.cancel() which masks the bug). Fails with TimeoutError on main, passes with fix.
  • Existing test_server_remains_functional_after_cancel covers the CancelledNotification path unchanged.
  • Verified against real subprocess scenario (Python 3.14, kill -9 parent with tool in progress).

Breaking Changes

None. Handlers already needed to be cancel-safe for CancelledNotification.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

session.py:108 pragma removed: RequestResponder.__exit__ now sees _completed == False when a handler is cancelled before reaching respond().

Note for reviewers: this also affects streamable HTTP when terminate() fires mid-request (stateless mode after handle_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.

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
@maxisbey
maxisbey marked this pull request as ready for review March 18, 2026 15:51
@maxisbey
maxisbey requested a review from KludexMarch 18, 2026 15:51

@claudeclaudeBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment threadsrc/mcp/server/lowlevel/server.py Outdated
Comment threadsrc/mcp/server/lowlevel/server.py Outdated
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.
@maxisbey
maxisbey merged commit 92c693b into mainMar 20, 2026
53 of 54 checks passed
@maxisbey
maxisbey deleted the fix/cancel-in-flight-on-transport-close branch March 20, 2026 13:37
dvaerum added a commit to dvaerum/Agent-MCP that referenced this pull request Aug 8, 2026
…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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@maxisbey@felixweinberger