diff --git a/docs/building-clients.md b/docs/building-clients.md index d39beda6..b9524e50 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -61,6 +61,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") +http_transport.connect client = MCP::Client.new(transport: http_transport) tools = client.tools @@ -74,16 +75,30 @@ response = client.call_tool( ) ``` +### Handshake + +Call `connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the transport sends an `initialize` request followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): + +```ruby +http_transport.connect +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } + +http_transport.connected? # => true +http_transport.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 transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport: ```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/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