ruby-sdk/test/mcp/client/stdio_test.rb
Koichi ITO 48b7aaacd9 Stop negotiating modern protocol versions through the initialize handshake
## Motivation and Context

Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761

A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through
the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are
both gated on the per-request `_meta` envelope, which such a connection never carries,
so strictly validating clients reject every result as missing the REQUIRED `resultType`.

The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of
the protocol version itself: legacy versions establish a session via `initialize`
(`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with
no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope
is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state
the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates
from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry;
the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too,
and counter-offers `LATEST_HANDSHAKE_VERSION`.

`initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list
(`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know,
is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on
`2025-11-25`, where `resultType` does not exist and its absence is the correct shape,
resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and
the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are
intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is
no longer constructible, so the gates are correct by construction.

The client side is symmetrized the same way, matching both reference SDKs:
the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default,
passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError`
(validated before the stdio transport spawns its child process), and a server answering `initialize` with
a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to
the discovery probe and never downgrades it into the legacy fallback: the probe's failure is
the real answer and propagates, instead of the fallback dying on the handshake guard.
The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`),
and the handshake list is derived from the stable and modern lists so the era partition cannot drift
when the next revision lands.

Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect
on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land
on a deprecating version and the call site the warnings used to sit on had become unreachable.
On the server the answer is removal: `Configuration#protocol_version` scopes the handshake,
so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction.
Accepting one would configure nothing (a modern version has no handshake to pin),
and neither reference SDK carries a config value that is accepted but inert:
Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions`
is consumed by its own era's mechanism. With no path left onto a deprecating revision,
the server-side deprecation warning plumbing became unreachable and is removed along with it.
Keying those warnings on a modern-era session instead was tried and rejected,
because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line
would then carry a deprecation warning for doing exactly what the revision prescribes
(a single test file produced over a thousand of them).

A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered,
and such a request continues to be served at the negotiated revision rather than rejected.
Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule
covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`,
through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with
its consequence, which is that no `resultType` appears on those results.

Two details follow from the pin being handshake-scoped. Reading it back when nothing is set
now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era,
so the getter no longer hands out a value its own writer rejects and round-tripping
the setting keeps working; only `Server#validate!` reads that default,
through three comparisons that answer the same for either value, so nothing else moves.
And the rejection names its reason instead of listing the accepted versions,
because this is the error an upgrade from a release that accepted the value lands on,
which makes it the migration note a bare list would not be.

`MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now,
and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be
a breaking change for no gain.

The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper
now use the handshake version as well, and the README section on `protocol_version` describes the pin as
handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default,
and `2026-07-28` reached through `server/discover` with no configuration needed.

## How Has This Been Tested?

New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is
counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType`
(the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer,
the `ArgumentError` for an explicitly modern version on the legacy connect
(asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult`
on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of
falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.

Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for
clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version,
the answer is an older one, and the connection speaks the answer afterwards
(over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption
from an echo, because every one of them had the server answer with the version the client had just offered.
Both new tests were confirmed to fail when the client is made to report its own offer instead.

Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked:
the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer,
and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires.
The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version,
or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths,
since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests
on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.

The default-pin test now also round-trips what it reads back into a fresh `Configuration`,
which is what the old default could not do. The default move was checked for neutrality outside
the suite as well, by building a server that uses every feature `Server#validate!` gates on that value
(`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations)
and confirming all of them are still accepted.

## Breaking Changes

Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through
the handshake are counter-offered `2025-11-25` and are served at that revision, matching
the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now
raises `ArgumentError` (the pin scopes the handshake, which cannot land there);
the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway.
Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`;
nothing in this SDK behaves differently for either value, but code comparing that reader against
a literal will see the change. The client no longer offers modern versions on the legacy handshake
and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features
no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision;
the client-side warning on a modern connect replaces them.
2026-08-14 16:08:07 +09:00

1718 lines
55 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_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