Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,9 @@ stdio_transport = MCP::Client::Stdio.new(
)
client = MCP::Client.new(transport: stdio_transport)

# Perform the MCP initialization handshake before sending any requests.
client.connect

# List available tools.
tools = client.tools
tools.each do |tool|
Expand Down Expand Up @@ -1822,6 +1825,9 @@ Example usage:
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
client = MCP::Client.new(transport: http_transport)

# Perform the MCP initialization handshake before sending any requests.
client.connect

# List available tools
tools = client.tools
tools.each do |tool|
Expand Down
31 changes: 17 additions & 14 deletions docs/building-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ The `MCP::Client` class provides an interface for interacting with MCP servers.
- 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) 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):

```ruby
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:
Expand All @@ -28,6 +44,7 @@ stdio_transport = MCP::Client::Stdio.new(
read_timeout: 30
)
client = MCP::Client.new(transport: stdio_transport)
client.connect

tools = client.tools
tools.each do |tool|
Expand Down Expand Up @@ -75,20 +92,6 @@ response = client.call_tool(
)
```

### Handshake

Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): 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):

```ruby
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.

### 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:
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ $ ruby examples/stdio_client.rb

The client will automatically launch `stdio_server.rb` as a subprocess and demonstrate:

- Performing the MCP initialization handshake via `client.connect`
- Listing and calling tools
- Listing prompts
- Listing and reading resources
- Automatic MCP protocol initialization
- Transport cleanup on exit

### 3. HTTP Server (`http_server.rb`)
Expand Down
3 changes: 3 additions & 0 deletions examples/stdio_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
client = MCP::Client.new(transport: transport)

begin
# Perform the MCP initialization handshake before sending any requests.
client.connect

# List available tools
puts "=== Listing tools ==="
tools = client.tools
Expand Down
20 changes: 9 additions & 11 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,17 @@ def initialize(transport:)

# 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.
Expand All @@ -91,10 +90,9 @@ def connect(client_info: nil, protocol_version: nil, capabilities: {})
)
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?)

Expand Down
149 changes: 100 additions & 49 deletions lib/mcp/client/stdio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Stdio
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
Expand All @@ -33,11 +33,108 @@ def initialize(command:, args: [], env: nil, read_timeout: nil)
@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)
Expand Down Expand Up @@ -98,57 +195,11 @@ def close
@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)
Expand Down
Loading