Skip to content

Fix dropped notifications/cancelled when a request is canceled - #1917

Open
zerotohero3 wants to merge 1 commit into
modelcontextprotocol:mainfrom
zerotohero3:fix/cancellation-notifications-dropped
Open

zerotohero3 wants to merge 1 commit into
modelcontextprotocol:mainfrom
zerotohero3:fix/cancellation-notifications-dropped

Conversation

@zerotohero3

@zerotohero3 zerotohero3 commented Oct 11, 2026 •

Copy link
Copy Markdown

Refs #1365. This fixes the stdio, stream and legacy SSE part. The Streamable HTTP part is not addressed here (see Scope).

Motivation and Context

Canceling the CancellationToken passed to a request (for example CallToolAsync) is supposed to send notifications/cancelled to the peer so the remote handler gets canceled. Today that notification is never sent, and a second bug can make the receiver ignore it even when it does arrive.

1. The cancellation callback is unregistered before it runs

SendRequestAsync registers the callback that sends notifications/cancelled, then awaits tcs.Task.WaitAsync(cancellationToken) inside a using for that registration. WaitAsync registers on the same token afterwards, so when the token is canceled:

  1. Its callback runs first, because callbacks run in reverse order of registration.
  2. The await continuation runs synchronously as part of Cancel().
  3. The continuation leaves the using block and disposes the notification registration before its callback has run.

The fix starts the wait before registering the notification callback, so that callback runs first.

2. Incoming responses clobber _handlingRequests entries

ProcessMessagesCoreAsync puts every incoming JsonRpcMessageWithId into _handlingRequests, including responses to our own requests, and removes entries by key only. Request ids are allocated independently by each side, both starting at 1, so collisions are expected. For example, on a pre-MRTR protocol version a tool that calls ElicitAsync gets a response whose id can match the in-flight tools/call. That response replaces the tools/call's entry and then removes it, so a later notifications/cancelled for the tools/call is ignored.

The fix:

  • Only incoming requests are tracked.
  • An entry is only removed if it is the one this message registered.

The compare-and-remove uses the ICollection<KeyValuePair<,>>.Remove idiom already used in McpServerPrimitiveCollection.

How Has This Been Tested?

I added these tests to CancellationTests:

  • CancellationPropagation_CancelingRequest_CancelsServerHandler runs on the default and 2025-11-25 protocol versions. It asserts that the server-side tool's token is canceled after the client cancels.
  • CancellationPropagation_ResponseWithSameIdAsInFlightRequest_DoesNotPreventCancellation covers a tool that elicits on 2025-11-25 and is then canceled, with a colliding request id.

Before and after:

  • Both tests fail on main; the tool is never canceled.
  • With only fix 1, the first passes and the second still fails.
  • With both fixes, both pass.

The existing tests didn't catch this because they only assert that the client observes OperationCanceledException, which WaitAsync produces either way.

I ran the full suites on net10.0 against both main and this branch, and they show the same set of failures:

  • ModelContextProtocol.Tests: 2,368 passed. The 26 failures are identical on both, all in ClientIntegrationTests, which needs npx; my environment doesn't have it.
  • ModelContextProtocol.AspNetCore.Tests (excluding the OAuth namespace): 467 passed, 0 failed. The OAuth tests hang in my local container on main and on this branch alike.

ModelContextProtocol.Core builds with no warnings for netstandard2.0, net8.0, net9.0 and net10.0. The new tests passed 10 consecutive runs.

Scope

For Streamable HTTP, the transport's SendMessageAsync doesn't return until the POST's response is read, so RegisterCancellation is never reached while a request is in flight. Canceling the token aborts the POST instead:

  • On 2026-07-28 and stateless servers, that already cancels the handler.
  • On stateful 2025-11-25 sessions, it doesn't.

Fixing that needs a design decision (see #1377), so it is left out of this PR. #1377 also adds an await Task.Delay(500) before the registration is disposed to give the notification time to go out. This PR fixes the underlying ordering issue, so that delay isn't needed.

An alternative to the reordering in fix 1 would avoid relying on callback order. The registration's callback would complete tcs itself, which is safe because it uses RunContinuationsAsynchronously, and WaitAsync would be dropped. I went with the smaller change, but I'm happy to switch.

Breaking Changes

None. The behavior change is that remote handlers now actually get canceled when the caller cancels, which is what the API already implies.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

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

Canceling the CancellationToken passed to a request never sent
notifications/cancelled to the peer, so remote handlers kept running.
SendRequestAsync registered the callback that sends the notification
and then awaited tcs.Task.WaitAsync(cancellationToken) inside a using
for that registration. Cancellation callbacks run in reverse order of
registration, so WaitAsync's callback ran first, its continuation ran
synchronously as part of Cancel(), and the using disposed the
notification registration before its callback had run. Start the wait
before registering so the notification callback runs first.

Separately, ProcessMessagesCoreAsync tracked every incoming message
with an ID in _handlingRequests, including responses to our own
requests, and removed entries by key only. Since each side allocates
request IDs independently, a response could replace and then remove
the entry of an in-flight request with the same ID, causing a later
notifications/cancelled for that request to be ignored. Track only
incoming requests, and only remove the entry a message registered.

Refs modelcontextprotocol#1365

This branch has not been deployed

No deployments
Sign up for free to 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.

1 participant