Support client-side notifications/cancelled per MCP specification
## 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.
This commit is contained in:
parent
6bf0a069b5
commit
a4cd7c8cde
1 changed files with 779 additions and 42 deletions
|
|
@ -34,6 +34,9 @@ module MCP
|
|||
@started = false
|
||||
@initialized = false
|
||||
@server_info = nil
|
||||
# Serializes writes to `@stdin` so a request line and a notification line emitted from
|
||||
# different threads (e.g. cancellation) cannot interleave on the wire.
|
||||
@write_mutex = Mutex.new
|
||||
end
|
||||
|
||||
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
||||
|
|
@ -132,6 +135,10 @@ module MCP
|
|||
@initialized
|
||||
end
|
||||
|
||||
# Transports may yield once the request line has been written to `@stdin`.
|
||||
# `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled`
|
||||
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
|
||||
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
|
||||
def send_request(request:)
|
||||
start unless @started
|
||||
unless @initialized
|
||||
|
|
@ -139,10 +146,23 @@ module MCP
|
|||
connect
|
||||
end
|
||||
|
||||
write_message(request)
|
||||
@write_mutex.synchronize do
|
||||
write_message(request)
|
||||
yield if block_given?
|
||||
end
|
||||
read_response(request)
|
||||
end
|
||||
|
||||
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
|
||||
# `notifications/cancelled` for an in-flight request.
|
||||
def send_notification(notification:)
|
||||
start unless @started
|
||||
connect unless @initialized
|
||||
|
||||
@write_mutex.synchronize { write_message(notification) }
|
||||
nil
|
||||
end
|
||||
|
||||
def start
|
||||
raise "MCP::Client::Stdio already started" if @started
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue