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
8 changed files with 779 additions and 42 deletions
63
README.md
63
README.md
|
|
@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
|
|||
- Supports roots (server-to-client filesystem boundary queries)
|
||||
- Supports sampling (server-to-client LLM completion requests)
|
||||
- Supports cursor-based pagination for list operations
|
||||
- Supports server-side cancellation of in-flight requests (notifications/cancelled)
|
||||
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
|
||||
|
||||
### Supported Methods
|
||||
|
||||
|
|
@ -1205,12 +1205,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
|
|||
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
|
||||
is never cancellable per the spec.
|
||||
|
||||
> [!NOTE]
|
||||
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
|
||||
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
|
||||
> from the client side can be done by constructing the notification payload and writing it
|
||||
> directly through the transport, but the calling thread does not yet unwind automatically.
|
||||
> This is tracked as a follow-up.
|
||||
Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below.
|
||||
|
||||
#### Server-Side: Handlers that Check for Cancellation
|
||||
|
||||
|
|
@ -1319,6 +1314,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
|
|||
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
|
||||
via `server_context.cancelled?` between nested calls.
|
||||
|
||||
#### Client-Side: Cancelling an In-Flight Request
|
||||
|
||||
`MCP::Client` lets the caller cancel a request it has already issued. 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
|
||||
client = MCP::Client.new(transport: transport)
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
Thread.new do
|
||||
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
||||
rescue MCP::CancelledError
|
||||
# cleanup
|
||||
end
|
||||
|
||||
# Later, from another thread:
|
||||
cancellation.cancel(reason: "user pressed cancel")
|
||||
```
|
||||
|
||||
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
||||
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword.
|
||||
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
||||
|
||||
> [!NOTE]
|
||||
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
||||
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
||||
> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
||||
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
||||
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
||||
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
||||
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
||||
|
||||
##### Wire-order guarantees
|
||||
|
||||
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
||||
so the server is guaranteed to read the request line before the cancel line.
|
||||
|
||||
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
||||
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
||||
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
||||
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
|
||||
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
||||
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
||||
|
||||
##### Custom transports
|
||||
|
||||
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
||||
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
||||
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
||||
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
||||
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
||||
|
||||
### Ping
|
||||
|
||||
The MCP Ruby SDK supports the
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -942,6 +942,37 @@ module MCP
|
|||
assert_equal("2025-11-25", client.protocol_version)
|
||||
end
|
||||
|
||||
def test_send_notification_posts_body_and_accepts_202
|
||||
notification = {
|
||||
jsonrpc: "2.0",
|
||||
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
||||
params: { requestId: "req-1", reason: "user cancel" },
|
||||
}
|
||||
|
||||
stub_request(:post, url).with(body: notification.to_json).to_return(status: 202, body: "")
|
||||
|
||||
result = client.send_notification(notification: notification)
|
||||
|
||||
assert_nil(result, "send_notification must return nil")
|
||||
end
|
||||
|
||||
def test_send_notification_surfaces_faraday_errors
|
||||
notification = {
|
||||
jsonrpc: "2.0",
|
||||
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
||||
params: { requestId: "req-1" },
|
||||
}
|
||||
|
||||
stub_request(:post, url).to_return(status: 500)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_notification(notification: notification)
|
||||
end
|
||||
|
||||
assert_equal(:internal_error, error.error_type)
|
||||
assert_match(%r{notifications/cancelled}, error.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_session
|
||||
|
|
|
|||
|
|
@ -1226,6 +1226,121 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
def test_send_notification_writes_json_line_without_waiting_for_response
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||
|
||||
notification = {
|
||||
jsonrpc: "2.0",
|
||||
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
||||
params: { requestId: "abc-123", reason: "user cancel" },
|
||||
}
|
||||
|
||||
# Server: respond to initialize, then read the notification line.
|
||||
server_thread = Thread.new do
|
||||
init_line = stdin_read.gets
|
||||
init_request = JSON.parse(init_line)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: init_request["id"],
|
||||
result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test", version: "1.0.0" } },
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
stdin_read.gets # skip initialized notification
|
||||
|
||||
stdin_read.gets # read the cancellation notification line we are testing
|
||||
end
|
||||
|
||||
result = transport.send_notification(notification: notification)
|
||||
|
||||
assert_nil(result, "send_notification must return nil (no response expected)")
|
||||
ensure
|
||||
server_thread.join
|
||||
stdin_read.close
|
||||
stdin_write.close
|
||||
stdout_read.close
|
||||
stdout_write.close
|
||||
end
|
||||
|
||||
def test_concurrent_write_message_does_not_interleave_lines
|
||||
# Regression: cancellation can call `send_notification` from a thread while `send_request` is mid-flight
|
||||
# on another thread. The two writes must serialize so JSON-RPC lines do not interleave on stdin.
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
||||
|
||||
# Server: respond to initialize, then accept multiple lines.
|
||||
server_thread = Thread.new do
|
||||
init_line = stdin_read.gets
|
||||
init_request = JSON.parse(init_line)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: init_request["id"],
|
||||
result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "t", version: "1.0.0" } },
|
||||
}))
|
||||
stdout_write.flush
|
||||
stdin_read.gets # initialized
|
||||
end
|
||||
|
||||
# Force initialization synchronously.
|
||||
notification = {
|
||||
jsonrpc: "2.0",
|
||||
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
||||
params: { requestId: "warmup" },
|
||||
}
|
||||
transport.send_notification(notification: notification)
|
||||
server_thread.join
|
||||
|
||||
# Now hammer send_notification from many threads concurrently. Each line is one JSON object terminated by `\n`;
|
||||
# if writes interleave, the received text would not be one valid JSON object per line.
|
||||
threads = 8.times.map do |i|
|
||||
Thread.new do
|
||||
10.times do |j|
|
||||
transport.send_notification(
|
||||
notification: {
|
||||
jsonrpc: "2.0",
|
||||
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
||||
params: { requestId: "t#{i}-#{j}" },
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
threads.each(&:join)
|
||||
|
||||
# Drain everything written to stdin and verify each line parses as JSON.
|
||||
stdin_write.close
|
||||
lines = []
|
||||
until stdin_read.eof?
|
||||
line = stdin_read.gets
|
||||
break if line.nil?
|
||||
|
||||
lines << line
|
||||
end
|
||||
lines.each do |line|
|
||||
assert(JSON.parse(line.strip), "interleaved write produced unparseable line: #{line.inspect}")
|
||||
end
|
||||
assert_operator(lines.size, :>=, 80, "expected at least 80 notification lines")
|
||||
ensure
|
||||
stdout_read.close
|
||||
stdout_write.close
|
||||
begin
|
||||
stderr_read.close
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_implicit_connect_deprecation_warning(&block)
|
||||
|
|
|
|||
|
|
@ -1142,5 +1142,336 @@ module MCP
|
|||
assert_equal(1, result.prompts.size)
|
||||
assert_equal("cursor1", result.next_cursor)
|
||||
end
|
||||
|
||||
def test_cancellation_without_reason_omits_reason_in_notification
|
||||
# The token-driven cancel path must omit `reason` from the `notifications/cancelled` params
|
||||
# when the token is cancelled without one.
|
||||
sent = Queue.new
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |**|
|
||||
cancellation.cancel # no reason
|
||||
{ "result" => {} }
|
||||
end
|
||||
transport.define_singleton_method(:send_notification) do |notification:|
|
||||
sent.push(notification)
|
||||
nil
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
notification = sent.pop
|
||||
assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method])
|
||||
refute(notification[:params].key?(:reason))
|
||||
end
|
||||
|
||||
def test_call_tool_with_cancellation_returns_response_when_no_cancel
|
||||
transport = mock
|
||||
transport.expects(:send_request).returns({ "result" => { "content" => [{ "type" => "text", "text" => "ok" }] } }).once
|
||||
transport.stubs(:send_notification) # required for `cancellation:` capability check
|
||||
|
||||
cancellation = MCP::Cancellation.new
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
|
||||
|
||||
assert_equal("ok", response.dig("result", "content", 0, "text"))
|
||||
end
|
||||
|
||||
def test_call_tool_with_cancellation_already_cancelled_raises_without_sending
|
||||
transport = mock
|
||||
transport.expects(:send_request).never
|
||||
transport.expects(:send_notification).never
|
||||
|
||||
cancellation = MCP::Cancellation.new
|
||||
cancellation.cancel(reason: "pre-cancelled")
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
end
|
||||
|
||||
def test_call_tool_with_cancellation_mid_flight_raises_and_sends_notification
|
||||
cancellation = MCP::Cancellation.new
|
||||
notification_queue = Queue.new
|
||||
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |**|
|
||||
# Cancel while the request is "in flight".
|
||||
cancellation.cancel(reason: "user abort")
|
||||
sleep(0.05)
|
||||
{ "result" => {} }
|
||||
end
|
||||
transport.define_singleton_method(:send_notification) do |notification:|
|
||||
notification_queue.push(notification)
|
||||
nil
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
error = assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
assert_equal("user abort", error.reason)
|
||||
|
||||
# Cancel notification is dispatched fire-and-forget on a separate thread;
|
||||
# block here until it reaches the transport.
|
||||
notification = notification_queue.pop
|
||||
assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method])
|
||||
assert_equal("user abort", notification[:params][:reason])
|
||||
end
|
||||
|
||||
def test_loop_methods_accept_cancellation_keyword
|
||||
# Regression: `tools` / `resources` / `resource_templates` / `prompts` are the high-level pagination loops.
|
||||
# They must accept `cancellation:` so the README's "all request methods accept the cancellation: keyword"
|
||||
# claim holds. The token is propagated to each underlying `list_*` page.
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
transport = mock
|
||||
transport.stubs(:send_request).returns({ "result" => { "tools" => [] } })
|
||||
transport.stubs(:send_notification) # required for `cancellation:` capability check
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
[
|
||||
-> { client.tools(cancellation: cancellation) },
|
||||
-> { client.resources(cancellation: cancellation) },
|
||||
-> { client.resource_templates(cancellation: cancellation) },
|
||||
-> { client.prompts(cancellation: cancellation) },
|
||||
].each do |loop_call|
|
||||
transport.stubs(:send_request).returns({ "result" => {} })
|
||||
loop_call.call
|
||||
end
|
||||
end
|
||||
|
||||
def test_cancel_callback_does_not_deadlock_when_transport_holds_a_mutex
|
||||
# Regression: when the cancellation callback fires on the same thread that is currently inside
|
||||
# `transport.send_request` (and therefore holds a transport-level mutex), a synchronous `send_notification`
|
||||
# from the callback would deadlock by re-entering the same mutex. The fix wakes the calling thread first
|
||||
# and dispatches the notification on a separate thread.
|
||||
transport_mutex = Mutex.new
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
# Custom transport class so we can model a mutex held during send_request.
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |**|
|
||||
transport_mutex.synchronize do
|
||||
# Trigger cancel while we hold the mutex. The callback must NOT try to synchronously acquire
|
||||
# the same mutex via send_notification.
|
||||
cancellation.cancel(reason: "mid-flight")
|
||||
# Hold the mutex briefly to give the callback time to (incorrectly) try to re-enter.
|
||||
sleep(0.05)
|
||||
{ "result" => {} }
|
||||
end
|
||||
end
|
||||
|
||||
# send_notification also needs the mutex - this is the path that would deadlock under synchronous dispatch.
|
||||
send_notification_called = Queue.new
|
||||
transport.define_singleton_method(:send_notification) do |notification:|
|
||||
transport_mutex.synchronize do
|
||||
send_notification_called.push(notification)
|
||||
end
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
# The notification must reach the transport eventually (after the mutex is released by the now-finished send_request).
|
||||
notification = send_notification_called.pop
|
||||
assert_equal(Methods::NOTIFICATIONS_CANCELLED, notification[:method])
|
||||
assert_equal("mid-flight", notification[:params][:reason])
|
||||
end
|
||||
|
||||
def test_late_cancel_after_response_does_not_send_stray_notification
|
||||
# Regression: between the worker thread pushing `:response` and the main thread leaving `dispatch_with_cancellation`,
|
||||
# a cancellation firing in that gap must not emit a stray `notifications/cancelled` for the already-completed request.
|
||||
# The completion-mutex gate makes the worker and the on_cancel callback fight for a single slot; whichever sets
|
||||
# `completed` first wins, the loser bails.
|
||||
cancellation = MCP::Cancellation.new
|
||||
notification_count = 0
|
||||
notification_count_mutex = Mutex.new
|
||||
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |**, &on_sent|
|
||||
on_sent&.call
|
||||
{ "result" => {} }
|
||||
end
|
||||
transport.define_singleton_method(:send_notification) do |**|
|
||||
notification_count_mutex.synchronize { notification_count += 1 }
|
||||
nil
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
response = client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
|
||||
|
||||
assert_equal({}, response["result"])
|
||||
|
||||
# Cancel after the call returned. The on_cancel hook was deregistered in the `ensure` block;
|
||||
# even if a callback raced with the response handoff, `completed` would already be true and
|
||||
# `queue.push :cancelled` plus the cancel-dispatch thread would not run.
|
||||
cancellation.cancel(reason: "late")
|
||||
|
||||
sleep(0.05) # let any erroneous cancel-dispatch thread complete.
|
||||
|
||||
count = notification_count_mutex.synchronize { notification_count }
|
||||
assert_equal(0, count, "no stray notifications/cancelled for an already-completed request")
|
||||
end
|
||||
|
||||
def test_call_tool_with_cancellation_raises_NoMethodError_when_transport_lacks_send_notification
|
||||
# Regression: a transport that only implements `send_request` cannot deliver `notifications/cancelled`.
|
||||
# We must surface this upfront with a clear `NoMethodError` rather than silently swallow
|
||||
# the failure inside the cancel-dispatch thread - otherwise the user sees `CancelledError` but
|
||||
# the server never receives the notification.
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |**|
|
||||
{ "result" => {} }
|
||||
end
|
||||
# NOTE: no send_notification.
|
||||
|
||||
cancellation = MCP::Cancellation.new
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
error = assert_raises(NoMethodError) do
|
||||
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
assert_match(/send_notification/, error.message)
|
||||
end
|
||||
|
||||
def test_cancel_notification_is_dispatched_after_request_write_via_on_sent_block
|
||||
# Regression for the wire-order race: a transport that yields from `send_request` after writing
|
||||
# the request must see `:request` recorded before `:cancel` even when the cancel signal arrives concurrently.
|
||||
recorded = []
|
||||
recorded_mutex = Mutex.new
|
||||
cancellation = MCP::Cancellation.new
|
||||
cancel_complete = Queue.new
|
||||
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |request:, &on_sent|
|
||||
# Trigger cancel BEFORE the simulated wire-write so the cancel-dispatch thread starts racing immediately.
|
||||
# The `on_sent` block is yielded only after we have recorded the request - so any cancel-write that obeys
|
||||
# the block must land afterwards.
|
||||
cancellation.cancel(reason: "race")
|
||||
sleep(0.02) # give cancel-dispatch thread time to spawn and start waiting
|
||||
recorded_mutex.synchronize { recorded << [:request, request[:id]] }
|
||||
on_sent&.call
|
||||
{ "result" => {} }
|
||||
end
|
||||
transport.define_singleton_method(:send_notification) do |notification:|
|
||||
recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] }
|
||||
cancel_complete.push(:done)
|
||||
nil
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
client.stubs(:generate_request_id).returns("req-A")
|
||||
|
||||
assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
cancel_complete.pop # wait for the fire-and-forget cancel dispatch
|
||||
|
||||
# Wire-order must be: request first, then cancel.
|
||||
assert_equal([[:request, "req-A"], [:cancel, "req-A"]], recorded)
|
||||
end
|
||||
|
||||
def test_cancel_notification_waits_for_delayed_on_sent_signal
|
||||
# Regression: the cancel-dispatch thread must wait for `&on_sent` no matter how long it takes -
|
||||
# it cannot fall back to wall-clock-based timeout. An earlier 100 ms fixed-duration fallback
|
||||
# could release the cancel 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. The wait is now bounded by worker termination
|
||||
# (via `ensure -> signal_sent.call`), not by elapsed time.
|
||||
recorded = []
|
||||
recorded_mutex = Mutex.new
|
||||
cancellation = MCP::Cancellation.new
|
||||
cancel_complete = Queue.new
|
||||
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |request:, &on_sent|
|
||||
cancellation.cancel(reason: "race")
|
||||
# Delay well beyond any plausible fixed-duration fallback (200 ms).
|
||||
# If cancel-dispatch fires on a timer rather than waiting for on_sent,
|
||||
# the recorded order will be [:cancel, :request] and the assertion below will fail.
|
||||
sleep(0.2)
|
||||
recorded_mutex.synchronize { recorded << [:request, request[:id]] }
|
||||
on_sent&.call
|
||||
{ "result" => {} }
|
||||
end
|
||||
transport.define_singleton_method(:send_notification) do |notification:|
|
||||
recorded_mutex.synchronize { recorded << [:cancel, notification[:params][:requestId]] }
|
||||
cancel_complete.push(:done)
|
||||
nil
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
client.stubs(:generate_request_id).returns("req-D")
|
||||
|
||||
assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
cancel_complete.pop
|
||||
|
||||
assert_equal([[:request, "req-D"], [:cancel, "req-D"]], recorded)
|
||||
end
|
||||
|
||||
def test_cancel_notification_dispatches_when_worker_raises_before_on_sent
|
||||
# Regression: if the worker raises before `&on_sent` is called - which can happen if a transport rejects
|
||||
# a malformed request synchronously - the cancel-dispatch thread must still terminate AND deliver the notification.
|
||||
# The worker's `ensure` block invokes `signal_sent.call`, which broadcasts to the condition variable and unblocks
|
||||
# the `until request_sent` wait. Without the ensure-driven unblock, the dispatch thread would leak.
|
||||
cancellation = MCP::Cancellation.new
|
||||
send_notification_called = Queue.new
|
||||
|
||||
transport = Object.new
|
||||
transport.define_singleton_method(:send_request) do |**|
|
||||
# Trigger cancel before raising so the cancel hook fires while the worker is mid-flight,
|
||||
# then bail out without ever calling on_sent.
|
||||
cancellation.cancel(reason: "race")
|
||||
raise StandardError, "transport boom"
|
||||
end
|
||||
transport.define_singleton_method(:send_notification) do |notification:|
|
||||
send_notification_called.push(notification)
|
||||
nil
|
||||
end
|
||||
|
||||
client = Client.new(transport: transport)
|
||||
client.stubs(:generate_request_id).returns("req-E")
|
||||
|
||||
assert_raises(MCP::CancelledError) do
|
||||
client.call_tool(name: "slow", arguments: {}, cancellation: cancellation)
|
||||
end
|
||||
|
||||
# The cancel-dispatch thread should still send the notification because the worker's `ensure` triggers `signal_sent.call`.
|
||||
notification = send_notification_called.pop
|
||||
assert_equal("req-E", notification[:params][:requestId])
|
||||
end
|
||||
|
||||
def test_call_tool_with_cancellation_normal_completion_does_not_send_notification
|
||||
transport = mock
|
||||
transport.expects(:send_request).returns({ "result" => {} }).once
|
||||
transport.expects(:send_notification).never
|
||||
|
||||
cancellation = MCP::Cancellation.new
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
client.call_tool(name: "noop", arguments: {}, cancellation: cancellation)
|
||||
|
||||
# Late cancel after normal completion: hook should already be deregistered.
|
||||
cancellation.cancel(reason: "late")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ module MCP
|
|||
end
|
||||
|
||||
test "CancelledError raised from nested server-to-client call suppresses response" do
|
||||
@server.define_tool(name: "nesting") do |server_context:| # rubocop:disable Lint/UnusedBlockArgument
|
||||
@server.define_tool(name: "nesting") do |**|
|
||||
# Simulate a transport-level CancelledError coming back from a nested
|
||||
# send_request (e.g. sampling/createMessage after parent was cancelled).
|
||||
raise MCP::CancelledError.new(request_id: "nested-xyz", reason: "parent aborted")
|
||||
|
|
|
|||
Loading…
Reference in a new issue