From 31892a5163060fd4e50273ed11905d0c56a3a316 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 20 Apr 2026 16:03:31 -0400 Subject: [PATCH] Add client-level connect for initialize handshake Per MCP spec, clients must send an `initialize` request followed by a `notifications/initialized` notification before issuing any other requests. The server's `InitializeResult` (protocol version, capabilities, server info, instructions) negotiates the session for the lifetime of the connection. `MCP::Client#connect` performs the handshake, returns the server's `InitializeResult`, and exposes `connected?` and `server_info` readers. It delegates to `transport.connect(...)` when the transport exposes an explicit handshake (e.g. `MCP::Client::HTTP`) and is a no-op otherwise (e.g. `MCP::Client::Stdio`, which manages the handshake implicitly on the first request). `initialize` is a protocol method, so the public API lives on the client. This matches the Python SDK (`ClientSession.initialize()`) and the TypeScript SDK (`Client.connect(transport)`). Transports retain their transport-specific concerns: HTTP captures the `Mcp-Session-Id` header and negotiated protocol version via the existing `capture_session_info` hook that fires when an `initialize` request passes through `send_request`. https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization --- docs/building-clients.md | 19 +++- lib/mcp/client.rb | 42 ++++++++ lib/mcp/client/http.rb | 80 +++++++++++++++- test/mcp/client/http_test.rb | 181 +++++++++++++++++++++++++++++++++++ test/mcp/client_test.rb | 71 ++++++++++++++ 5 files changed, 390 insertions(+), 3 deletions(-) diff --git a/docs/building-clients.md b/docs/building-clients.md index d39beda6..1fa817a6 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -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| @@ -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. diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 31057ed9..7cf59830 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -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. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index c0d72471..cbde8ae2 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -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 @@ -13,7 +17,7 @@ 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 @@ -21,6 +25,78 @@ def initialize(url:, headers: {}, &block) @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. @@ -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! diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 19917dd8..fdff6b58 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -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) @@ -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 diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 4142ffd8..e28aedc2 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -5,6 +5,77 @@ module MCP class ClientTest < Minitest::Test + def test_connect_delegates_to_transport_and_forwards_keyword_args + transport = mock + init_result = { + "protocolVersion" => "2025-11-25", + "capabilities" => { "tools" => {} }, + "serverInfo" => { "name" => "test-server", "version" => "1.0" }, + } + transport.expects(:connect).with( + client_info: { name: "my-app", version: "1.0" }, + protocol_version: "2025-11-25", + capabilities: { roots: {} }, + ).returns(init_result).once + + result = Client.new(transport: transport).connect( + client_info: { name: "my-app", version: "1.0" }, + protocol_version: "2025-11-25", + capabilities: { roots: {} }, + ) + + assert_equal(init_result, result) + end + + def test_connect_passes_nil_defaults_to_transport + transport = mock + transport.expects(:connect) + .with(client_info: nil, protocol_version: nil, capabilities: {}) + .returns({ "protocolVersion" => "2025-11-25" }).once + + Client.new(transport: transport).connect + end + + def test_connect_is_noop_when_transport_does_not_respond_to_connect + transport = mock + transport.stubs(:respond_to?).with(:connect).returns(false) + transport.stubs(:respond_to?).with(:server_info).returns(false) + + client = Client.new(transport: transport) + + assert_nil(client.connect) + assert_nil(client.server_info) + end + + def test_connected_delegates_to_transport_when_supported + transport = mock + transport.expects(:connected?).returns(true) + + assert_predicate(Client.new(transport: transport), :connected?) + end + + def test_connected_returns_true_when_transport_does_not_respond_to_connected + transport = mock + transport.stubs(:respond_to?).with(:connected?).returns(false) + + assert_predicate(Client.new(transport: transport), :connected?) + end + + def test_server_info_delegates_to_transport_when_supported + transport = mock + init_result = { "protocolVersion" => "2025-11-25" } + transport.expects(:server_info).returns(init_result) + + assert_equal(init_result, Client.new(transport: transport).server_info) + end + + def test_server_info_returns_nil_when_transport_does_not_expose_it + transport = mock + transport.stubs(:respond_to?).with(:server_info).returns(false) + + assert_nil(Client.new(transport: transport).server_info) + end + def test_tools_sends_request_to_transport_and_returns_tools_array transport = mock mock_response = {