From 46b738e945b775bacd4d69d54a70a476ff77e185 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 3 May 2026 02:43:19 +0900 Subject: [PATCH] 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. --- README.md | 6 + docs/building-clients.md | 31 +-- examples/README.md | 2 +- examples/stdio_client.rb | 3 + lib/mcp/client.rb | 20 +- lib/mcp/client/stdio.rb | 149 ++++++++---- test/mcp/client/stdio_test.rb | 445 ++++++++++++++++++++++++++++++++++ 7 files changed, 581 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index d180dccb..71fb5acd 100644 --- a/README.md +++ b/README.md @@ -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| @@ -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| diff --git a/docs/building-clients.md b/docs/building-clients.md index 1fa817a6..247ec757 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -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: @@ -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| @@ -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: diff --git a/examples/README.md b/examples/README.md index 71700e15..c0fcb334 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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`) diff --git a/examples/stdio_client.rb b/examples/stdio_client.rb index 8bcb5e05..60b20674 100644 --- a/examples/stdio_client.rb +++ b/examples/stdio_client.rb @@ -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 diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 7cf59830..b6516714 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -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. @@ -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?) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index db4e726b..9ddabc81 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -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 @@ -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) @@ -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) diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index b177f6af..4f09abf8 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -722,8 +722,453 @@ def test_send_request_raises_error_for_missing_result stdout_write.close end + def test_connect_performs_initialize_handshake_explicitly + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + received_methods = [] + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + received_methods << init_request["method"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + + notification_line = stdin_read.gets + received_methods << JSON.parse(notification_line)["method"] + end + + result = transport.connect + + server_thread.join + + assert_equal(["initialize", "notifications/initialized"], received_methods) + assert_equal("2025-11-25", result["protocolVersion"]) + assert_equal({ "tools" => {} }, result["capabilities"]) + assert_equal({ "name" => "test-server", "version" => "1.0.0" }, result["serverInfo"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_caches_server_info + transport, server_thread, pipes = stub_successful_connect + + transport.connect + + assert_equal("2025-11-25", transport.server_info["protocolVersion"]) + assert_equal({ "tools" => {} }, transport.server_info["capabilities"]) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connect_is_idempotent + transport, server_thread, pipes = stub_successful_connect + + first_result = transport.connect + second_result = transport.connect + + assert_same(first_result, second_result) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connect_accepts_custom_parameters + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + sent_init_params = nil + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + sent_init_params = init_request["params"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-03-26" }, + )) + stdout_write.flush + stdin_read.gets + end + + transport.connect( + client_info: { name: "my-app", version: "9.9" }, + protocol_version: "2025-03-26", + capabilities: { roots: { listChanged: true } }, + ) + + assert_equal("2025-03-26", sent_init_params["protocolVersion"]) + assert_equal({ "name" => "my-app", "version" => "9.9" }, sent_init_params["clientInfo"]) + assert_equal({ "roots" => { "listChanged" => true } }, sent_init_params["capabilities"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_jsonrpc_error_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + error: { code: -32602, message: "boom" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "boom") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_missing_result + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(transport, :connected?) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_non_hash_result + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: [], + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "missing result in response") + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_clears_state_when_initialized_notification_fails + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + # Close stdin_read first so the next write to @stdin (the notification) raises EPIPE. + stdin_read.close + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25" }, + )) + stdout_write.flush + end + + assert_raises(RequestHandlerError) do + transport.connect + end + + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + [stdin_read, stdin_write, stdout_read, stdout_write].each do |io| + io.close unless io.closed? + end + end + + def test_connect_can_be_retried_after_failed_handshake + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + # First handshake: server returns an unsupported protocol version. + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "1999-01-01" }, + )) + stdout_write.flush + + # Second handshake: server returns a supported protocol version. + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "2025-11-25" }, + )) + stdout_write.flush + stdin_read.gets + end + + assert_raises(RequestHandlerError) do + transport.connect + end + + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + + result = transport.connect + + server_thread.join + + assert_predicate(transport, :connected?) + assert_equal("2025-11-25", result["protocolVersion"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connect_raises_on_unsupported_protocol_version + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + sent_methods = [] + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + sent_methods << init_request["method"] + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { protocolVersion: "1999-01-01" }, + )) + stdout_write.flush + end + + error = assert_raises(RequestHandlerError) do + transport.connect + end + + assert_includes(error.message, "unsupported protocol version") + assert_includes(error.message, "1999-01-01") + assert_equal(["initialize"], sent_methods) + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_connected_is_false_before_first_send_request + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + refute_predicate(transport, :connected?) + end + + def test_connected_is_true_after_explicit_connect + transport, server_thread, pipes = stub_successful_connect + + refute_predicate(transport, :connected?) + + transport.connect + + assert_predicate(transport, :connected?) + ensure + server_thread.join + pipes.each(&:close) + end + + def test_connected_is_false_after_close + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + transport.instance_variable_set(:@initialized, true) + + assert_predicate(transport, :connected?) + + transport.close + + refute_predicate(transport, :connected?) + ensure + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io| + io.close unless io.closed? + end + end + + def test_server_info_is_nil_before_connect + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + assert_nil(transport.server_info) + end + + def test_server_info_is_cleared_after_close + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + + wait_thread = mock("wait_thread") + wait_thread.stubs(:alive?).returns(true) + wait_thread.stubs(:value).returns(nil) + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + transport.start + transport.instance_variable_set(:@initialized, true) + transport.instance_variable_set(:@server_info, { "protocolVersion" => "2025-11-25" }) + + transport.close + + assert_nil(transport.server_info) + ensure + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io| + io.close unless io.closed? + end + end + private + def stub_successful_connect + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate( + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + )) + stdout_write.flush + stdin_read.gets + end + + [transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]] + end + def mock_wait_thread thread = mock("wait_thread") thread.stubs(:alive?).returns(true)