Skip to content

Commit 35b4670

Browse files
committed
Support notifications/cancelled per MCP specification
## Motivation and Context The MCP specification defines `notifications/cancelled` so either party can stop a previously-issued in-flight request. The Ruby SDK declared the method constant but had no receive logic, no in-flight tracking, and no way for handlers to participate in cancellation; cancellation notifications fell through to the unsupported-method path. This PR implements the **server-side** half of the spec. The server stops processing the targeted request cooperatively and suppresses its JSON-RPC response, matching the Python SDK's anyio `CancelScope` and the TypeScript SDK's `RequestHandlerExtra.signal`. Cancellation is observable by every user-overridable request handler: - `Tool.call` (tools/call) - prompt templates (prompts/get) - blocks registered via `resources_read_handler` / `completion_handler` / `resources_subscribe_handler` / `resources_unsubscribe_handler` / `define_custom_method` Each handler opts in by declaring a `server_context:` keyword and polls `server_context.cancelled?` or calls `server_context.raise_if_cancelled!` (raising `MCP::CancelledError`) inside long-running work. Handlers that keep their existing `|params|` (or `|args|`) signature continue to work unchanged. On `StreamableHTTPTransport`, cancelling a parent `tools/call` automatically cancels n ested server-to-client requests (`sampling/createMessage`, `elicitation/create`); the nested `send_request` raises `MCP::CancelledError` and a cancel notification is routed to the peer on the parent's POST response stream. `StdioTransport` is single-threaded and blocks on `$stdin.gets`, so it deliberately does not propagate nested cancellation. Tools running on stdio still observe cancellation between calls via `server_context.cancelled?`. The design is cooperative-only (no `Thread#raise`) because preemptive cancellation is unsafe across Rack adapters and arbitrary handler code. The `initialize` request is never cancellable, satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications are silently ignored per the spec. `MCP::Client#cancel` (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR. Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation ## How Has This Been Tested? `test/mcp/cancellation_test.rb` covers the `MCP::Cancellation` token contract. `test/mcp/server_cancellation_test.rb` covers end-to-end cancellation: - A handler spins on `cancelled?` in a background thread; the test sends `notifications/cancelled` and asserts the handler observed cancellation and the JSON-RPC response was suppressed. Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method) has its own regression test. - `initialize` is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored. - The `reason` propagates into the `cancellation_reason` instrumentation field. - Custom transports implementing only the abstract `(method, params = nil)` contract keep working; the new kwargs (`session_id:`, `related_request_id:`, `parent_cancellation:`, `server_session:`) are silently dropped when the transport's signature does not declare them. `StreamableHTTPTransport` tests cover nested cancellation, hook deregistration after normal completion (so a late parent cancel does not emit a stray `notifications/cancelled`), and the first-writer-wins race when a real response and a cancel arrive concurrently. ## Breaking Change None. `MCP::ServerContext#initialize` gains an optional `cancellation:` keyword (defaults to `nil`), and `StreamableHTTPTransport#send_request` / `#send_notification` gain optional cancellation kwargs. The abstract `Transport` base class and `StdioTransport` keep their existing `(method, params = nil)` signatures, and custom transports following those signatures continue to work. `StreamableHTTPTransport` now dispatches client-originated notifications through `ServerSession#handle_json` before returning 202; previously these were accepted without dispatch, but the existing handlers for `notifications/initialized` and `notifications/progress` were already no-ops, so no user-visible effect is expected. Adding `server_context:` to a handler block is strictly opt-in.
1 parent 2ad3d21 commit 35b4670

15 files changed

Lines changed: 1404 additions & 41 deletions

README.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
4141
- Supports roots (server-to-client filesystem boundary queries)
4242
- Supports sampling (server-to-client LLM completion requests)
4343
- Supports cursor-based pagination for list operations
44+
- Supports server-side cancellation of in-flight requests (notifications/cancelled)
4445

4546
### Supported Methods
4647

@@ -1096,9 +1097,137 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
10961097
- `notifications/tools/list_changed`
10971098
- `notifications/prompts/list_changed`
10981099
- `notifications/resources/list_changed`
1100+
- `notifications/cancelled`
10991101
- `notifications/progress`
11001102
- `notifications/message`
11011103

1104+
### Cancellation
1105+
1106+
The MCP Ruby SDK supports server-side handling of the
1107+
[MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
1108+
When a client sends `notifications/cancelled` for an in-flight request, the server stops
1109+
processing cooperatively and suppresses the JSON-RPC response for that request.
1110+
1111+
Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead,
1112+
a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools
1113+
poll it to exit early. When a tool returns after cancellation has been observed,
1114+
the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
1115+
is never cancellable per the spec.
1116+
1117+
> [!NOTE]
1118+
> Client-initiated cancellation (`Client#cancel` equivalent that would also abort
1119+
> the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
1120+
> from the client side can be done by constructing the notification payload and writing it
1121+
> directly through the transport, but the calling thread does not yet unwind automatically.
1122+
> This is tracked as a follow-up.
1123+
1124+
#### Server-Side: Handlers that Check for Cancellation
1125+
1126+
Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
1127+
`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
1128+
`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
1129+
an `MCP::ServerContext` wired to the in-flight request's cancellation token.
1130+
Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise
1131+
`MCP::CancelledError` at a safe point:
1132+
1133+
```ruby
1134+
class LongRunningTool < MCP::Tool
1135+
description "A tool that supports cancellation"
1136+
input_schema(properties: { count: { type: "integer" } }, required: ["count"])
1137+
1138+
def self.call(count:, server_context:)
1139+
count.times do |i|
1140+
# Exit early if the client has sent `notifications/cancelled`.
1141+
break if server_context.cancelled?
1142+
1143+
do_work(i)
1144+
end
1145+
1146+
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
1147+
end
1148+
end
1149+
```
1150+
1151+
Alternatively, raise at the next safe point with `raise_if_cancelled!`:
1152+
1153+
```ruby
1154+
def self.call(count:, server_context:)
1155+
count.times do |i|
1156+
server_context.raise_if_cancelled!
1157+
1158+
do_work(i)
1159+
end
1160+
1161+
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
1162+
end
1163+
```
1164+
1165+
When a handler observes cancellation (either by returning early with `cancelled?` or
1166+
by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and
1167+
no JSON-RPC result is sent to the client.
1168+
1169+
The same pattern works for other handler types:
1170+
1171+
```ruby
1172+
# resources/read
1173+
server.resources_read_handler do |params, server_context:|
1174+
server_context.raise_if_cancelled!
1175+
# read the resource
1176+
end
1177+
1178+
# completion/complete
1179+
server.completion_handler do |params, server_context:|
1180+
server_context.raise_if_cancelled!
1181+
# compute completions
1182+
end
1183+
1184+
# custom method
1185+
server.define_custom_method(method_name: "custom/slow") do |params, server_context:|
1186+
server_context.raise_if_cancelled!
1187+
# do work
1188+
end
1189+
1190+
# prompts (via Prompt subclass)
1191+
class SlowPrompt < MCP::Prompt
1192+
prompt_name "slow_prompt"
1193+
1194+
def self.template(args, server_context:)
1195+
server_context.raise_if_cancelled!
1196+
MCP::Prompt::Result.new(messages: [])
1197+
end
1198+
end
1199+
```
1200+
1201+
Handlers that do not declare a `server_context:` keyword continue to work unchanged -
1202+
the opt-in detection only wraps the context when the block signature asks for it.
1203+
1204+
#### Nested Server-to-Client Requests Are Cancelled Automatically
1205+
1206+
When a tool handler is waiting on a nested server-to-client request
1207+
(`server_context.create_sampling_message`, `create_form_elicitation`, or
1208+
`create_url_elicitation`), cancelling the parent tool call automatically raises
1209+
`MCP::CancelledError` from the nested call, so the tool does not need to wrap it
1210+
in its own `cancelled?` checks:
1211+
1212+
```ruby
1213+
def self.call(server_context:)
1214+
result = server_context.create_sampling_message(messages: messages, max_tokens: 100)
1215+
# If the parent tools/call is cancelled while waiting above, MCP::CancelledError
1216+
# is raised here and the tool can let it propagate or clean up as needed.
1217+
MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }])
1218+
rescue MCP::CancelledError
1219+
# Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server
1220+
# will still suppress the JSON-RPC response per the MCP spec.
1221+
raise
1222+
end
1223+
```
1224+
1225+
Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
1226+
`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested
1227+
`server_context.create_sampling_message` inside a tool runs to completion even if
1228+
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
1229+
via `server_context.cancelled?` between nested calls.
1230+
11021231
### Ping
11031232

11041233
The MCP Ruby SDK supports the

lib/json_rpc_handler.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class ErrorCode
1818

1919
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
2020

21+
# Sentinel return value from a handler. When a handler returns this,
22+
# `process_request` emits no JSON-RPC response for the request,
23+
# matching the notification-style semantics (id is ignored).
24+
NO_RESPONSE = Object.new.freeze
25+
2126
extend self
2227

2328
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
@@ -103,6 +108,7 @@ def process_request(request, id_validation_pattern:, &method_finder)
103108
end
104109

105110
result = method.call(params)
111+
return if result.equal?(NO_RESPONSE)
106112

107113
success_response(id: id, result: result)
108114
rescue MCP::Server::RequestHandlerError => e

lib/mcp.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
module MCP
1010
autoload :Annotations, "mcp/annotations"
11+
autoload :Cancellation, "mcp/cancellation"
12+
autoload :CancelledError, "mcp/cancelled_error"
1113
autoload :Client, "mcp/client"
1214
autoload :Content, "mcp/content"
1315
autoload :Icon, "mcp/icon"

lib/mcp/cancellation.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "cancelled_error"
4+
5+
module MCP
6+
class Cancellation
7+
attr_reader :reason, :request_id
8+
9+
def initialize(request_id: nil)
10+
@request_id = request_id
11+
@reason = nil
12+
@cancelled = false
13+
@callbacks = []
14+
@mutex = Mutex.new
15+
end
16+
17+
def cancelled?
18+
@mutex.synchronize { @cancelled }
19+
end
20+
21+
def cancel(reason: nil)
22+
callbacks = @mutex.synchronize do
23+
return false if @cancelled
24+
25+
@cancelled = true
26+
@reason = reason
27+
@callbacks.tap { @callbacks = [] }
28+
end
29+
30+
callbacks.each do |callback|
31+
callback.call(reason)
32+
rescue StandardError => e
33+
MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" })
34+
end
35+
36+
true
37+
end
38+
39+
# Registers a callback invoked synchronously on the first `cancel` call.
40+
# If already cancelled, fires immediately.
41+
#
42+
# Returns the block itself as a handle that can be passed to `off_cancel`
43+
# to deregister it (e.g. when a nested request completes normally and the
44+
# hook should not fire on a later parent cancellation).
45+
def on_cancel(&block)
46+
fire_now = false
47+
@mutex.synchronize do
48+
if @cancelled
49+
fire_now = true
50+
else
51+
@callbacks << block
52+
end
53+
end
54+
55+
block.call(@reason) if fire_now
56+
block
57+
end
58+
59+
# Removes a previously-registered `on_cancel` callback. Returns `true`
60+
# if the callback was still pending (i.e. had not yet fired), `false`
61+
# otherwise. Safe to call with `nil`.
62+
def off_cancel(handle)
63+
return false unless handle
64+
65+
@mutex.synchronize { !@callbacks.delete(handle).nil? }
66+
end
67+
68+
def raise_if_cancelled!
69+
raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled?
70+
end
71+
end
72+
end

lib/mcp/cancelled_error.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class CancelledError < StandardError
5+
attr_reader :request_id, :reason
6+
7+
def initialize(message = "Request was cancelled", request_id: nil, reason: nil)
8+
super(message)
9+
@request_id = request_id
10+
@reason = reason
11+
end
12+
end
13+
end

0 commit comments

Comments
 (0)