Skip to content

mcp: deferred cancellation can target a new invocation reusing the request ID #1389

Closed

Description

@skosovsky

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:

  1. An incoming request A with ID 7 is running.
  2. The peer sends notifications/cancelled for ID 7. Preempt schedules go c.conn.CancelCause(...) and returns, without selecting the invocation to cancel.
  3. A completes independently. processResult removes A from incomingByID before writing its response.
  4. After receiving A's response, the peer sends request B with ID 7, which is registered in incomingByID.
  5. 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

Activity

  1. added a commit that references this issue on Oct 13, 2026
    7cdd99f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions