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:
Koichi ITO 2026-04-30 02:41:16 +09:00
commit a4cd7c8cde
3 changed files with 779 additions and 42 deletions

View file

@ -107,6 +107,8 @@ module MCP
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional token; cancelling it sends
# `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call.
# @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
# and `next_cursor` (String or nil).
#
@ -118,9 +120,9 @@ module MCP
# cursor = page.next_cursor
# break unless cursor
# end
def list_tools(cursor: nil, meta: nil)
def list_tools(cursor: nil, meta: nil, cancellation: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "tools/list", params: params, meta: meta)
response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation)
result = response["result"] || {}
tools = (result["tools"] || []).map do |tool|
@ -141,6 +143,9 @@ module MCP
#
# Each call will make a new request - the result is not cached.
#
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# Cancelling it aborts whichever page is currently in flight; pages already returned are kept,
# but the call raises `MCP::CancelledError` instead of returning the partial set.
# @return [Array<MCP::Client::Tool>] An array of available tools.
#
# @example
@ -148,9 +153,9 @@ module MCP
# tools.each do |tool|
# puts tool.name
# end
def tools
def tools(cancellation: nil)
# TODO: consider renaming to `list_all_tools`.
fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
fetch_all_pages { |cursor| list_tools(cursor: cursor, cancellation: cancellation) }.flat_map(&:tools)
end
# Returns a single page of resources from the server.
@ -158,11 +163,12 @@ module MCP
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
# and `next_cursor` (String or nil).
def list_resources(cursor: nil, meta: nil)
def list_resources(cursor: nil, meta: nil, cancellation: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "resources/list", params: params, meta: meta)
response = request(method: "resources/list", params: params, meta: meta, cancellation: cancellation)
result = response["result"] || {}
ListResourcesResult.new(
@ -178,10 +184,11 @@ module MCP
#
# Each call will make a new request - the result is not cached.
#
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
# @return [Array<Hash>] An array of available resources.
def resources
def resources(cancellation: nil)
# TODO: consider renaming to `list_all_resources`.
fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources)
end
# Returns a single page of resource templates from the server.
@ -189,11 +196,12 @@ module MCP
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
# (Array<Hash>) and `next_cursor` (String or nil).
def list_resource_templates(cursor: nil, meta: nil)
def list_resource_templates(cursor: nil, meta: nil, cancellation: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "resources/templates/list", params: params, meta: meta)
response = request(method: "resources/templates/list", params: params, meta: meta, cancellation: cancellation)
result = response["result"] || {}
ListResourceTemplatesResult.new(
@ -209,10 +217,11 @@ module MCP
#
# Each call will make a new request - the result is not cached.
#
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
# @return [Array<Hash>] An array of available resource templates.
def resource_templates
def resource_templates(cancellation: nil)
# TODO: consider renaming to `list_all_resource_templates`.
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates)
end
# Returns a single page of prompts from the server.
@ -220,11 +229,12 @@ module MCP
# @param cursor [String, nil] Cursor from a previous page response.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
# and `next_cursor` (String or nil).
def list_prompts(cursor: nil, meta: nil)
def list_prompts(cursor: nil, meta: nil, cancellation: nil)
params = cursor ? { cursor: cursor } : nil
response = request(method: "prompts/list", params: params, meta: meta)
response = request(method: "prompts/list", params: params, meta: meta, cancellation: cancellation)
result = response["result"] || {}
ListPromptsResult.new(
@ -240,10 +250,11 @@ module MCP
#
# Each call will make a new request - the result is not cached.
#
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
# @return [Array<Hash>] An array of available prompts.
def prompts
def prompts(cancellation: nil)
# TODO: consider renaming to `list_all_prompts`.
fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts)
end
# Calls a tool via the transport layer and returns the full response from the server.
@ -256,6 +267,8 @@ module MCP
# e.g. the W3C Trace Context keys reserved by SEP-414
# (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
# `progress_token` takes precedence over a `progressToken` entry in `meta`.
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token. Cancelling it from another thread
# sends `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call.
# @return [Hash] The full JSON-RPC response from the transport.
#
# @example Call by name
@ -267,10 +280,19 @@ module MCP
# response = client.call_tool(tool: tool, arguments: { foo: "bar" })
# structured_content = response.dig("result", "structuredContent")
#
# @example Cancellable call
# 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")
#
# @note
# The exact requirements for `arguments` are determined by the transport layer in use.
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil)
tool_name = name || tool&.name
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
@ -282,7 +304,7 @@ module MCP
end
params[:_meta] = meta_entries unless meta_entries.empty?
request(method: "tools/call", params: params)
request(method: "tools/call", params: params, cancellation: cancellation)
end
# Reads a resource from the server by URI and returns the contents.
@ -290,9 +312,10 @@ module MCP
# @param uri [String] The URI of the resource to read.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [Array<Hash>] An array of resource contents (text or blob).
def read_resource(uri:, meta: nil)
response = request(method: "resources/read", params: { uri: uri }, meta: meta)
def read_resource(uri:, meta: nil, cancellation: nil)
response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation)
response.dig("result", "contents") || []
end
@ -302,9 +325,10 @@ module MCP
# @param name [String] The name of the prompt to get.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [Hash] A hash containing the prompt details.
def get_prompt(name:, meta: nil)
response = request(method: "prompts/get", params: { name: name }, meta: meta)
def get_prompt(name:, meta: nil, cancellation: nil)
response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation)
response.fetch("result", {})
end
@ -317,12 +341,13 @@ module MCP
# @param context [Hash, nil] Optional context with previously resolved arguments.
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
def complete(ref:, argument:, context: nil, meta: nil)
def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil)
params = { ref: ref, argument: argument }
params[:context] = context if context
response = request(method: "completion/complete", params: params, meta: meta)
response = request(method: "completion/complete", params: params, meta: meta, cancellation: cancellation)
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
end
@ -330,6 +355,9 @@ module MCP
# Sends a `ping` request to the server to verify the connection is alive.
# Per the MCP spec, the server responds with an empty result.
#
# @param meta [Hash, nil] Additional `_meta` entries to send with the request,
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
# @return [Hash] An empty hash on success.
# @raise [ServerError] If the server returns a JSON-RPC error.
# @raise [ValidationError] If the response `result` is missing or not a Hash.
@ -338,8 +366,8 @@ module MCP
# client.ping # => {}
#
# @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
def ping(meta: nil)
result = request(method: Methods::PING, meta: meta)["result"]
def ping(meta: nil, cancellation: nil)
result = request(method: Methods::PING, meta: meta, cancellation: cancellation)["result"]
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
result
@ -372,17 +400,21 @@ module MCP
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
# request-specific metadata such as W3C trace context (`traceparent`,
# `tracestate`, `baggage`); see {MCP::TraceContext}.
def request(method:, params: nil, meta: nil)
def request(method:, params: nil, meta: nil, cancellation: nil)
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
request_body = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
id: generate_request_id,
method: method,
}
request_body[:params] = params if params
response = transport.send_request(request: request_body)
response = if cancellation
dispatch_with_cancellation(request_body, cancellation)
else
transport.send_request(request: request_body)
end
# Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
if response.is_a?(Hash) && response.key?("error")
@ -393,8 +425,140 @@ module MCP
response
end
def request_id
# Generates a fresh JSON-RPC request id for an outgoing request.
# Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
# an `MCP::Cancellation` token instead.
def generate_request_id
SecureRandom.uuid
end
# Sends `request_body` while watching `cancellation`. The actual blocking `transport.send_request` runs on
# a worker thread; the calling thread waits on a Queue that is woken either by the response or by a cancel signal
# (whichever arrives first - matching the server-side `StreamableHTTPTransport#cancel_pending_request` race contract).
#
# When a cancel wins the race, the calling thread raises `MCP::CancelledError` immediately and the `notifications/cancelled`
# dispatch runs fire-and-forget on its own thread. We deliberately do not wait for that dispatch here: the calling thread
# must not be blocked by a slow or stalled transport write on the cancel path.
# The worker thread is also not force-killed; it stays blocked on the underlying I/O until the server actually responds
# (or the transport closes). This is the same trade-off the server-side `StreamableHTTPTransport#send_request` accepts and
# is noted in the README's Cancellation section.
def dispatch_with_cancellation(request_body, cancellation)
unless transport.respond_to?(:send_notification)
raise NoMethodError, "Cancellation support requires a transport that responds to `send_notification(notification:)` " \
"so `notifications/cancelled` can be delivered to the peer. The bundled `MCP::Client::Stdio` and `MCP::Client::HTTP` transports " \
"implement this interface; custom transports must add it before passing `cancellation:` to a request method."
end
cancellation.raise_if_cancelled!
request_id = request_body[:id]
queue = Queue.new
# First-writer-wins gate. Whichever side (worker or on_cancel) flips `completed` first owns the queue's single slot; the loser bails.
# This closes the late-cancel window between the worker pushing `:response` and the main thread completing `dispatch_with_cancellation`,
# where a callback firing in that gap would otherwise emit a stray `notifications/cancelled` for a request that already succeeded.
completion_mutex = Mutex.new
completed = false
sent_mutex = Mutex.new
sent_cond = ConditionVariable.new
request_sent = false
signal_sent = lambda do
sent_mutex.synchronize do
unless request_sent
request_sent = true
sent_cond.broadcast
end
end
end
Thread.new do
Thread.current.report_on_exception = false
begin
result = transport.send_request(request: request_body, &signal_sent)
completion_mutex.synchronize do
next if completed
completed = true
queue.push([:response, result])
end
rescue StandardError => e
completion_mutex.synchronize do
next if completed
completed = true
queue.push([:error, e])
end
ensure
# Unblock any waiting cancel-dispatch thread on completion (or error)
# so it does not stall when the transport ignored the block.
signal_sent.call
end
end
cancel_hook = cancellation.on_cancel do |reason|
should_dispatch = completion_mutex.synchronize do
next false if completed
completed = true
# Wake the waiting thread first, then dispatch the `notifications/cancelled` send on a separate thread.
# The wake-first ordering matters because the cancellation callback can run on the worker thread itself
# (e.g. a tool that triggers cancel from within `transport.send_request`), and a synchronous `send_notification`
# here would deadlock when the worker holds a transport-level mutex.
queue.push([:cancelled, reason])
true
end
next unless should_dispatch
Thread.new do
Thread.current.report_on_exception = false
# Wait for the worker's send-boundary signal before issuing `notifications/cancelled`. Bundled transports raise
# the signal via `&on_sent` from inside `send_request`; custom transports that ignore the block still raise it
# via the worker's `ensure -> signal_sent.call`, so the loop is bounded by worker termination rather than by wall-clock time.
# The previous fixed-duration fallback could release this thread before the worker reached its send-boundary at all,
# allowing the cancel to be issued without any prior request commitment - which the spec only covers under
# the receiver's MAY-ignore-unknown-id clause and is therefore avoided here.
sent_mutex.synchronize do
sent_cond.wait(sent_mutex) until request_sent
end
cancel(request_id: request_id, reason: reason)
rescue StandardError
# Swallow notification-send failures: the calling thread has already been woken with `:cancelled` above and
# is on its way to raising `MCP::CancelledError`.
end
end
tag, payload = queue.pop
case tag
when :response
payload
when :error
raise payload
when :cancelled
raise MCP::CancelledError.new(request_id: request_id, reason: payload)
end
ensure
cancellation&.off_cancel(cancel_hook) if cancel_hook
end
# Sends `notifications/cancelled` to the server for an in-flight request.
# Per spec, this is fire-and-forget: the server is expected to stop processing and suppress its response,
# with no acknowledgement returned. Driven internally by {#dispatch_with_cancellation} when a request's
# `cancellation` token fires.
def cancel(request_id:, reason: nil)
params = { requestId: request_id }
params[:reason] = reason if reason
notification = {
jsonrpc: JsonRpcHandler::Version::V2_0,
method: Methods::NOTIFICATIONS_CANCELLED,
params: params,
}
transport.send_notification(notification: notification)
nil
end
end
end

View file

@ -178,9 +178,18 @@ module MCP
end
# Sends a JSON-RPC request and returns the parsed response body.
# After a successful `initialize` handshake, the session ID and protocol
# version returned by the server are captured and automatically included
# on subsequent requests.
# After a successful `initialize` handshake, the session ID and protocol version returned by
# the server are captured and automatically included on subsequent requests.
#
# If a block is given, it is invoked just before Faraday's `post` is called.
# Faraday's synchronous `post` does not expose a post-write / pre-response hook,
# so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens
# inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release
# the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST
# that may overlap with the original request on the network. The spec covers this:
# the sender has issued the request and still believes it in-progress, and receivers MAY ignore
# a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
def send_request(request:)
method = request[:method] || request["method"]
params = request[:params] || request["params"]
@ -188,6 +197,7 @@ module MCP
step_up_retried = false
begin
yield if block_given?
response = client.post("", request, session_headers)
body = parse_response_body(response, method, params)
@ -274,6 +284,23 @@ module MCP
end
end
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
# `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted
# per the Streamable HTTP spec.
def send_notification(notification:)
method = notification[:method] || notification["method"]
client.post("", notification, session_headers)
nil
rescue Faraday::Error => e
raise RequestHandlerError.new(
"Failed to send #{method} notification",
{ method: method },
error_type: :internal_error,
original_error: e,
)
end
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
# with the current `Mcp-Session-Id` header, and clears locally tracked
# session state afterward. No-op when no session has been established.

View file

@ -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