ruby-sdk/lib/mcp/client/stdio.rb
Koichi ITO d0d7308a89 Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575
## Motivation and Context

Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic.

Server side, `Server#discover` reaches its spec-final shape:

- `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
  legacy versions are negotiated via `initialize`, not selected from discovery. This also closes
  an interop hazard of the interim response: auto-negotiating TS and Python clients treat
  any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`,
  so advertising only legacy versions made them fail outright.
- The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints
  on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with
  `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise.
- The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp
  instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002
  (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level
  `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and
  the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built
  against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`.

Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default:

- `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct
  (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`).
  It works before `connect`; the stdio transport's connect guard exempts `server/discover`
  and spawns the subprocess on demand.
- `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs
  (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:`
  (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover`
  and falls back to the legacy handshake on any discovery failure, including a successful discovery
  without a mutually supported modern version (rollout tolerance for servers that answer discovery
  while only serving legacy versions). Custom transports whose `connect` does not declare `mode:`
  keep receiving the historical legacy call shape, detected by keyword introspection;
  a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords
  stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError`
  instead of silently downgrading.
- The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`,
  instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried
  the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make
  the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version,
  the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here
  as `mode: :modern` with `protocol_version: "2026-07-28"`.
- An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe,
  so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle.
  `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools
  and when using server-initiated requests, which exist only on the legacy lifecycle.
- Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends
  on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference:
  `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one),
  `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta`
  `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server
  does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover.
- The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK)
  when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately,
  but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback
  instead of blocking `connect` forever. Regular requests keep the unbounded default.
- In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto
  every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names
  from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and
  keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize`
  is never sent.
- The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake
  and its scenario servers do not expect a preceding probe.

Resolves #389.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values);
the existing discover assertions across `server_test.rb` and both transport test files were updated to
the modern-only `supportedVersions`.

New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError`
on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`,
the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`,
`ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection,
and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence.

New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with
the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks,
and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`,
modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout
default applying only while probing, and the fallback when a server never answers the probe.

## Breaking Changes

`server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`);
this method has not shipped in a gem release yet, so no released behavior changes.

On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before
the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with
an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing
the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation
`protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable
across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
2026-08-08 03:45:00 +09:00

496 lines
19 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 "../protocol_deprecations"
require_relative "../version"
require_relative "modern_envelope"
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
# Default upper bound on a single newline-delimited frame read from the
# server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a
# newline arrives, so a spawned server that never emits one can grow a single
# String until the host process is OOM-killed. 4 MiB is large enough for any
# realistic JSON-RPC frame, including base64-embedded images.
MAX_LINE_BYTES = 4 * 1024 * 1024
# Seconds the `server/discover` probe may wait when no `read_timeout` was configured.
# A compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one
# that silently drops unknown methods would otherwise block `connect(mode: :auto)` forever.
# Matches the C# SDK's `DiscoverProbeTimeout` default.
DEFAULT_DISCOVER_PROBE_TIMEOUT = 5
attr_reader :command, :args, :env, :server_info
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
# limit read without an upper bound, which would silently disable the
# protection this option exists to provide.
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
raise ArgumentError, "max_line_bytes must be a positive Integer"
end
@command = command
@args = args
@env = env
@read_timeout = read_timeout
@max_line_bytes = max_line_bytes
@stdin = nil
@stdout = nil
@stderr = nil
@wait_thread = nil
@stderr_thread = nil
@started = false
@initialized = false
@server_info = nil
@modern_protocol_version = nil
@modern_client_info = nil
@modern_capabilities = 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.
# @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
# the handshake below, `:modern` skips it and probes `server/discover`,
# and `:auto` probes `server/discover` first, falling back to the legacy handshake
# when the server does not serve a mutually supported modern version.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
return @server_info if connected?
start unless @started
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
case mode
when :legacy
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
when :modern
connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
when :auto
connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
else
raise ArgumentError, "mode must be :legacy, :modern, or :auto"
end
end
# Whether the transport operates in the stateless modern lifecycle (SEP-2575):
# no handshake was performed and every request carries the `_meta` envelope.
def modern?
!@modern_client_info.nil?
end
# The protocol version in use on this connection, independent of its era:
# negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
# Returns `nil` before `connect` and after `close`.
def protocol_version
@modern_protocol_version || (@server_info && @server_info["protocolVersion"])
end
# Returns true once `connect` has completed the handshake or adopted the modern lifecycle.
# Returns false before the handshake and after `close`.
def connected?
@initialized || modern?
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:)
method = request[:method] || request["method"]
if method == MCP::Methods::SERVER_DISCOVER
# `server/discover` (SEP-2575) is sessionless capability discovery that
# works before (or instead of) `connect`.
start unless @started
elsif !connected?
raise "MCP::Client#connect must be called before sending requests."
end
request = stamp_modern(request)
@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 connected?
@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
leave_modern_mode
end
private
def connect_legacy(client_info:, protocol_version:, capabilities:)
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
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: negotiated_protocol_version, uplevel: 1)
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
# Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
# No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
def connect_modern(client_info:, protocol_version:, capabilities:)
version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
unless MCP::Configuration.modern_protocol_version?(version)
raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
end
@modern_protocol_version = version
@modern_client_info = client_info
@modern_capabilities = capabilities || {}
begin
result = with_probe_read_timeout { probe_discover }
rescue StandardError
leave_modern_mode
raise
end
supported = result["supportedVersions"]
unless supported.is_a?(Array) && supported.include?(version)
leave_modern_mode
raise RequestHandlerError.new(
"Server discovery failed: no mutually supported modern protocol version " \
"(server supports #{supported.inspect})",
{ method: MCP::Methods::SERVER_DISCOVER },
error_type: :internal_error,
)
end
@server_info = result
@server_info
end
# Probes `server/discover` and adopts the modern lifecycle when the server serves
# a mutually supported modern version; otherwise falls back to the legacy handshake.
# The fallback intentionally covers a successful discovery without a mutual modern
# version as well: during the 2026-07-28 rollout a server may answer discovery while
# only serving legacy versions.
def connect_auto(client_info:, protocol_version:, capabilities:)
connect_modern(client_info: client_info, protocol_version: nil, capabilities: capabilities)
rescue RequestHandlerError
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
end
def leave_modern_mode
@modern_protocol_version = nil
@modern_client_info = nil
@modern_capabilities = nil
end
# Bounds the probe read when the caller configured no `read_timeout`, and only for the probe:
# regular requests keep the unbounded default so long-running tools are unaffected.
# The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence
# and falls back on.
def with_probe_read_timeout
return yield if @read_timeout
@read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT
begin
yield
ensure
@read_timeout = nil
end
end
def probe_discover
request = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::SERVER_DISCOVER,
}
@write_mutex.synchronize { write_message(stamp_modern(request)) }
response = read_response(request)
if response.key?("error")
error = response["error"]
raise RequestHandlerError.new(
"Server discovery failed: #{error["message"]}",
{ method: MCP::Methods::SERVER_DISCOVER },
error_type: :internal_error,
)
end
result = response["result"]
unless result.is_a?(Hash)
raise RequestHandlerError.new(
"Server discovery failed: missing result in response",
{ method: MCP::Methods::SERVER_DISCOVER },
error_type: :internal_error,
)
end
result
end
# Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple.
def stamp_modern(request)
return request unless modern? && (request[:id] || request["id"])
ModernEnvelope.stamp(
request,
protocol_version: @modern_protocol_version,
client_info: @modern_client_info,
capabilities: @modern_capabilities,
)
end
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 = read_line(method, params)
raise_connection_error!(method, params) if line.nil?
parsed = JSON.parse(line.strip)
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
# the same way as a frame without an id.
next unless parsed.is_a?(Hash) && 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
# Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`.
# Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit
# is reached before a newline arrives, which signals a server streaming an unbounded frame.
# A short final frame without a trailing newline (EOF) is still returned, since its length
# stays under the limit.
def read_line(method, params)
line = @stdout.gets("\n", @max_line_bytes)
return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
# The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and
# cannot be resumed. Close before raising so a later `send_request` fails cleanly instead
# of parsing a truncated frame.
begin
close
rescue StandardError
nil
end
raise RequestHandlerError.new(
"Server response frame exceeds #{@max_line_bytes} bytes without a newline",
{ 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