Skip to content

Client ignores legacy reverse-request cancellation and may cancel an unrelated outbound request #1251

Description

@aurokin

Summary

In a legacy MCP session, a server's notifications/cancelled does not cancel the matching server-originated request's RequestContext.ct. The RoleClient service loop removes an outbound response waiter instead of cancelling the inbound request token. If an unrelated outbound request has the same ID, its waiter can be cancelled instead.

Reproduced on current main at 302319861a4b5ab538f6aebf25befdc3c7dfe039. The same routing is present in the released rmcp 3.2.0 source.

Reproduction

  1. Complete an initialize handshake selecting protocol version 2025-11-25.
  2. Have the client's create_elicitation handler signal that it has started, then await context.ct.cancelled().
  3. Send this request from the server:
{"jsonrpc":"2.0","id":"elicitation-1","method":"elicitation/create","params":{"message":"Continue?","requestedSchema":{"type":"object","properties":{}}}}
  1. After the handler starts, send:
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"elicitation-1"}}
  1. Keep the connection open. The handler's token remains active. Sending a subsequent ping confirms the service can still process traffic.

Runnable regression against unpatched main

The regression file contains a raw-peer reproduction. The following commands copy only that test file into a fresh checkout of unpatched upstream; no production source changes are applied:

git clone https://github.com/modelcontextprotocol/rust-sdk.git rmcp-cancel-repro
cd rmcp-cancel-repro
git switch --detach 302319861a4b5ab538f6aebf25befdc3c7dfe039
git fetch https://github.com/aurokin/rust-sdk.git a389b20049bad9de1d165951366075b124793840
git show a389b20049bad9de1d165951366075b124793840:crates/rmcp/tests/test_cancelled_response.rs \
  > crates/rmcp/tests/test_cancelled_response.rs
cargo test -p rmcp --test test_cancelled_response \
  --features client,elicitation,transport-io

On unpatched main, three tests pass and two fail:

  • peer_cancels_reverse_request_without_cancelling_outbound_request detects that an unrelated outbound waiter is no longer pending after cancellation of an ID that does not identify an inbound request.
  • cancelled_reverse_request_stays_suppressed_during_eof_drain times out waiting for the handler's cancellation event. This happens before the test sends EOF or closes the connection, so the failure is the missing token cancellation, not a disconnect-handling issue.

The reverse-request cases cover a normal legacy handshake, directly configured legacy peers, and direct peers without handshake information. They also cover equal IDs in opposite directions and distinct string/numeric IDs.

Expected behavior

For legacy sessions, cancellation should remove and cancel the exact matching inbound request token. Unknown IDs must not affect an outbound request merely because it has the same ID.

The handler should observe RequestContext.ct without a connection shutdown. Unsent responses should be suppressed as recommended by the cancellation specification, while already-sent response races remain valid. The connection should remain usable.

Root cause

In the incoming notification branch, R::IS_CLIENT selects local_responder_pool.remove(request_id). That pool tracks locally originated requests. Tokens for peer-originated requests are in local_ct_pool.

This is separate from #1193, which concerns cancellation of an outgoing Streamable HTTP request before its response stream starts, and #857, which concerned TCP disconnects.

Proposed approach for discussion

Route incoming cancellation by the established lifecycle and request direction:

  • Legacy peer cancellation cancels the exact matching inbound token.
  • Modern server cancellation retires only an actual outbound subscriptions/listen request, including its response waiter. Removing outbound cleanup entirely would break subscription termination.
  • Successful discovery retains modern lifecycle semantics even when it selects an older application version. Existing startup metadata can preserve that distinction.

Keep string and numeric IDs distinct, preserve uncancelled response draining during graceful shutdown, and apply normal peer-cancellation suppression during EOF draining. No dependency changes or outgoing server-teardown redesign are needed.

I have a candidate fix and regressions, but have not opened a PR. Does this lifecycle-aware split match the intended behavior? I'm opening the issue first for feedback, following docs/CONTRIBUTE.MD.

Protocol references

Activity

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

    P1High: significant functionality gap or spec violationT-serviceService layer changesbugSomething is not workingready for workIssue is well-defined and ready to be picked up

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions