Support client-side notifications/cancelled per MCP specification#425
Open
koic wants to merge 1 commit into
Open
Support client-side notifications/cancelled per MCP specification#425koic wants to merge 1 commit into
notifications/cancelled per MCP specification#425koic wants to merge 1 commit into
Conversation
b97de43 to
3a07a03
Compare
## Motivation and Context
The server-side half of MCP `notifications/cancelled` shipped earlier and lets handlers
observe cancellation via `server_context.cancelled?` / `raise_if_cancelled!`.
This PR delivers the client-side equivalent that was deferred at the time:
an `MCP::Client` user can now cancel a request they have already issued and have
the calling thread wake up, matching the Python SDK
(`anyio.CancelScope`) and TypeScript SDK (`AbortSignal`) ergonomics.
The recommended pattern is to pass an `MCP::Cancellation` token into the request method,
run the request on a worker thread, and call `cancellation.cancel(reason:)` from another thread.
The cancelling thread sends `notifications/cancelled` to the server, and the calling thread is
woken up with `MCP::CancelledError`:
```ruby
cancellation = MCP::Cancellation.new
Thread.new do
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
rescue MCP::CancelledError
# cleanup
end
cancellation.cancel(reason: "user pressed cancel")
```
For low-level use, `Client#cancel(request_id:, reason:)` sends the notification without managing
the calling thread, and `Client#generate_request_id` lets callers pre-allocate an id before kicking off
a request on another thread. The `request_id:` and `cancellation:` keywords are accepted by
every request method (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`,
`list_resource_templates`, `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`,
`complete`, `ping`).
When `cancellation:` is supplied, the actual blocking `transport.send_request` runs on a worker thread;
the calling thread waits on a `Queue` woken either by the response or by the cancel signal
(whichever arrives first - the same race contract as the server-side `StreamableHTTPTransport#cancel_pending_request`).
On a normal completion the `on_cancel` hook is deregistered so a late cancel does not emit a stray `notifications/cancelled`.
The worker thread is *not* force-killed when a cancel wins the race; it stays blocked on the underlying I/O until
the transport actually returns (or the user closes it). For `Client::HTTP` the leak resolves as soon as
the server sends any response; for `Client::Stdio` the user may need to call `client.transport.close`
if the server stops responding entirely.
`Client::Stdio` and `Client::HTTP` gain `send_notification(notification:)` so `Client#cancel` can deliver
the JSON-RPC notification through the existing transport plumbing. Custom transports that do not implement
`send_notification` cause `Client#cancel` to raise `NoMethodError`.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
## How Has This Been Tested?
`test/mcp/client_test.rb` covers `Client#cancel` payload (with and without `reason`),
`Client#generate_request_id` returning distinct UUIDs, every request method threading `request_id:` through,
and the full `cancellation:` flow:
- Returns the response when no cancel happens (no behaviour regression for callers that do not pass a token).
- Pre-cancelled token raises `MCP::CancelledError` immediately and never reaches the transport
(no stray request, no stray notification).
- Mid-flight cancel raises `MCP::CancelledError` carrying the supplied reason and sends `notifications/cancelled`
through the transport.
- Late cancel after a normal completion does not emit a stray notification
(the `on_cancel` hook is deregistered in the `ensure` block).
`test/mcp/client/stdio_test.rb` covers `send_notification` writing the JSON line through the spawned process
and returning `nil` without waiting for a response.
`test/mcp/client/http_test.rb` covers `send_notification` POSTing the body, tolerating HTTP 202 Accepted,
and surfacing Faraday errors as `RequestHandlerError`.
## Breaking Change
None. All new keywords (`request_id:`, `cancellation:`) on request methods are optional and default to `nil`.
Existing callers that do not pass them get the original synchronous behaviour. Custom transports that already
implement `send_request(request:)` continue to work; only callers of `Client#cancel` need a transport
that responds to `send_notification`, and the failure mode is a clear `NoMethodError` rather than silent breakage.
3a07a03 to
a4cd7c8
Compare
atesgoral
approved these changes
Jun 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
The server-side half of MCP
notifications/cancelledshipped earlier and lets handlers observe cancellation viaserver_context.cancelled?/raise_if_cancelled!. This PR delivers the client-side equivalent that was deferred at the time: anMCP::Clientuser can now cancel a request they have already issued and have the calling thread wake up, matching the Python SDK (anyio.CancelScope) and TypeScript SDK (AbortSignal) ergonomics.The recommended pattern is to pass an
MCP::Cancellationtoken into the request method, run the request on a worker thread, and callcancellation.cancel(reason:)from another thread. The cancelling thread sendsnotifications/cancelledto the server, and the calling thread is woken up withMCP::CancelledError:For low-level use,
Client#cancel(request_id:, reason:)sends the notification without managing the calling thread, andClient#generate_request_idlets callers pre-allocate an id before kicking off a request on another thread. Therequest_id:andcancellation:keywords are accepted by every request method (tools,list_tools,resources,list_resources,resource_templates,list_resource_templates,prompts,list_prompts,call_tool,read_resource,get_prompt,complete,ping).When
cancellation:is supplied, the actual blockingtransport.send_requestruns on a worker thread; the calling thread waits on aQueuewoken either by the response or by the cancel signal (whichever arrives first - the same race contract as the server-sideStreamableHTTPTransport#cancel_pending_request). On a normal completion theon_cancelhook is deregistered so a late cancel does not emit a straynotifications/cancelled. The worker thread is not force-killed when a cancel wins the race; it stays blocked on the underlying I/O until the transport actually returns (or the user closes it). ForClient::HTTPthe leak resolves as soon as the server sends any response; forClient::Stdiothe user may need to callclient.transport.closeif the server stops responding entirely.Client::StdioandClient::HTTPgainsend_notification(notification:)soClient#cancelcan deliver the JSON-RPC notification through the existing transport plumbing. Custom transports that do not implementsend_notificationcauseClient#cancelto raiseNoMethodError.Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
How Has This Been Tested?
test/mcp/client_test.rbcoversClient#cancelpayload (with and withoutreason),Client#generate_request_idreturning distinct UUIDs, every request method threadingrequest_id:through, and the fullcancellation:flow:MCP::CancelledErrorimmediately and never reaches the transport (no stray request, no stray notification).MCP::CancelledErrorcarrying the supplied reason and sendsnotifications/cancelledthrough the transport.on_cancelhook is deregistered in theensureblock).test/mcp/client/stdio_test.rbcoverssend_notificationwriting the JSON line through the spawned process and returningnilwithout waiting for a response.test/mcp/client/http_test.rbcoverssend_notificationPOSTing the body, tolerating HTTP 202 Accepted, and surfacing Faraday errors asRequestHandlerError.Breaking Change
None. All new keywords (
request_id:,cancellation:) on request methods are optional and default tonil. Existing callers that do not pass them get the original synchronous behaviour. Custom transports that already implementsend_request(request:)continue to work; only callers ofClient#cancelneed a transport that responds tosend_notification, and the failure mode is a clearNoMethodErrorrather than silent breakage.Types of changes
Checklist