fix: send notifications/cancelled on request timeout and caller cancellation - #2514
dragogargo wants to merge 8 commits into
Conversation
…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.
|
CI note: Ubuntu checks all pass (10/10). Windows failures are from |
|
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. |
Motivation and Context
Fixes #2507.
ClientSession.send_request()never emits anotifications/cancelledmessage when a request is interrupted — either by the SDK's ownanyio.fail_aftertimeout 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_toolinvocations → 50 leaked server-side coroutines.The server already handles
CancelledNotificationcorrectly (cancels the in-flight request). Only the client-side emit is missing.Changes
TimeoutError): sendnotifications/cancelledbefore raisingMCPErrorCancelledError): catchanyio.get_cancelled_exc_class(), send notification, re-raise_send_cancelled_notificationhelper: builds and sends the notification inside a shielded cancel scope to guarantee delivery even when called from a cancelled taskHow Has This Been Tested?
test_send_request_sends_cancelled_notification_on_timeout— verifies the notification is sent whenread_timeout_secondstriggerstest_send_request_sends_cancelled_notification_on_caller_cancel— verifies the notification is sent when the caller cancels the task externallytest_session.pytests passBreaking 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
Checklist