Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/json_rpc_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class ErrorCode

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

# Sentinel return value from a handler. When a handler returns this,
# `process_request` emits no JSON-RPC response for the request,
# matching the notification-style semantics (id is ignored).
NO_RESPONSE = Object.new.freeze

extend self

def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
Expand Down Expand Up @@ -103,6 +108,7 @@ def process_request(request, id_validation_pattern:, &method_finder)
end

result = method.call(params)
return if result.equal?(NO_RESPONSE)

success_response(id: id, result: result)
rescue MCP::Server::RequestHandlerError => e
Expand Down
2 changes: 2 additions & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

module MCP
autoload :Annotations, "mcp/annotations"
autoload :Cancellation, "mcp/cancellation"
autoload :CancelledError, "mcp/cancelled_error"
autoload :Client, "mcp/client"
autoload :Content, "mcp/content"
autoload :Icon, "mcp/icon"
Expand Down
72 changes: 72 additions & 0 deletions lib/mcp/cancellation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

require_relative "cancelled_error"

module MCP
class Cancellation
attr_reader :reason, :request_id

def initialize(request_id: nil)
@request_id = request_id
@reason = nil
@cancelled = false
@callbacks = []
@mutex = Mutex.new
end

def cancelled?
@mutex.synchronize { @cancelled }
end

def cancel(reason: nil)
callbacks = @mutex.synchronize do
return false if @cancelled

@cancelled = true
@reason = reason
@callbacks.tap { @callbacks = [] }
end

callbacks.each do |callback|
callback.call(reason)
rescue StandardError => e
MCP.configuration.exception_reporter.call(e, { error: "Cancellation callback failed" })
end

true
end

# Registers a callback invoked synchronously on the first `cancel` call.
# If already cancelled, fires immediately.
#
# Returns the block itself as a handle that can be passed to `off_cancel`
# to deregister it (e.g. when a nested request completes normally and the
# hook should not fire on a later parent cancellation).
def on_cancel(&block)
fire_now = false
@mutex.synchronize do
if @cancelled
fire_now = true
else
@callbacks << block
end
end

block.call(@reason) if fire_now
block
end

# Removes a previously-registered `on_cancel` callback. Returns `true`
# if the callback was still pending (i.e. had not yet fired), `false`
# otherwise. Safe to call with `nil`.
def off_cancel(handle)
return false unless handle

@mutex.synchronize { !@callbacks.delete(handle).nil? }
end

def raise_if_cancelled!
raise CancelledError.new(request_id: @request_id, reason: @reason) if cancelled?
end
end
end
13 changes: 13 additions & 0 deletions lib/mcp/cancelled_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module MCP
class CancelledError < StandardError
attr_reader :request_id, :reason

def initialize(message = "Request was cancelled", request_id: nil, reason: nil)
super(message)
@request_id = request_id
@reason = reason
end
end
end
Loading