Repository navigation
Bug: AssertionError: Request already responded to — cancellation race in v1.27.0 #2416
Description
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
Bug: AssertionError: Request already responded to — cancellation race in v1.27.0
AssertionError: Request already responded to when CancelledNotification arrives after handler completes but before respond()
Description
When a client sends a notifications/cancelled for a request whose handler has already finished executing but hasn't yet called message.respond(), the server crashes with AssertionError: Request already responded to.
PR #2334 (v1.27.0) fixed the ClosedResourceError crash path by catching CancelledError in _handle_request and guarding respond() against BrokenResourceError/ClosedResourceError. However, it left a race window between handler completion and respond() where a cancellation notification can set _completed = True first, causing the assert on line 129 of session.py to fire.
Reproduction scenario
- Client sends a
tools/callrequest with a long-running handler (e.g. polling with 600s timeout) - Handler completes and returns a result
- Between the handler's
returnand theawait message.respond(response)call in_handle_request, the client sendsnotifications/cancelledfor that same request ID - The cancellation notification handler (
session.py:403-406) callsresponder.cancel(), which:- Calls
_cancel_scope.cancel() - Sets
_completed = True - Sends an error response
"Request cancelled"
- Calls
- Back in
_handle_request, execution reachesawait message.respond(response)atserver.py:800 respond()hitsassert not self._completedatsession.py:129→ crash
The cancel_scope.cancel() only raises CancelledError if the task is currently in an await. Since the handler already returned, the code path between the handler return and respond() is synchronous — no checkpoint where CancelledError can be delivered. The except anyio.get_cancelled_exc_class() guard at server.py:773 never fires.
Stack trace
ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "mcp/server/lowlevel/server.py", line 703, in _handle_message
| await self._handle_request(message, req, session, lifespan_context, raise_exceptions)
| File "mcp/server/lowlevel/server.py", line 800, in _handle_request
| await message.respond(response)
| File "mcp/shared/session.py", line 129, in respond
| assert not self._completed, "Request already responded to"
| AssertionError: Request already responded to
+------------------------------------
Impact
This crashes the entire MCP server process, killing all in-flight requests. In our case, the server manages multiple long-running background agents, so a crash loses all active work. The crash is triggered by normal client behavior (user cancels an operation), making it a reliability issue rather than an edge case.
Related
- [Bug] Server crashes with anyio.ClosedResourceError when receiving raw invalid UTF-8 bytes #2328 — original ClosedResourceError report
- [v1.x] fix: handle ClosedResourceError when transport closes mid-request #2334 — v1.x fix (covers ClosedResourceError but not this assert race)
- fix: cancel in-flight handlers when transport closes in server.run() #2306 — main branch transport-close cancellation
Example Code
The race window in `_handle_request` (`server.py:719`):
# Line 770: handler completes, returns response
response = await handler(req)
# ... exception handling ...
# Line 799-800: GAP — between handler return and respond(),
# a CancelledNotification can arrive on another task and call
# responder.cancel(), setting _completed = True and sending
# an error response. No await in this gap means no CancelledError
# can be delivered.
try:
await message.respond(response) # <-- assert fires here
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
...
The `except anyio.get_cancelled_exc_class()` at line 773 correctly handles the case where the cancellation arrives *during* handler execution. But it cannot handle cancellation that arrives *after* the handler returns, because there's no async checkpoint between the handler return and `respond()`.
## Suggested fix
In `session.py`, change `respond()` to handle the already-completed case gracefully instead of asserting:
async def respond(self, response: SendResultT | ErrorData) -> None:
if not self._entered:
raise RuntimeError("RequestResponder must be used as a context manager")
# If already completed (e.g. by a concurrent cancellation), skip silently.
if self._completed:
return
if not self.cancelled:
self._completed = True
await self._session._send_response(
request_id=self.request_id, response=response
)
Alternatively, the guard could be added in `_handle_request` before calling `respond()`:
if not message._completed:
try:
await message.respond(response)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("Response for %s dropped - transport closed", message.request_id)
The first approach (in `respond()` itself) is more robust since it closes the race for all callers.Python & MCP Python SDK
- `mcp` 1.27.0
- Python 3.14
- anyio (asyncio backend)
- FastMCP stdio transport
- Client: Claude Code 2.1.92
Activity
fix: handle cancellation race in RequestResponder.respond()
fix: silence respond() when concurrent cancellation already completed…
chore(deps): update 5 dependencies, hold mcp at 1.26.0
chore(deps): update 5 dependencies, hold mcp at 1.26.0
cutlerbenjamin1-cmd commentedon Apr 19, 2026
Hit this same crash on Windows with Claude Code's stdio transport (MCP SDK 1.14.1, Python 3.12). The recent Claude Code update started sending
notifications/cancelledon user interrupt, which reliably triggers the race.The fix is two guards in
RequestResponder(session.py):respond()— replace the assert with an early return:cancel()— skip the error response if the handler already responded:Both directions are safe under cooperative multitasking since
_completedis set synchronously before the firstawaitin each method, so exactly one of them wins and sends a single JSON-RPC response. No double-response, no crash.I've got a branch with the fix + two unit tests (respond-after-cancel and cancel-after-respond) passing against main, full suite green (1171 passed). Happy to open a PR if that'd be useful.
Release v0.6.3: fix MCP server crash on host cancellation (python-sdk…
mlorentedev commentedon Jun 19, 2026
Confirming this reproduces downstream, and sharing the guard we're using.
hive is an MCP stdio server. A tool that offloads sync work (git) to a worker thread can call
message.respond()after anotifications/cancelledfor that request has already runresponder.cancel()(setting_completed = True).respond()then hitsassert not self._completedatmcp/shared/session.py:129, theAssertionErrorpropagates into the receive loop's task group, and every subsequent call on the shared process returnsConnection closed— a single rejected/cancelled call poisons the whole session.This matches the report exactly (handler done → cancel arrives →
respond()asserts). Onmcp1.26.0 the sibling__exit__leak (#2610) is already masked by theexcept anyio.get_cancelled_exc_class(): returnguard inServer._handle_request, but thisrespond()window is not, so it's still live.As a stopgap we self-gate a patch on
RequestResponder.respondthat short-circuits only when_completedis already set (debug-log instead of assert), so it goes inert the moment upstream guardsrespond():We back it with a deterministic in-memory regression test that drives a real lowlevel
Serverand sendsnotifications/cancelledmid-handler. Happy to open a PR porting a guardedrespond()plus that test if it'd help land #2416.refactor(compat): drop redundant RequestResponder.__exit__ patch (#248)
shivamsingh-007 commentedon Jul 11, 2026
I'd like to work on this. The fix is straightforward: replace \�ssert not self._completed\ with a silent early return in \RequestResponder.respond()\ so a cancellation notification that arrives after the handler returns doesn't crash the server. I'll also add a regression test. Opening a PR shortly.
docs: correct the compat-shim description in AGENTS.md (#336)
mlorentedev commentedon Aug 8, 2026
Following up on my June comment — we still carry this guard in production in hive (an MCP stdio server). Ours patches
respond()only, self-gated to the exact state (_completedalready set) so it goes inert the moment upstream guards it.#3088 was withdrawn by its author and #2418 has been dormant since April, so as far as I can tell nobody is currently on this.
If a fix would be accepted, I am happy to open a PR: early return in
respond()when_completedis already set, the matching skip incancel()so it does not send a second response, plus regression tests for both orderings (respond-after-cancel and cancel-after-respond).One question before I do — which base would you want it against,
main,v1.x, or both?fix(deps): re-narrow the mcp cap to <2.0 and correct its rationale (#345
NexuChat commentedon Aug 10, 2026
Confirming this on 1.29.0, with a variant that may be worth folding into the title: when the tool handler is synchronous, this is not a race — it is deterministic.
The report above describes a narrow window between the handler returning and
respond()being reached. That window is real, but with adefhandler (rather thanasync def) there is no window at all: the cancel scope has no checkpoint anywhere in the handler, soexcept anyio.get_cancelled_exc_class()atserver.py:773can never fire, and the normal-completion path at line 806 is always the one taken. Every cancellation of an in-flight sync handler ends in the assert.That matters for severity because it removes the timing luck. Reproduces on the first attempt, every time, with one process, one session, three sequential calls, and no concurrency:
fail_afteronly has to be shorter than the handler. The traceback matches the one above and unwinds throughmcp/server/stdio.py,fastmcp/server/mixins/transport.pyrun_stdio_async, and out of the server'smain()— the process exits.Two things this cost us downstream, in case they are useful as impact evidence. First, the failure names nothing true: the client sees an ordinary timeout on one call and a dead transport on every call after it, so we localised it only by running four concurrent workers and noticing the shape of the damage. Second, the damage is shaped by when rather than how much — four workers writing to the same backend lost 5 of 18 writes distributed 4/1/0 across three of them, which is simply the order in which each one first hit a timeout, because after that it could never write again.
cancel_on_abandondefaults toTruein the 2.x client (mcp/shared/dispatcher.py:98) and sendingnotifications/cancelledfor an abandoned request is what the spec asks for, so a client can only avoid this by never bounding a request at all — which is what we now do, at the cost of being unable to time-bound an in-flight call.The
respond()-side guard proposed above closes it for sync handlers too, and is the one we would prefer for that reason: the_handle_requestvariant still leaves any other caller ofrespond()exposed. Makingrespond()a no-op-with-warning rather than anassertalso seems worth doing on its own — anassertin a request-dispatch path is compiled out entirely underpython -O, and when it is not, it takes the process down.Ecocitizenz commentedon Aug 10, 2026
NexuChat commentedon Aug 10, 2026
Agreed on the invariant, and the phrasing is better than what I had. Putting it at the response boundary is what makes it hold for handlers the SDK does not control — a sync handler cannot be made to reach a checkpoint, and a server author has no way to know that the tool they wrote in
defhas quietly opted out of the guard.On the acceptance test being the operational outcome rather than the absent exception: I can offer a measurement for that, since we shipped the client-side workaround and watched the number move. Four processes writing concurrently through separate stdio sessions, each write confirmed by a bounded read-after-write poll, so the deadline occasionally expires with a request in flight.
The distribution is the part worth noticing. It is not proportional to load; it is the order in which each worker first hit a timeout, because after that it could never write again. That is why "the third call still succeeds" is the test that matters — the first exception disappearing tells you nothing about whether the session survived, and a fix that only stops the traceback would leave that 4 / 1 / 0 shape untouched.
Two small additions to the matrix, from the same failure. Cancellation of a request whose handler is already blocked in I/O behaves like the sync case even when the handler is
async, if the blocking call is not itself a checkpoint — worth a row because it makes the bug reachable from code that looks correctly written. And on the last row: a caller that gives up locally but leaves the request on the wire will have the next queued request wait behind the abandoned one. That is the right trade against a dead session, but it is a behaviour change worth pinning so nobody later "fixes" the queuing by reintroducing the cancel.Happy to contribute the repro as a test case if that is useful.
Ecocitizenz commentedon Aug 10, 2026
NexuChat commentedon Aug 10, 2026
Here is that test case, and running it turned up one thing worth folding into the invariant: guarding
respond()alone is not sufficient.cancel()is the other terminal path and has the same duplicate-transition problem — a repeatednotifications/cancelled, or a cancel arriving after the handler has already responded, still puts a second terminal response on the wire. The invariant as you stated it is right; it just has to be enforced at both doors.Verified as a sequence, on a clean
mcp==1.29.0:respond()onlyrespond()andcancel()And the operational assertion you singled out, end to end against a real stdio server (
mcp-server-datahub0.6.0, all handlers sync) with the guard in place:Unpatched, that third line is
MCPError: Connection closed.The test
Written against the responder rather than a subprocess so it runs in CI without spawning a server, and driving
RequestResponderdirectly because that is where the invariant lives.The guard the tests pass against
Same shape in both methods — refuse the second terminal transition, log which one it was, and return rather than raise:
and in
cancel(), beforeself._cancel_scope.cancel():The warning carries the request id and the attempted transition and nothing else, per your point about not logging arguments or tool output.
Three of the seven rows in your matrix are covered above. The remaining four — async cancellation at a checkpoint, completion immediately before cancellation, cancellation immediately before completion, and the measured queueing behind a still-running handler — want a subprocess or a timing harness rather than the in-memory pair, so I have left them out rather than write something that passes without exercising the ordering.
Happy to open this as a PR with the guard and these tests if that is wanted, or to leave it here for whoever picks the issue up.
Ecocitizenz commentedon Aug 10, 2026
Ecocitizenz commentedon Aug 10, 2026
NexuChat commentedon Aug 10, 2026
Agreed on the invariant as you have now stated it — one terminal state, once, whichever path arrives first — and the distinction you want kept visible is the right one. It is exactly what bit us downstream: the transport died while the write it was confirming may already have landed, so protocol-terminal and side-effect-occurred had to be tracked separately or a lost session would have been reported as a lost write.
Before opening the PR I checked where it should land, and the answer is not
main.mainis now v2.0.0 (released 2026-07-28) and has been refactored past this code path entirely — there is noRequestResponder, no_completed, and nomcp/shared/session.py; the dispatch surface isshared/dispatcher.py,shared/jsonrpc_dispatcher.py,shared/inbound.pyandshared/peer.py. The guard as written applies to the 1.x line, where the assertion still stands, and 1.29.0 shipped the same day as 2.0.0.So there are two different pieces of work and I would rather ask than guess:
respond()andcancel(), plus the three tests. Ready to submit against whichever branch that line is maintained on; I could not see one from the public branch list, so I do not know the target.Happy to do either. If 1.x is still receiving fixes, point me at the branch and I will open it with the tests. If the useful question is now whether 2.x has the same hole, I can test that instead and report before writing anything.
Either way the reproduction and the three-step result stand for 1.29.0, which is what
mcp-server-datahub0.6.0 and anything else pinned to the 1.x client are running today.NexuChat commentedon Sep 11, 2026
Still reproduces on 1.30.0 (released 2026-09-07), the latest v1.x — and it needs no DataHub or third-party server. Self-contained repro, 3 of 3 runs, every run on the first cancellation:
Server —
mcp==1.30.0, one tool whose work cannot observe cancellation (a sync call on a worker thread, which is what adeftool amounts to):Client —
mcp==2.2.0in a separate environment, defaultcancel_on_abandon; run aspython client.py /path/to/v1-env/bin/python server.py:Output:
and the server dies with
mcp/server/lowlevel/server.py", line 806, in _handle_request→AssertionError: Request already responded to.In 1.30.0 the normal-completion
respond()atserver.py:806is still unguarded and the bareassertatshared/session.py:129is unchanged. The two v1.x fix PRs that followed this issue (#2949, #3088) were closed unmerged, and #2418 targetsmain, where v2 no longer has this code path — so as far as I can tell there is currently no open fix aimed atv1.x.If a v1.x fix is still wanted, I'm happy to open a PR against
v1.xwith the guard on both terminal paths (respond()andcancel(), per the test case earlier in this thread) and this repro as a regression test.