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
19 changes: 17 additions & 2 deletions docs/building-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
```ruby
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|
Expand All @@ -74,16 +75,30 @@ 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 a successful `initialize` request, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes the session ID on subsequent requests. Both are exposed on the transport:
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:

```ruby
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 sending a fresh `initialize` request.
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.

Expand Down
42 changes: 42 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,48 @@ def initialize(transport:)
# So keeping it public
attr_reader :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).
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`.
#
# 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
# returns `nil`.
#
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
# @param protocol_version [String, nil] Protocol version to offer.
# @param capabilities [Hash] Capabilities advertised by the client.
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
# does not expose an explicit handshake.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
def connect(client_info: nil, protocol_version: nil, capabilities: {})
return unless transport.respond_to?(:connect)

transport.connect(
client_info: client_info,
protocol_version: protocol_version,
capabilities: 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.
def connected?
return transport.connected? if transport.respond_to?(:connected?)

true
end

# Returns a single page of tools from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
Expand Down
80 changes: 79 additions & 1 deletion lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# frozen_string_literal: true

require "securerandom"
require_relative "../../json_rpc_handler"
require_relative "../configuration"
require_relative "../methods"
require_relative "../version"

module MCP
class Client
Expand All @@ -13,14 +17,86 @@ class HTTP
SESSION_ID_HEADER = "Mcp-Session-Id"
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"

attr_reader :url, :session_id, :protocol_version
attr_reader :url, :session_id, :protocol_version, :server_info

def initialize(url:, headers: {}, &block)
@url = url
@headers = headers
@faraday_customizer = block
@session_id = nil
@protocol_version = nil
@server_info = nil
@connected = false
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.
#
# @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
# or a malformed result.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
def connect(client_info: nil, protocol_version: nil, capabilities: {})
return @server_info if connected?

client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION

response = send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::INITIALIZE,
params: {
protocolVersion: protocol_version,
capabilities: capabilities,
clientInfo: client_info,
},
})

if response.is_a?(Hash) && 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.is_a?(Hash) && 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"]

send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
})

@connected = true
@server_info
end

# Returns true once `connect` has completed the full handshake
# (`initialize` response received and `notifications/initialized` sent).
# Returns false before the first handshake and after `close`.
def connected?
@connected
end

# Sends a JSON-RPC request and returns the parsed response body.
Expand Down Expand Up @@ -159,6 +235,8 @@ def capture_session_info(method, response, body)
def clear_session
@session_id = nil
@protocol_version = nil
@server_info = nil
@connected = false
end

def require_faraday!
Expand Down
181 changes: 181 additions & 0 deletions test/mcp/client/http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,171 @@ def test_close_is_idempotent
assert_requested(:delete, url, times: 1)
end

def test_connect_performs_initialize_handshake
init_stub = stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" },
body: {
result: {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "test-server", version: "1.0" },
},
}.to_json,
)

notification_stub = stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
.to_return(status: 202, body: "")

result = client.connect

assert_requested(init_stub)
assert_requested(notification_stub)
assert_equal("2025-11-25", result["protocolVersion"])
assert_equal({ "tools" => {} }, result["capabilities"])
assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"])
end

def test_connect_caches_server_info
stub_initialize
stub_notification

client.connect

assert_equal("2025-11-25", client.server_info["protocolVersion"])
end

def test_connect_uses_default_client_info_and_protocol_version
notification_stub = stub_notification

init_stub = stub_request(:post, url)
.with do |req|
body = JSON.parse(req.body)
body["method"] == "initialize" &&
body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION &&
body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } &&
body["params"]["capabilities"] == {}
end
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json,
)

client.connect

assert_requested(init_stub)
assert_requested(notification_stub)
end

def test_connect_accepts_custom_parameters
notification_stub = stub_notification

init_stub = stub_request(:post, url)
.with do |req|
body = JSON.parse(req.body)
body["method"] == "initialize" &&
body["params"]["protocolVersion"] == "2025-03-26" &&
body["params"]["clientInfo"] == { "name" => "my-app", "version" => "9.9" } &&
body["params"]["capabilities"] == { "roots" => { "listChanged" => true } }
end
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { protocolVersion: "2025-03-26" } }.to_json,
)

client.connect(
client_info: { name: "my-app", version: "9.9" },
protocol_version: "2025-03-26",
capabilities: { roots: { listChanged: true } },
)

assert_requested(init_stub)
assert_requested(notification_stub)
end

def test_connect_is_idempotent
init_stub = stub_initialize
notification_stub = stub_notification

first_result = client.connect
second_result = client.connect

assert_same(first_result, second_result)
assert_requested(init_stub, times: 1)
assert_requested(notification_stub, times: 1)
end

def test_connect_raises_on_jsonrpc_error_response
stub_request(:post, url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json,
)

error = assert_raises(RequestHandlerError) do
client.connect
end

assert_includes(error.message, "Unsupported protocol version")
refute_predicate(client, :connected?)
end

def test_connect_raises_on_missing_result
stub_request(:post, url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { jsonrpc: "2.0", id: "x" }.to_json,
)

error = assert_raises(RequestHandlerError) do
client.connect
end

assert_includes(error.message, "missing result in response")
refute_predicate(client, :connected?)
end

def test_connected_lifecycle
refute_predicate(client, :connected?)

stub_initialize
stub_notification
client.connect

assert_predicate(client, :connected?)

stub_request(:delete, url).to_return(status: 200)
client.close

refute_predicate(client, :connected?)
end

def test_reconnect_after_close
stub_initialize
stub_notification
client.connect
stub_request(:delete, url).to_return(status: 200)
client.close

stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" },
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
)

client.connect

assert_predicate(client, :connected?)
assert_equal("s2", client.session_id)
end

def test_close_allows_reinitializing_a_fresh_session
initialize_session
stub_request(:delete, url).to_return(status: 200)
Expand Down Expand Up @@ -732,6 +897,22 @@ def initialize_session
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
end

def stub_initialize
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
.to_return(
status: 200,
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
)
end

def stub_notification
stub_request(:post, url)
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
.to_return(status: 202, body: "")
end

def stub_request(method, url)
WebMock.stub_request(method, url)
end
Expand Down
Loading