ruby-sdk/lib/json_rpc_handler.rb
Koichi ITO a97e391353 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.
2026-05-03 04:02:22 +09:00

184 lines
5.6 KiB
Ruby

# frozen_string_literal: true
require "json"
module JsonRpcHandler
class Version
V1_0 = "1.0"
V2_0 = "2.0"
end
class ErrorCode
INVALID_REQUEST = -32600
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
PARSE_ERROR = -32700
end
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)
if request.is_a?(Array)
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: "Request is an empty array",
}) if request.empty?
# Handle batch requests
responses = request.map { |req| process_request(req, id_validation_pattern: id_validation_pattern, &method_finder) }.compact
# A single item is hoisted out of the array
return responses.first if responses.one?
# An empty array yields nil
responses if responses.any?
elsif request.is_a?(Hash)
# Handle single request
process_request(request, id_validation_pattern: id_validation_pattern, &method_finder)
else
error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: "Request must be an array or a hash",
})
end
end
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
begin
request = JSON.parse(request_json, symbolize_names: true)
response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
rescue JSON::ParserError
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::PARSE_ERROR,
message: "Parse error",
data: "Invalid JSON",
})
end
response&.to_json
end
def process_request(request, id_validation_pattern:, &method_finder)
id = request[:id]
error = if !valid_version?(request[:jsonrpc])
"JSON-RPC version must be 2.0"
elsif !valid_id?(request[:id], id_validation_pattern)
"Request ID must match validation pattern, or be an integer or null"
elsif !valid_method_name?(request[:method])
'Method name must be a string and not start with "rpc."'
end
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: error,
}) if error
method_name = request[:method]
params = request[:params]
unless valid_params?(params)
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_PARAMS,
message: "Invalid params",
data: "Method parameters must be an array or an object or null",
})
end
begin
method = method_finder.call(method_name, id)
if method.nil?
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::METHOD_NOT_FOUND,
message: "Method not found",
data: method_name,
})
end
result = method.call(params)
return if result.equal?(NO_RESPONSE)
success_response(id: id, result: result)
rescue MCP::Server::RequestHandlerError => e
handle_request_error(e, id, id_validation_pattern)
rescue StandardError => e
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INTERNAL_ERROR,
message: "Internal error",
data: e.message,
})
end
end
def handle_request_error(error, id, id_validation_pattern)
if error.respond_to?(:error_code) && error.error_code
code = error.error_code
message = error.message
else
error_type = error.respond_to?(:error_type) ? error.error_type : nil
code, message = case error_type
when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
else [ErrorCode::INTERNAL_ERROR, "Internal error"]
end
end
data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: code,
message: message,
data: data,
})
end
def valid_version?(version)
version == Version::V2_0
end
def valid_id?(id, pattern = nil)
return true if id.nil? || id.is_a?(Integer)
return false unless id.is_a?(String)
pattern ? id.match?(pattern) : true
end
def valid_method_name?(method)
method.is_a?(String) && !method.start_with?("rpc.")
end
def valid_params?(params)
params.nil? || params.is_a?(Array) || params.is_a?(Hash)
end
def success_response(id:, result:)
{
jsonrpc: Version::V2_0,
id: id,
result: result,
} unless id.nil?
end
def error_response(id:, id_validation_pattern:, error:)
{
jsonrpc: Version::V2_0,
id: valid_id?(id, id_validation_pattern) ? id : nil,
error: error.compact,
} unless id.nil?
end
end