ruby-sdk/test/mcp/client/stdio_test.rb
Koichi ITO d0d7308a89 Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575
## Motivation and Context

Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic.

Server side, `Server#discover` reaches its spec-final shape:

- `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
  legacy versions are negotiated via `initialize`, not selected from discovery. This also closes
  an interop hazard of the interim response: auto-negotiating TS and Python clients treat
  any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`,
  so advertising only legacy versions made them fail outright.
- The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints
  on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with
  `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise.
- The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp
  instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002
  (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level
  `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and
  the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built
  against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`.

Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default:

- `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct
  (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`).
  It works before `connect`; the stdio transport's connect guard exempts `server/discover`
  and spawns the subprocess on demand.
- `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs
  (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:`
  (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover`
  and falls back to the legacy handshake on any discovery failure, including a successful discovery
  without a mutually supported modern version (rollout tolerance for servers that answer discovery
  while only serving legacy versions). Custom transports whose `connect` does not declare `mode:`
  keep receiving the historical legacy call shape, detected by keyword introspection;
  a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords
  stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError`
  instead of silently downgrading.
- The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`,
  instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried
  the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make
  the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version,
  the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here
  as `mode: :modern` with `protocol_version: "2026-07-28"`.
- An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe,
  so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle.
  `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools
  and when using server-initiated requests, which exist only on the legacy lifecycle.
- Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends
  on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference:
  `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one),
  `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta`
  `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server
  does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover.
- The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK)
  when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately,
  but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback
  instead of blocking `connect` forever. Regular requests keep the unbounded default.
- In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto
  every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names
  from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and
  keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize`
  is never sent.
- The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake
  and its scenario servers do not expect a preceding probe.

Resolves #389.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values);
the existing discover assertions across `server_test.rb` and both transport test files were updated to
the modern-only `supportedVersions`.

New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError`
on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`,
the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`,
`ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection,
and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence.

New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with
the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks,
and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`,
modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout
default applying only while probing, and the fallback when a server never answers the probe.

## Breaking Changes

`server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`);
this method has not shipped in a gem release yet, so no released behavior changes.

On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before
the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with
an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing
the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation
`protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable
across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
2026-08-08 03:45:00 +09:00

1617 lines
51 KiB
Ruby

# 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_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_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28
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
stdin_read.gets
end
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
end
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
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_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