Add client-level connect handshake to stdio transport

## Motivation and Context

This commit implements Phase 1 of 3 for #334.

The MCP specification requires an `initialize` request followed by
a `notifications/initialized` notification before any other interaction.
PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect`
to expose this handshake explicitly for the HTTP transport,
but stdio remained on its private `initialize_session` lazy-init path,
inconsistent with the Python SDK (`ClientSession.initialize()`) and
TypeScript SDK (`Client.connect(transport)`),
both of which require an explicit handshake call regardless of transport.

The implicit init path on the first `send_request` is
preserved as a non-breaking compatibility shim. Public API added on `Stdio`:

- `connect(client_info:, protocol_version:, capabilities:)` performs the
  handshake, validates the negotiated `protocolVersion` against
  `MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`,
  caches the `InitializeResult`, and is idempotent.
- `connected?` reports handshake completion.
- `server_info` exposes the cached `InitializeResult`.

Documentation and examples are updated so new users learn the explicit pattern
from the start.

## How Has This Been Tested?

Fifteen regression tests cover: explicit handshake, `server_info` caching,
idempotence, custom keyword arguments, JSON-RPC error response, missing
or non-Hash `result`, unsupported protocol version with state rollback,
state rollback when the `notifications/initialized` write fails,
retry after a failed handshake, and the `connected?` / `server_info` lifecycle.

The pre-existing stdio tests pass without modification, verifying that
the implicit-init compatibility path still works.

## Breaking Changes

None for the typical "implicit init via `client.tools` / `client.call_tool`" usage.
Existing stdio clients keep working unchanged.

Three observable behavior changes for code that inspects transport state directly:

- `Client#connected?` for a never-initialized stdio client returns `false`
  (previously fell through to a `true` default via the `respond_to?` fallback).
- `Client#server_info` for stdio returns the cached `InitializeResult`
  after the handshake (previously always `nil` via the fallback).
- The implicit handshake path now validates the server's negotiated
  protocol version, raising `RequestHandlerError` for unsupported versions.
  Production servers that return one of the supported versions are unaffected;
  this closes a pre-existing spec-conformance gap on stdio.
This commit is contained in:
Koichi ITO 2026-05-03 02:43:19 +09:00
commit 46b738e945
2 changed files with 581 additions and 75 deletions

View file

@ -61,18 +61,17 @@ module MCP
# The server's `InitializeResult` (protocol version, capabilities, server info,
# instructions), as reported by the transport after a successful `connect`.
# Returns `nil` before `connect`, after `close`, or when the transport manages
# the handshake implicitly and does not expose it (e.g. stdio).
# Returns `nil` before `connect`, after `close`, or when the transport does
# not expose a cached handshake result.
def server_info
transport.server_info if transport.respond_to?(:server_info)
end
# Performs the MCP `initialize` handshake by delegating to the transport when
# it exposes a `connect` method (e.g. `MCP::Client::HTTP`). Returns the
# server's `InitializeResult`.
# Performs the MCP `initialize` handshake by delegating to the transport
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
# `InitializeResult`.
#
# When the transport does not respond to `:connect` (e.g. `MCP::Client::Stdio`
# manages the handshake implicitly on the first request), this is a no-op and
# When the transport does not respond to `:connect`, this is a no-op and
# returns `nil`.
#
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
@ -91,10 +90,9 @@ module MCP
)
end
# Returns true once `connect` has completed the handshake on transports that
# expose connection state. Transports that manage the handshake implicitly
# (e.g. stdio) always report `true`, since the first request will initialize
# on demand.
# Returns true once `connect` has completed the handshake on the underlying
# transport. Transports that do not expose connection state are assumed
# connected and return `true`.
def connected?
return transport.connected? if transport.respond_to?(:connected?)

View file

@ -19,7 +19,7 @@ module MCP
CLOSE_TIMEOUT = 2
STDERR_READ_SIZE = 4096
attr_reader :command, :args, :env
attr_reader :command, :args, :env, :server_info
def initialize(command:, args: [], env: nil, read_timeout: nil)
@command = command
@ -33,11 +33,108 @@ module MCP
@stderr_thread = nil
@started = false
@initialized = false
@server_info = nil
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
def send_request(request:)
start unless @started
initialize_session unless @initialized
connect unless @initialized
write_message(request)
read_response(request)
@ -98,57 +195,11 @@ module MCP
@stderr_thread.join(CLOSE_TIMEOUT)
@started = false
@initialized = false
@server_info = nil
end
private
# The client MUST send a protocol version it supports. This SHOULD be the latest version.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
#
# Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs:
# https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175
# https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495
def initialize_session
init_request = {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::INITIALIZE,
params: {
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION },
},
}
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.key?("result")
raise RequestHandlerError.new(
"Server initialization failed: missing result in response",
{ method: MCP::Methods::INITIALIZE },
error_type: :internal_error,
)
end
notification = {
jsonrpc: JsonRpcHandler::Version::V2_0,
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
}
write_message(notification)
@initialized = true
end
def write_message(message)
ensure_running!
json = JSON.generate(message)