## 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.
296 lines
10 KiB
Ruby
296 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "open3"
|
|
require "securerandom"
|
|
require "timeout"
|
|
require_relative "../../json_rpc_handler"
|
|
require_relative "../configuration"
|
|
require_relative "../methods"
|
|
require_relative "../version"
|
|
|
|
module MCP
|
|
class Client
|
|
class Stdio
|
|
# Seconds to wait for the server process to exit before sending SIGTERM.
|
|
# Matches the Python and TypeScript SDKs' shutdown timeout:
|
|
# https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/stdio/__init__.py#L48
|
|
# https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/stdio.ts#L221
|
|
CLOSE_TIMEOUT = 2
|
|
STDERR_READ_SIZE = 4096
|
|
|
|
attr_reader :command, :args, :env, :server_info
|
|
|
|
def initialize(command:, args: [], env: nil, read_timeout: nil)
|
|
@command = command
|
|
@args = args
|
|
@env = env
|
|
@read_timeout = read_timeout
|
|
@stdin = nil
|
|
@stdout = nil
|
|
@stderr = nil
|
|
@wait_thread = nil
|
|
@stderr_thread = nil
|
|
@started = false
|
|
@initialized = false
|
|
@server_info = nil
|
|
# Serializes writes to `@stdin` so a request line and a notification line emitted from
|
|
# different threads (e.g. cancellation) cannot interleave on the wire.
|
|
@write_mutex = Mutex.new
|
|
end
|
|
|
|
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
|
# followed by the required `notifications/initialized` notification. The
|
|
# server's `InitializeResult` (protocol version, capabilities, server
|
|
# info, instructions) is cached on the transport and returned.
|
|
#
|
|
# Idempotent: a second call returns the cached `InitializeResult` without
|
|
# contacting the server. After `close`, state is cleared and `connect`
|
|
# will handshake again. Spawns the subprocess via `start` if it has not
|
|
# been started yet.
|
|
#
|
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
|
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
|
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
# @return [Hash] The server's `InitializeResult`.
|
|
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
|
# a malformed result, or an unsupported protocol version.
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
return @server_info if @initialized
|
|
|
|
start unless @started
|
|
|
|
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
|
|
init_request = {
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
id: SecureRandom.uuid,
|
|
method: MCP::Methods::INITIALIZE,
|
|
params: {
|
|
protocolVersion: protocol_version,
|
|
capabilities: capabilities,
|
|
clientInfo: client_info,
|
|
},
|
|
}
|
|
|
|
write_message(init_request)
|
|
response = read_response(init_request)
|
|
|
|
if response.key?("error")
|
|
error = response["error"]
|
|
raise RequestHandlerError.new(
|
|
"Server initialization failed: #{error["message"]}",
|
|
{ method: MCP::Methods::INITIALIZE },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
unless response["result"].is_a?(Hash)
|
|
raise RequestHandlerError.new(
|
|
"Server initialization failed: missing result in response",
|
|
{ method: MCP::Methods::INITIALIZE },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
@server_info = response["result"]
|
|
|
|
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
# Per spec, if the client does not support the server's returned protocol version,
|
|
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
|
|
# raising so a retry starts without a stale `server_info`.
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
|
@server_info = nil
|
|
raise RequestHandlerError.new(
|
|
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
{ method: MCP::Methods::INITIALIZE },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
begin
|
|
notification = {
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
}
|
|
write_message(notification)
|
|
rescue StandardError
|
|
@server_info = nil
|
|
raise
|
|
end
|
|
|
|
@initialized = true
|
|
@server_info
|
|
end
|
|
|
|
# Returns true once `connect` (or the implicit handshake on the first
|
|
# `send_request`) has completed. Returns false before the handshake
|
|
# and after `close`.
|
|
def connected?
|
|
@initialized
|
|
end
|
|
|
|
# Transports may yield once the request line has been written to `@stdin`.
|
|
# `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled`
|
|
# write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
|
|
# so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
|
|
def send_request(request:)
|
|
start unless @started
|
|
unless @initialized
|
|
warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
|
|
connect
|
|
end
|
|
|
|
@write_mutex.synchronize do
|
|
write_message(request)
|
|
yield if block_given?
|
|
end
|
|
read_response(request)
|
|
end
|
|
|
|
# Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
|
|
# `notifications/cancelled` for an in-flight request.
|
|
def send_notification(notification:)
|
|
start unless @started
|
|
connect unless @initialized
|
|
|
|
@write_mutex.synchronize { write_message(notification) }
|
|
nil
|
|
end
|
|
|
|
def start
|
|
raise "MCP::Client::Stdio already started" if @started
|
|
|
|
spawn_env = @env || {}
|
|
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(spawn_env, @command, *@args)
|
|
@stdout.set_encoding("UTF-8")
|
|
@stdin.set_encoding("UTF-8")
|
|
|
|
# Drain stderr in the background to prevent the pipe buffer from filling up,
|
|
# which would cause the server process to block and deadlock.
|
|
@stderr_thread = Thread.new do
|
|
loop do
|
|
@stderr.readpartial(STDERR_READ_SIZE)
|
|
end
|
|
rescue IOError
|
|
nil
|
|
end
|
|
|
|
@started = true
|
|
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOEXEC => e
|
|
raise RequestHandlerError.new(
|
|
"Failed to spawn server process: #{e.message}",
|
|
{},
|
|
error_type: :internal_error,
|
|
original_error: e,
|
|
)
|
|
end
|
|
|
|
def close
|
|
return unless @started
|
|
|
|
@stdin.close
|
|
@stdout.close
|
|
@stderr.close
|
|
|
|
begin
|
|
Timeout.timeout(CLOSE_TIMEOUT) { @wait_thread.value }
|
|
rescue Timeout::Error
|
|
begin
|
|
Process.kill("TERM", @wait_thread.pid)
|
|
Timeout.timeout(CLOSE_TIMEOUT) { @wait_thread.value }
|
|
rescue Timeout::Error
|
|
begin
|
|
Process.kill("KILL", @wait_thread.pid)
|
|
rescue Errno::ESRCH
|
|
nil
|
|
end
|
|
rescue Errno::ESRCH
|
|
nil
|
|
end
|
|
end
|
|
|
|
@stderr_thread.join(CLOSE_TIMEOUT)
|
|
@started = false
|
|
@initialized = false
|
|
@server_info = nil
|
|
end
|
|
|
|
private
|
|
|
|
def write_message(message)
|
|
ensure_running!
|
|
json = JSON.generate(message)
|
|
@stdin.puts(json)
|
|
@stdin.flush
|
|
rescue IOError, Errno::EPIPE => e
|
|
raise RequestHandlerError.new(
|
|
"Failed to write to server process",
|
|
{},
|
|
error_type: :internal_error,
|
|
original_error: e,
|
|
)
|
|
end
|
|
|
|
def read_response(request)
|
|
request_id = request[:id] || request["id"]
|
|
method = request[:method] || request["method"]
|
|
params = request[:params] || request["params"]
|
|
|
|
loop do
|
|
ensure_running!
|
|
wait_for_readable!(method, params) if @read_timeout
|
|
line = @stdout.gets
|
|
raise_connection_error!(method, params) if line.nil?
|
|
|
|
parsed = JSON.parse(line.strip)
|
|
|
|
next unless parsed.key?("id")
|
|
|
|
return parsed if parsed["id"] == request_id
|
|
end
|
|
rescue JSON::ParserError => e
|
|
raise RequestHandlerError.new(
|
|
"Failed to parse server response",
|
|
{ method: method, params: params },
|
|
error_type: :internal_error,
|
|
original_error: e,
|
|
)
|
|
end
|
|
|
|
def ensure_running!
|
|
return if @wait_thread.alive?
|
|
|
|
raise RequestHandlerError.new(
|
|
"Server process has exited",
|
|
{},
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
def wait_for_readable!(method, params)
|
|
ready = @stdout.wait_readable(@read_timeout)
|
|
return if ready
|
|
|
|
raise RequestHandlerError.new(
|
|
"Timed out waiting for server response",
|
|
{ method: method, params: params },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
def raise_connection_error!(method, params)
|
|
raise RequestHandlerError.new(
|
|
"Server process closed stdout unexpectedly",
|
|
{ method: method, params: params },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|