ruby-sdk/docs/building-clients.md
Koichi ITO 46b738e945 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.
2026-05-03 15:57:22 +09:00

5.3 KiB

layout title nav_order
default Building Clients 4

Building an MCP Client

The MCP::Client class provides an interface for interacting with MCP servers.

Supported operations:

  • Tool listing (MCP::Client#tools) and invocation (MCP::Client#call_tool)
  • Resource listing (MCP::Client#resources) and reading (MCP::Client#read_resources)
  • Resource template listing (MCP::Client#resource_templates)
  • Prompt listing (MCP::Client#prompts) and retrieval (MCP::Client#get_prompt)
  • Completion requests (MCP::Client#complete)

Handshake

Call MCP::Client#connect to perform the MCP initialization handshake before sending any other requests. The client sends an initialize request through the transport, followed by the required notifications/initialized notification, and caches the server's InitializeResult (protocol version, capabilities, server info, instructions):

client.connect
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }

client.connected?  # => true
client.server_info # => cached InitializeResult

connect accepts optional client_info:, protocol_version:, and capabilities: keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After close, state is cleared and connect will handshake again.

This applies to both the Stdio and HTTP transports below.

Stdio Transport

Use MCP::Client::Stdio to interact with MCP servers running as subprocesses:

stdio_transport = MCP::Client::Stdio.new(
  command: "bundle",
  args: ["exec", "ruby", "path/to/server.rb"],
  env: { "API_KEY" => "my_secret_key" },
  read_timeout: 30
)
client = MCP::Client.new(transport: stdio_transport)
client.connect

tools = client.tools
tools.each do |tool|
  puts "Tool: #{tool.name} - #{tool.description}"
end

response = client.call_tool(
  tool: tools.first,
  arguments: { message: "Hello, world!" }
)

stdio_transport.close
Parameter Required Description
command: Yes The command to spawn the server process.
args: No An array of arguments passed to the command. Defaults to [].
env: No A hash of environment variables for the server process. Defaults to nil.
read_timeout: No Timeout in seconds for waiting for a server response. Defaults to nil.

HTTP Transport

Use MCP::Client::HTTP to interact with MCP servers over HTTP. Requires the faraday gem, plus event_stream_parser if the server uses SSE (text/event-stream) responses:

gem 'mcp'
gem 'faraday', '>= 2.0'
gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
client = MCP::Client.new(transport: http_transport)
client.connect

tools = client.tools
tools.each do |tool|
  puts "Tool: #{tool.name} - #{tool.description}"
end

response = client.call_tool(
  tool: tools.first,
  arguments: { message: "Hello, world!" }
)

Sessions

After connect succeeds, the HTTP transport captures the Mcp-Session-Id header and protocolVersion from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:

http_transport.session_id       # => "abc123..."
http_transport.protocol_version # => "2025-11-25"

If the server terminates the session, subsequent requests return HTTP 404 and the transport raises MCP::Client::SessionExpiredError (a subclass of RequestHandlerError). Session state is cleared automatically; callers should start a new session by calling connect again.

To explicitly terminate a session (e.g., when the client application is shutting down), call close. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A 405 Method Not Allowed response (server doesn't support client-initiated termination) or 404 Not Found (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling close without an active session is a no-op.

http_transport.close

Authorization

Provide custom headers for authentication:

http_transport = MCP::Client::HTTP.new(
  url: "https://api.example.com/mcp",
  headers: {
    "Authorization" => "Bearer my_token"
  }
)
client = MCP::Client.new(transport: http_transport)

Customizing the Faraday Connection

Pass a block to customize the underlying Faraday connection:

http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
  faraday.use MyApp::Middleware::HttpRecorder
  faraday.adapter :typhoeus
end

Custom Transport

If the built-in transports do not fit your needs, you can implement your own:

class CustomTransport
  def send_request(request:)
    # Your transport-specific logic here.
    # Returns a Hash modeling a JSON-RPC response object.
  end
end

client = MCP::Client.new(transport: CustomTransport.new)

For more details, see the full README.