Skip to content

fix: send notifications/cancelled on request timeout and caller cancellation - #2514

Closed
dragogargo wants to merge 8 commits into
modelcontextprotocol:mainfrom
dragogargo:fix/send-cancelled-notification-2507
Closed

dragogargo wants to merge 8 commits into
modelcontextprotocol:mainfrom
dragogargo:fix/send-cancelled-notification-2507

Conversation

@dragogargo

Copy link
Copy Markdown

Motivation and Context

Fixes #2507. ClientSession.send_request() never emits a notifications/cancelled message when a request is interrupted — either by the SDK's own anyio.fail_after timeout or by external cancellation (e.g. asyncio.wait_for). The MCP spec requires the sender to issue this notification on timeout, and cooperative cancellation likewise needs it.

Impact: Server-side tool coroutines remain suspended after a client cancellation, holding DB connections, cursors, locks, and file handles until the session ends. With long-lived sessions, every cancelled call is a silent resource leak. The issue author demonstrated 50 cancelled call_tool invocations → 50 leaked server-side coroutines.

The server already handles CancelledNotification correctly (cancels the in-flight request). Only the client-side emit is missing.

Changes

  1. Timeout path (TimeoutError): send notifications/cancelled before raising MCPError
  2. External cancellation path (CancelledError): catch anyio.get_cancelled_exc_class(), send notification, re-raise
  3. _send_cancelled_notification helper: builds and sends the notification inside a shielded cancel scope to guarantee delivery even when called from a cancelled task
  4. Two regression tests: one for each cancellation path, using in-memory streams

How Has This Been Tested?

  • test_send_request_sends_cancelled_notification_on_timeout — verifies the notification is sent when read_timeout_seconds triggers
  • test_send_request_sends_cancelled_notification_on_caller_cancel — verifies the notification is sent when the caller cancels the task externally
  • All 10 existing test_session.py tests pass

Breaking Changes

No. The only behavioral change is that the client now sends an additional notification that the server already expects and handles.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

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

dragogargo added 8 commits April 27, 2026 20:23
…llation

ClientSession.send_request() never emitted a notifications/cancelled
message when a request was interrupted, either by the SDK's own timeout
or by the caller's cancellation. This violated the MCP spec and caused
server-side tool coroutines to leak — they remained suspended holding
resources (DB connections, locks, file handles) until the session ended.

Handle both cancellation paths:
- TimeoutError from anyio.fail_after: send notification before raising
- External CancelledError from caller: catch, send notification, re-raise

The notification is sent inside a shielded cancel scope to guarantee
delivery even when called from a cancelled task. The server's existing
CancelledNotification handler already cancels in-flight requests on
receipt, so no server-side changes are needed.

Fixes modelcontextprotocol#2507
The shielded CancelScope in _send_cancelled_notification could block
indefinitely if the write stream was full or closed during shutdown.
Add a 2-second timeout inside the shield so the notification is
best-effort but never blocks session cleanup.
Replace elif with separate if statements and remove unnecessary
if-pass blocks to avoid uncovered branch arcs in coverage.py.
strict-no-cover correctly identified that cancel() guards and
notification validation are now exercised by the new cancellation tests.
Remove the outdated pragmas per AGENTS.md guidelines.
Line 147-148 is unreachable: _cancel_scope is always set in __init__.
The previous pragma removal was too aggressive - only remove pragmas
from lines that are actually covered by the new tests.
@dragogargo

Copy link
Copy Markdown
Author

CI note: Ubuntu checks all pass (10/10). Windows failures are from test_notification_validation_error in tests/issues/test_88_random_error.py — unrelated to this PR's changes (session.py + test_session.py).

@maxisbey

maxisbey commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for the PR, and sorry it sat here without a proper review. This has since landed via #2838.

We're closing most of the open PR backlog. v2 is out and changed a lot of the SDK, so many older PRs no longer apply as written, and we're a small team that realistically doesn't have the capacity to work through the rest.

If this still matters to you on v2, the most useful thing you can do is open an issue (or comment on the existing one) with your use case and a repro. Hearing why it matters to you is what we use to decide what to prioritise.

AI Disclaimer

@maxisbey maxisbey closed this Oct 5, 2026
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.

ClientSession never sends notifications/cancelled when call_tool is cancelled — server-side coroutines leak

2 participants