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
4 changed files with 779 additions and 42 deletions

View file

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

View file

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

View file

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

View file

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