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.
This commit is contained in:
parent
2ad3d21492
commit
a97e391353
1 changed files with 1485 additions and 41 deletions
129
README.md
129
README.md
|
|
@ -41,6 +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)
|
||||
|
||||
### Supported Methods
|
||||
|
||||
|
|
@ -1096,9 +1097,137 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
|
|||
- `notifications/tools/list_changed`
|
||||
- `notifications/prompts/list_changed`
|
||||
- `notifications/resources/list_changed`
|
||||
- `notifications/cancelled`
|
||||
- `notifications/progress`
|
||||
- `notifications/message`
|
||||
|
||||
### Cancellation
|
||||
|
||||
The MCP Ruby SDK supports server-side handling of the
|
||||
[MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation).
|
||||
When a client sends `notifications/cancelled` for an in-flight request, the server stops
|
||||
processing cooperatively and suppresses the JSON-RPC response for that request.
|
||||
|
||||
Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead,
|
||||
a `MCP::Cancellation` token is threaded through `server_context`, and long-running tools
|
||||
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.
|
||||
|
||||
#### Server-Side: Handlers that Check for Cancellation
|
||||
|
||||
Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates,
|
||||
`resources_read_handler`, `completion_handler`, `resources_subscribe_handler`,
|
||||
`resources_unsubscribe_handler`, and `define_custom_method` blocks - receives
|
||||
an `MCP::ServerContext` wired to the in-flight request's cancellation token.
|
||||
Handlers check `cancelled?` in their work loop, or call `raise_if_cancelled!` to raise
|
||||
`MCP::CancelledError` at a safe point:
|
||||
|
||||
```ruby
|
||||
class LongRunningTool < MCP::Tool
|
||||
description "A tool that supports cancellation"
|
||||
input_schema(properties: { count: { type: "integer" } }, required: ["count"])
|
||||
|
||||
def self.call(count:, server_context:)
|
||||
count.times do |i|
|
||||
# Exit early if the client has sent `notifications/cancelled`.
|
||||
break if server_context.cancelled?
|
||||
|
||||
do_work(i)
|
||||
end
|
||||
|
||||
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Alternatively, raise at the next safe point with `raise_if_cancelled!`:
|
||||
|
||||
```ruby
|
||||
def self.call(count:, server_context:)
|
||||
count.times do |i|
|
||||
server_context.raise_if_cancelled!
|
||||
|
||||
do_work(i)
|
||||
end
|
||||
|
||||
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
|
||||
end
|
||||
```
|
||||
|
||||
When a handler observes cancellation (either by returning early with `cancelled?` or
|
||||
by raising `MCP::CancelledError` via `raise_if_cancelled!`), the server drops the response and
|
||||
no JSON-RPC result is sent to the client.
|
||||
|
||||
The same pattern works for other handler types:
|
||||
|
||||
```ruby
|
||||
# resources/read
|
||||
server.resources_read_handler do |params, server_context:|
|
||||
server_context.raise_if_cancelled!
|
||||
# read the resource
|
||||
end
|
||||
|
||||
# completion/complete
|
||||
server.completion_handler do |params, server_context:|
|
||||
server_context.raise_if_cancelled!
|
||||
# compute completions
|
||||
end
|
||||
|
||||
# custom method
|
||||
server.define_custom_method(method_name: "custom/slow") do |params, server_context:|
|
||||
server_context.raise_if_cancelled!
|
||||
# do work
|
||||
end
|
||||
|
||||
# prompts (via Prompt subclass)
|
||||
class SlowPrompt < MCP::Prompt
|
||||
prompt_name "slow_prompt"
|
||||
|
||||
def self.template(args, server_context:)
|
||||
server_context.raise_if_cancelled!
|
||||
MCP::Prompt::Result.new(messages: [])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Handlers that do not declare a `server_context:` keyword continue to work unchanged -
|
||||
the opt-in detection only wraps the context when the block signature asks for it.
|
||||
|
||||
#### Nested Server-to-Client Requests Are Cancelled Automatically
|
||||
|
||||
When a tool handler is waiting on a nested server-to-client request
|
||||
(`server_context.create_sampling_message`, `create_form_elicitation`, or
|
||||
`create_url_elicitation`), cancelling the parent tool call automatically raises
|
||||
`MCP::CancelledError` from the nested call, so the tool does not need to wrap it
|
||||
in its own `cancelled?` checks:
|
||||
|
||||
```ruby
|
||||
def self.call(server_context:)
|
||||
result = server_context.create_sampling_message(messages: messages, max_tokens: 100)
|
||||
# If the parent tools/call is cancelled while waiting above, MCP::CancelledError
|
||||
# is raised here and the tool can let it propagate or clean up as needed.
|
||||
MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }])
|
||||
rescue MCP::CancelledError
|
||||
# Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server
|
||||
# will still suppress the JSON-RPC response per the MCP spec.
|
||||
raise
|
||||
end
|
||||
```
|
||||
|
||||
Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
|
||||
`StdioTransport` is single-threaded and blocks on `$stdin.gets`, so a nested
|
||||
`server_context.create_sampling_message` inside a tool runs to completion even if
|
||||
the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
|
||||
via `server_context.cancelled?` between nested calls.
|
||||
|
||||
### Ping
|
||||
|
||||
The MCP Ruby SDK supports the
|
||||
|
|
|
|||
Loading…
Reference in a new issue