# frozen_string_literal: true require "test_helper" require "json" require "mcp/client" require "mcp/client/stdio" require "mcp/client/tool" module MCP class Client class StdioTest < Minitest::Test include DeprecationWarningTestHelper def test_send_request_raises_when_connect_not_called Open3.expects(:popen3).never transport = Stdio.new(command: "ruby", args: ["server.rb"]) error = assert_raises(RuntimeError) do transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) end assert_equal("MCP::Client#connect must be called before sending requests.", error.message) end def test_send_request_starts_process_and_returns_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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } # Simulate server responses: initialize response, then tools/list response server_thread = Thread.new do # Read and respond to initialize request init_line = stdin_read.gets init_request = JSON.parse(init_line) init_response = { jsonrpc: "2.0", id: init_request["id"], result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, } stdout_write.puts(JSON.generate(init_response)) stdout_write.flush # Read initialized notification stdin_read.gets # Read and respond to tools/list request tools_line = stdin_read.gets tools_request = JSON.parse(tools_line) tools_response = { jsonrpc: "2.0", id: tools_request["id"], result: { tools: [{ name: "test_tool", description: "A test tool", inputSchema: {} }] }, } stdout_write.puts(JSON.generate(tools_response)) stdout_write.flush end transport.connect response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_equal(1, response.dig("result", "tools").size) assert_equal("test_tool", response.dig("result", "tools", 0, "name")) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close stderr_read.close end def test_send_request_answers_a_server_ping_inline 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } pong_line = nil 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification, then the tools/list request. stdin_read.gets stdin_read.gets # Ping the client while it awaits the response; the receiver "MUST respond promptly". stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) stdout_write.flush pong_line = stdin_read.gets stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) stdout_write.flush end transport.connect response = transport.send_request(request: request) server_thread.join assert_equal("test-id", response["id"]) pong = JSON.parse(pong_line) assert_equal("srv-ping-1", pong["id"]) assert_equal({}, pong["result"]) refute(pong.key?("error")) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_a_server_ping_reusing_the_awaited_id_is_answered_and_not_returned_as_the_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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } pong_line = nil 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification, then the tools/list request. stdin_read.gets stdin_read.gets # JSON-RPC id spaces are per sender, so a server ping may reuse the id the client # is awaiting; it must still be answered rather than treated as the response. stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", method: "ping" })) stdout_write.flush pong_line = stdin_read.gets stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) stdout_write.flush end transport.connect response = transport.send_request(request: request) server_thread.join assert_equal("test-id", response["id"]) assert_empty(response.dig("result", "tools")) pong = JSON.parse(pong_line) assert_equal("test-id", pong["id"]) assert_equal({}, pong["result"]) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_a_pong_that_cannot_be_written_does_not_fail_the_awaited_request 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification, then the tools/list request. stdin_read.gets stdin_read.gets # Break the write path: the pong cannot be delivered, and that must not fail the request. stdin_read.close stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) stdout_write.flush end transport.connect response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_empty(response.dig("result", "tools")) ensure server_thread.join stdin_read.close unless stdin_read.closed? stdin_write.close stdout_read.close stdout_write.close end def test_a_ping_with_a_malformed_id_is_not_answered 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } pong_line = nil 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification, then the tools/list request. stdin_read.gets stdin_read.gets # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames outright. stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: { "x" => 1 }, method: "ping" })) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-2", method: "ping" })) stdout_write.flush # The single pong that arrives must answer the well-formed ping, not the malformed one. pong_line = stdin_read.gets stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) stdout_write.flush end transport.connect response = transport.send_request(request: request) server_thread.join assert_equal("test-id", response["id"]) pong = JSON.parse(pong_line) assert_equal("srv-ping-2", pong["id"]) assert_equal({}, pong["result"]) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_request_skips_notifications 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } server_thread = Thread.new do # Handle initialize handshake 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification stdin_read.gets # Read tools/list request stdin_read.gets # Send a notification before the response notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" } stdout_write.puts(JSON.generate(notification)) stdout_write.flush # Then send the actual response stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] }, })) stdout_write.flush end transport.connect response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_empty(response.dig("result", "tools")) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_request_skips_non_object_frames 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification stdin_read.gets # Read tools/list request stdin_read.gets # Non-object frames are skipped like any other non-matching frame. stdout_write.puts("[[]]") stdout_write.puts("null") stdout_write.flush # Then send the actual response stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] }, })) stdout_write.flush end transport.connect response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_empty(response.dig("result", "tools")) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_request_raises_error_when_process_exits stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe dead_thread = mock("wait_thread") dead_thread.stubs(:alive?).returns(false) dead_thread.stubs(:value).returns(nil) stderr_read, _ = IO.pipe Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, dead_thread]) transport = Stdio.new(command: "ruby", args: ["server.rb"]) transport.start error = assert_raises(RequestHandlerError) do transport.connect end assert_equal("Server process has exited", error.message) assert_equal(:internal_error, error.error_type) ensure stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_request_raises_error_on_closed_stdout 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } server_thread = Thread.new do # Handle initialize handshake 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification stdin_read.gets # Read tools/list request, then close stdout stdin_read.gets stdout_write.close end transport.connect error = assert_raises(RequestHandlerError) do transport.send_request(request: request) end assert_equal("Server process closed stdout unexpectedly", error.message) assert_equal(:internal_error, error.error_type) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_close_resets_state 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 assert(transport.instance_variable_get(:@started)) transport.close refute(transport.instance_variable_get(:@started)) refute(transport.instance_variable_get(:@initialized)) ensure stdin_read.close begin stdin_write.close rescue nil end begin stdout_read.close rescue nil end stdout_write.close begin stderr_read.close rescue nil end stderr_write.close end def test_multiple_send_requests_do_not_reinitialize 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 # First call: initialize handshake 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification notification_line = stdin_read.gets received_methods << JSON.parse(notification_line)["method"] # First request: tools/list first_line = stdin_read.gets first_request = JSON.parse(first_line) received_methods << first_request["method"] stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: first_request["id"], result: { tools: [] }, })) stdout_write.flush # Second request: tools/list (no re-initialization) second_line = stdin_read.gets second_request = JSON.parse(second_line) received_methods << second_request["method"] stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: second_request["id"], result: { tools: [] }, })) stdout_write.flush end transport.connect transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" }) assert_equal( ["initialize", "notifications/initialized", "tools/list", "tools/list"], received_methods, ) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_env_is_passed_to_process transport = Stdio.new(command: "ruby", args: ["server.rb"], env: { "FOO" => "bar" }) Open3.expects(:popen3).with({ "FOO" => "bar" }, "ruby", "server.rb").returns( [StringIO.new, StringIO.new, StringIO.new, mock_wait_thread], ) transport.start end def test_send_request_raises_error_on_invalid_json 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"]) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } server_thread = Thread.new do # Handle initialize handshake 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification stdin_read.gets # Read tools/list request, then send invalid JSON stdin_read.gets stdout_write.puts("not valid json") stdout_write.flush end transport.connect error = assert_raises(RequestHandlerError) do transport.send_request(request: request) end assert_equal("Failed to parse server response", error.message) assert_equal(:internal_error, error.error_type) assert_instance_of(JSON::ParserError, error.original_error) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_close_kills_process_on_timeout stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe stderr_read, _ = IO.pipe hanging_thread = mock("wait_thread") hanging_thread.stubs(:alive?).returns(true) hanging_thread.stubs(:pid).returns(99999) hanging_thread.stubs(:value).raises(Timeout::Error) Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, hanging_thread]) transport = Stdio.new(command: "ruby", args: ["server.rb"]) transport.start Process.expects(:kill).with("TERM", 99999).once Process.expects(:kill).with("KILL", 99999).once transport.close ensure stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_read_response_raises_error_on_timeout 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"], read_timeout: 0.01) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list", } server_thread = Thread.new do # Handle initialize handshake 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification stdin_read.gets # Read tools/list request but don't respond (simulate timeout) stdin_read.gets end transport.connect error = assert_raises(RequestHandlerError) do transport.send_request(request: request) end assert_equal("Timed out waiting for server response", error.message) assert_equal(:internal_error, error.error_type) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_request_raises_error_when_stdin_is_closed 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 # Handle initialize handshake 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification stdin_read.gets # Read and respond to first request line = stdin_read.gets request = JSON.parse(line) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: request["id"], result: {}, })) stdout_write.flush end transport.connect # Complete a successful request before breaking the pipe. transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" }) server_thread.join # Now close stdin to simulate broken pipe stdin_write.close error = assert_raises(RequestHandlerError) do transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) end assert_equal("Failed to write to server process", error.message) assert_equal(:internal_error, error.error_type) ensure stdin_read.close begin stdin_write.close rescue nil end stdout_read.close stdout_write.close end def test_close_is_noop_when_not_started transport = Stdio.new(command: "ruby", args: ["server.rb"]) # Should not raise transport.close end def test_start_raises_error_when_already_started 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"]) transport.start error = assert_raises(RuntimeError) do transport.start end assert_equal("MCP::Client::Stdio already started", error.message) ensure stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_start_raises_error_for_invalid_command Open3.stubs(:popen3).raises(Errno::ENOENT.new("No such file or directory - nonexistent_command")) transport = Stdio.new(command: "nonexistent_command") error = assert_raises(RequestHandlerError) do transport.start end assert_match(/Failed to spawn server process/, error.message) assert_equal(:internal_error, error.error_type) assert_instance_of(Errno::ENOENT, error.original_error) 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_send_request_does_not_warn_after_explicit_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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, )) stdout_write.flush stdin_read.gets ping_line = stdin_read.gets ping_request = JSON.parse(ping_line) stdout_write.puts(JSON.generate( jsonrpc: "2.0", id: ping_request["id"], result: {}, )) stdout_write.flush end assert_silent do transport.connect end response = nil assert_silent do response = transport.send_request(request: { jsonrpc: "2.0", id: "ping-id", method: "ping" }) end assert_equal("ping-id", response["id"]) 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_adopts_a_counter_offered_protocol_version # A server may answer `initialize` with a version other than the one offered, and the connection # then speaks the answer rather than the offer. This server counter-offers a handshake version # to clients asking `initialize` for a modern one, which only resolves anything because # clients behave this way. counter_offered = "2025-06-18" refute_equal( counter_offered, MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, "the counter-offer has to differ from the offer for this to test anything", ) 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"]) offered = nil server_thread = Thread.new do init_request = JSON.parse(stdin_read.gets) offered = init_request.dig("params", "protocolVersion") stdout_write.puts(JSON.generate( jsonrpc: "2.0", id: init_request["id"], result: { protocolVersion: counter_offered }, )) stdout_write.flush stdin_read.gets end transport.connect assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) assert_equal(counter_offered, transport.protocol_version) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result # A server answering `initialize` with a modern version is refused like an unknown one: # the handshake settles on a legacy version by definition (SEP-2575 era model), # and the TypeScript and Python clients refuse a modern counter-offer the same way. 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: "2026-07-28" }, )) stdout_write.flush end error = assert_raises(RequestHandlerError) do transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) end assert_includes(error.message, "2026-07-28") 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_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake # Validated before the child process is spawned: a pure argument error must not leave # an orphaned server process behind. Open3.expects(:popen3).never transport = Stdio.new(command: "ruby", args: ["server.rb"]) error = assert_raises(ArgumentError) do transport.connect(protocol_version: "2026-07-28", mode: :legacy) end assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") 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 def test_new_raises_argument_error_when_max_line_bytes_is_not_a_positive_integer [nil, 0, -1, 1.5, "1024"].each do |invalid| error = assert_raises(ArgumentError) do Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: invalid) end assert_equal("max_line_bytes must be a positive Integer", error.message) end end def test_send_request_raises_when_response_frame_exceeds_max_line_bytes 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"], max_line_bytes: 1024) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush # Read initialized notification. stdin_read.gets # Read tools/list request, then stream an unbounded frame with no newline. stdin_read.gets stdout_write.write("A" * 2000) stdout_write.flush end transport.connect error = assert_raises(RequestHandlerError) do transport.send_request(request: request) end assert_match(/exceeds 1024 bytes without a newline/, error.message) assert_equal(:internal_error, error.error_type) # The stream is desynced (leftover bytes in the pipe), so the transport is closed # rather than left resumable on a corrupt stream. refute_predicate(transport, :connected?) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_request_accepts_response_within_custom_max_line_bytes 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"], max_line_bytes: 1024) request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" } 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: {}, serverInfo: { name: "test-server", version: "1.0.0" }, }, })) stdout_write.flush stdin_read.gets tools_line = stdin_read.gets tools_request = JSON.parse(tools_line) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] }, })) stdout_write.flush end transport.connect response = transport.send_request(request: request) assert_equal("test-id", response["id"]) assert_equal({ "tools" => [] }, response["result"]) ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_send_notification_writes_json_line_without_waiting_for_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"]) notification = { jsonrpc: "2.0", method: MCP::Methods::NOTIFICATIONS_CANCELLED, params: { requestId: "abc-123", reason: "user cancel" }, } # Server: respond to initialize, then read the notification line. 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: {}, serverInfo: { name: "test", version: "1.0.0" } }, })) stdout_write.flush stdin_read.gets # skip initialized notification stdin_read.gets # read the cancellation notification line we are testing end result = transport.send_notification(notification: notification) assert_nil(result, "send_notification must return nil (no response expected)") ensure server_thread.join stdin_read.close stdin_write.close stdout_read.close stdout_write.close end def test_concurrent_write_message_does_not_interleave_lines # Regression: cancellation can call `send_notification` from a thread while `send_request` is mid-flight # on another thread. The two writes must serialize so JSON-RPC lines do not interleave on stdin. 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: respond to initialize, then accept multiple lines. 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: {}, serverInfo: { name: "t", version: "1.0.0" } }, })) stdout_write.flush stdin_read.gets # initialized end # Force initialization synchronously. notification = { jsonrpc: "2.0", method: MCP::Methods::NOTIFICATIONS_CANCELLED, params: { requestId: "warmup" }, } transport.send_notification(notification: notification) server_thread.join # Now hammer send_notification from many threads concurrently. Each line is one JSON object terminated by `\n`; # if writes interleave, the received text would not be one valid JSON object per line. threads = 8.times.map do |i| Thread.new do 10.times do |j| transport.send_notification( notification: { jsonrpc: "2.0", method: MCP::Methods::NOTIFICATIONS_CANCELLED, params: { requestId: "t#{i}-#{j}" }, }, ) end end end threads.each(&:join) # Drain everything written to stdin and verify each line parses as JSON. stdin_write.close lines = [] until stdin_read.eof? line = stdin_read.gets break if line.nil? lines << line end lines.each do |line| assert(JSON.parse(line.strip), "interleaved write produced unparseable line: #{line.inspect}") end assert_operator(lines.size, :>=, 80, "expected at least 80 notification lines") ensure stdout_read.close stdout_write.close begin stderr_read.close rescue nil 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 test_send_request_allows_server_discover_before_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 discover_request = JSON.parse(stdin_read.gets) response = { jsonrpc: "2.0", id: discover_request["id"], result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" }, } stdout_write.puts(JSON.generate(response)) stdout_write.flush end # No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery. response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" }) assert_equal(["2026-07-28"], response.dig("result", "supportedVersions")) refute_predicate(transport, :connected?) ensure server_thread&.join [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| io.close unless io.closed? end end def test_connect_modern_probes_discover_and_stamps_requests 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 = [] server_thread = Thread.new do discover_request = JSON.parse(stdin_read.gets) received << discover_request stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: discover_request["id"], result: { supportedVersions: ["2026-07-28"], capabilities: {}, serverInfo: { name: "test-server", version: "1.0" }, ttlMs: 0, cacheScope: "private", }, })) stdout_write.flush tools_request = JSON.parse(stdin_read.gets) received << tools_request stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } })) stdout_write.flush end result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" }) response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" }) assert_equal(["2026-07-28"], result["supportedVersions"]) assert_predicate(transport, :modern?) assert_predicate(transport, :connected?) assert_equal("2026-07-28", transport.protocol_version) assert_equal("t1", response["id"]) # No `initialize` was sent; both frames carry the SEP-2575 envelope. assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] }) triple = received[1].dig("params", "_meta") assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"]) assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"]) assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"]) ensure server_thread&.join [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| io.close unless io.closed? end end def test_connect_modern_warns_for_deprecated_capabilities # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks. 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 discover_request = JSON.parse(stdin_read.gets) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: discover_request["id"], result: { supportedVersions: ["2026-07-28"], capabilities: {}, serverInfo: { name: "test-server", version: "1.0" }, ttlMs: 0, cacheScope: "private", }, })) stdout_write.flush end assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) end assert_predicate(transport, :modern?) ensure server_thread&.join [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| io.close unless io.closed? end end def test_connect_auto_falls_back_to_the_legacy_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 discover_request = JSON.parse(stdin_read.gets) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: discover_request["id"], error: { code: -32601, message: "Method not found" }, })) stdout_write.flush init_request = JSON.parse(stdin_read.gets) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: init_request["id"], result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test-server", version: "1.0" }, }, })) stdout_write.flush # `notifications/initialized` stdin_read.gets end result = transport.connect(mode: :auto) assert_equal("2025-11-25", result["protocolVersion"]) assert_equal("2025-11-25", transport.protocol_version) refute_predicate(transport, :modern?) assert_predicate(transport, :connected?) ensure server_thread&.join [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| io.close unless io.closed? end end def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured transport = Stdio.new(command: "ruby", args: ["server.rb"]) transport.stubs(:start) observed_timeout = nil transport.define_singleton_method(:probe_discover) do observed_timeout = @read_timeout raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error) end transport.define_singleton_method(:connect_legacy) do |**| { "protocolVersion" => "2025-11-25" } end result = transport.connect(mode: :auto) assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout) assert_nil(transport.instance_variable_get(:@read_timeout)) assert_equal("2025-11-25", result["protocolVersion"]) end def test_connect_auto_falls_back_when_the_server_never_answers_the_probe 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]) # A configured `read_timeout` also bounds the probe; the server below reads # the `server/discover` request and never responds, which must count as # legacy evidence instead of blocking `connect` forever. transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05) server_thread = Thread.new do # Silently swallow the probe. stdin_read.gets init_request = JSON.parse(stdin_read.gets) stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: init_request["id"], result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test-server", version: "1.0" }, }, })) stdout_write.flush # `notifications/initialized` stdin_read.gets end result = transport.connect(mode: :auto) assert_equal("2025-11-25", result["protocolVersion"]) refute_predicate(transport, :modern?) ensure server_thread&.join [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| io.close unless io.closed? end end def mock_wait_thread thread = mock("wait_thread") thread.stubs(:alive?).returns(true) thread.stubs(:value).returns(nil) thread end end end end