Repository navigation
mcp: deferred cancellation can target a new invocation reusing the request ID #1389
Description
Describe the bug
canceller.Preempt launches Connection.CancelCause in a goroutine. The lookup of the incoming request by ID therefore happens after the cancellation notification has already been processed by the read loop. If the old call completes and another call is admitted with the same ID before that goroutine runs, the cancellation targets the new invocation.
This is independent of numeric ID precision: small integer IDs such as 7 are enough.
To reproduce / ordering
On main at 8c761a2d6711d0ad92b363fe6230547d579a8a23:
- An incoming request A with ID 7 is running.
- The peer sends
notifications/cancelledfor ID 7.Preemptschedulesgo c.conn.CancelCause(...)and returns, without selecting the invocation to cancel. - A completes independently.
processResultremoves A fromincomingByIDbefore writing its response. - After receiving A's response, the peer sends request B with ID 7, which is registered in
incomingByID. - The deferred cancellation goroutine performs its lookup and cancels B.
The relevant code is in mcp/transport.go (canceller.Preempt) and internal/jsonrpc2/conn.go (CancelCause / processResult). CancelCause itself captures the request under the connection lock and then cancels that captured request; deferring the entire call loses that protection across the preemption boundary.
MCP 2026-07-28 prohibits reusing IDs of requests for which the sender has not yet received a response; the sequence above reuses the ID after receipt: https://modelcontextprotocol.io/specification/2026-07-28/basic#requests . This is distinct from simultaneous duplicate in-flight IDs in #1004.
Expected behavior
The cancellation is bound to the invocation present when the notification is handled. A new invocation must not inherit the old cancellation. Unrelated IDs and the peer-provided cancellation cause must remain unaffected.
Regression evidence
A new package-level regression uses the real JSON-RPC connection and MCP canceller, with channel barriers for handler admission and the old response writer. It checks that preemption has cancelled the selected invocation before returning, retains the peer's reason, leaves a neighboring request active, and preserves the replacement invocation and its cancellation entry across the old response's completion. The writer barrier deliberately exercises the internal map-retirement boundary; it is not claimed to be a timing-independent end-to-end reproduction of every wire interleaving above.
With only the test added to the unmodified main revision:
--- FAIL: TestCancellationBindsBeforeRequestIDReuse
cancellation_reuse_test.go:100: preempter returned before selected invocation was cancelled
Making CancelCause synchronous at the preemption boundary passes the regression and the existing cancellation tests (-race -count=20). Full tests, vet, and build also pass. The fix does not wait for the tool handler to finish or write a response.
Additional context
- Go: go1.27.1 darwin/arm64.
- A cancelled request is still answered #1259 / mcp: send no response for a cancelled request on 2026-07-28 stdio sessions #1267 concern responding after peer cancellation. The current mcp: send no response for a cancelled request on 2026-07-28 stdio sessions #1267 diff still schedules
CancelFromPeerin a goroutine, so that path would need the same invocation-binding consideration. - mcp: a response that has already arrived is discarded when the caller's context is cancelled during decoding, and notifications/cancelled is sent for the completed request #1368 / mcp: keep a delivered response when the caller's context is cancelled #1370 concern client-side response/cancellation ordering.
- jsonrpc: numeric request IDs above 2^53 lose precision #1376 / jsonrpc: preserve integer request ID precision #1375 concern numeric identity precision, not this scheduling race.
- The separate protocol conformance scripts were not run for this patch.
Activity
mcp: bind peer cancellation before request ID reuse (modelcontextprot…