ruby-sdk/test/mcp/server/transports/stdio_transport_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

751 lines
26 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
module MCP
class Server
module Transports
class StdioTransportTest < ActiveSupport::TestCase
include InstrumentationTestHelper
include InitializeParamsTestHelper
setup do
configuration = MCP::Configuration.new
configuration.instrumentation_callback = instrumentation_helper.callback
@server = Server.new(name: "test_server", configuration: configuration)
@transport = StdioTransport.new(@server)
end
test "initializes with server and closed state" do
server = @transport.instance_variable_get(:@server)
assert_equal @server.object_id, server.object_id
refute @transport.instance_variable_get(:@open)
end
test "processes JSON-RPC requests from stdin and sends responses to stdout" do
request = {
jsonrpc: "2.0",
method: "ping",
id: "123",
}
input = StringIO.new(JSON.generate(request) + "\n")
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
thread = Thread.new { @transport.open }
sleep(0.1)
@transport.close
thread.join
response = JSON.parse(output.string, symbolize_names: true)
assert_equal("2.0", response[:jsonrpc])
assert_equal("123", response[:id])
assert_equal({}, response[:result])
refute(@transport.instance_variable_get(:@open))
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "sends string responses to stdout" do
output = StringIO.new
original_stdout = $stdout
begin
$stdout = output
@transport.send_response("test response")
assert_equal("test response\n", output.string)
ensure
$stdout = original_stdout
end
end
test "sends JSON responses to stdout" do
output = StringIO.new
original_stdout = $stdout
begin
$stdout = output
response = { key: "value" }
@transport.send_response(response)
assert_equal(JSON.generate(response) + "\n", output.string)
ensure
$stdout = original_stdout
end
end
test "handles valid JSON-RPC requests" do
request = {
jsonrpc: "2.0",
method: "ping",
id: "123",
}
output = StringIO.new
original_stdout = $stdout
begin
$stdout = output
@transport.send(:handle_request, JSON.generate(request))
response = JSON.parse(output.string, symbolize_names: true)
assert_equal("2.0", response[:jsonrpc])
assert_nil(response[:id])
assert_nil(response[:result])
ensure
$stdout = original_stdout
end
end
test "open creates a ServerSession and processes requests through it" do
request = {
jsonrpc: "2.0",
method: "initialize",
id: "1",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "stdio-client", version: "1.0" },
},
}
input = StringIO.new(JSON.generate(request) + "\n")
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
@transport.open
# Verify a session was created.
session = @transport.instance_variable_get(:@session)
assert_instance_of(ServerSession, session)
# Verify client info was stored on the session, not on the server.
assert_equal({ name: "stdio-client", version: "1.0" }, session.client)
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "open handles a malformed JSON-RPC batch element and emits an error response" do
input = StringIO.new("[[]]\n")
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
@transport.open
response = JSON.parse(output.string, symbolize_names: true)
assert_nil(response[:id])
assert_equal(-32600, response[:error][:code])
assert_equal("Invalid Request", response[:error][:message])
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "rejects duplicate initialize on the same stdio session with -32600" do
first = {
jsonrpc: "2.0",
method: "initialize",
id: "first",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "original", version: "1.0" },
},
}
second = {
jsonrpc: "2.0",
method: "initialize",
id: "second",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "intruder", version: "9.9" },
},
}
input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n")
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
@transport.open
lines = output.string.lines
assert_equal(2, lines.length)
first_response = JSON.parse(lines[0], symbolize_names: true)
second_response = JSON.parse(lines[1], symbolize_names: true)
assert_equal("first", first_response[:id])
refute_nil(first_response[:result])
assert_equal("second", second_response[:id])
assert_equal(-32600, second_response[:error][:code])
assert_equal("Invalid Request", second_response[:error][:message])
session = @transport.instance_variable_get(:@session)
assert_equal({ name: "original", version: "1.0" }, session.client)
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "handles invalid JSON requests" do
invalid_json = "invalid json"
output = StringIO.new
original_stdout = $stdout
begin
$stdout = output
@transport.send(:handle_request, invalid_json)
response = JSON.parse(output.string, symbolize_names: true)
assert_equal("2.0", response[:jsonrpc])
assert_nil(response[:id])
assert_equal(-32600, response[:error][:code])
assert_equal("Invalid Request", response[:error][:message])
assert_equal("Request must be an array or a hash", response[:error][:data])
ensure
$stdout = original_stdout
end
end
test "send_request sends request to stdout and waits for response" do
reader, writer = IO.pipe
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = reader
$stdout = output
@transport.instance_variable_set(:@open, true)
# Send response from client in a thread.
Thread.new do
sleep(0.05) # Wait for request to be written to `StringIO`.
request = JSON.parse(output.string.lines.first, symbolize_names: true)
response = {
jsonrpc: "2.0",
id: request[:id],
result: { content: "test response" },
}
writer.puts(response.to_json)
writer.flush
end
result = @transport.send_request("test/method", { param: "value" })
assert_equal({ content: "test response" }, result)
# Verify request was sent.
request = JSON.parse(output.string.lines.first, symbolize_names: true)
assert_equal("2.0", request[:jsonrpc])
assert_equal("test/method", request[:method])
assert_equal({ param: "value" }, request[:params])
assert(request[:id])
ensure
$stdin = original_stdin
$stdout = original_stdout
begin
writer.close
rescue
nil
end
begin
reader.close
rescue
nil
end
end
end
test "send_request raises on error response from client" do
reader, writer = IO.pipe
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = reader
$stdout = output
@transport.instance_variable_set(:@open, true)
Thread.new do
sleep(0.05) # Wait for request to be written to `StringIO`.
request = JSON.parse(output.string.lines.first, symbolize_names: true)
error_response = {
jsonrpc: "2.0",
id: request[:id],
error: { code: -1, message: "User rejected sampling request" },
}
writer.puts(error_response.to_json)
writer.flush
end
error = assert_raises(StandardError) do
@transport.send_request("sampling/createMessage", { messages: [] })
end
assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected sampling request", error.message)
ensure
$stdin = original_stdin
$stdout = original_stdout
begin
writer.close
rescue
nil
end
begin
reader.close
rescue
nil
end
end
end
test "send_request does not double-report intentional raises via exception_reporter" do
reader, writer = IO.pipe
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
reported_errors = []
original_reporter = MCP.configuration.exception_reporter
begin
MCP.configuration.exception_reporter = ->(e, ctx) { reported_errors << [e, ctx] }
$stdin = reader
$stdout = output
@transport.instance_variable_set(:@open, true)
Thread.new do
sleep(0.05) # Wait for request to be written to `StringIO`.
request = JSON.parse(output.string.lines.first, symbolize_names: true)
error_response = {
jsonrpc: "2.0",
id: request[:id],
error: { code: -1, message: "rejected" },
}
writer.puts(error_response.to_json)
writer.flush
end
assert_raises(StandardError) do
@transport.send_request("sampling/createMessage", { messages: [] })
end
assert_empty(reported_errors)
ensure
MCP.configuration.exception_reporter = original_reporter
$stdin = original_stdin
$stdout = original_stdout
begin
writer.close
rescue
nil
end
begin
reader.close
rescue
nil
end
end
end
test "send_request processes interleaved requests via session" do
reader, writer = IO.pipe
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = reader
$stdout = output
@transport.instance_variable_set(:@open, true)
# Initialize a session so @session is set.
session = MCP::ServerSession.new(server: @server, transport: @transport)
@transport.instance_variable_set(:@session, session)
Thread.new do
sleep(0.05) # Wait for request to be written to `StringIO`.
request = JSON.parse(output.string.lines.first, symbolize_names: true)
# Send an interleaved ping request before the response.
ping = { jsonrpc: "2.0", method: "ping", id: "ping-1" }
writer.puts(ping.to_json)
writer.flush
sleep(0.05) # Wait for the ping to be processed.
# Then send the actual response.
response = {
jsonrpc: "2.0",
id: request[:id],
result: { content: "done" },
}
writer.puts(response.to_json)
writer.flush
end
result = @transport.send_request("test/method", { param: "value" })
assert_equal({ content: "done" }, result)
# Verify the interleaved ping was handled (response sent to output).
lines = output.string.lines
ping_response = lines.find { |l| l.include?("ping-1") }
assert(ping_response, "Interleaved ping request should have been handled")
ensure
$stdin = original_stdin
$stdout = original_stdout
begin
writer.close
rescue
nil
end
begin
reader.close
rescue
nil
end
end
end
test "send_request raises when transport is closed while waiting" do
reader, writer = IO.pipe
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = reader
$stdout = output
@transport.instance_variable_set(:@open, true)
# Close transport while waiting for response.
Thread.new do
sleep(0.05) # Wait for request to be written to `StringIO`.
@transport.instance_variable_set(:@open, false)
writer.close
end
error = assert_raises(RuntimeError) do
@transport.send_request("sampling/createMessage", { messages: [] })
end
assert_equal("Transport closed while waiting for response to sampling/createMessage request.", error.message)
ensure
$stdin = original_stdin
$stdout = original_stdout
begin
writer.close
rescue IOError
nil
end
begin
reader.close
rescue IOError
nil
end
end
end
test "raises ArgumentError when max_line_bytes is not a positive Integer" do
[nil, 0, -1, 1.5, "1024"].each do |invalid|
error = assert_raises(ArgumentError) do
StdioTransport.new(@server, max_line_bytes: invalid)
end
assert_equal("max_line_bytes must be a positive Integer", error.message)
end
end
test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do
transport = StdioTransport.new(@server, max_line_bytes: 64)
input = StringIO.new("A" * 200) # No newline: an unbounded frame.
output = StringIO.new
reported = []
original_stdin = $stdin
original_stdout = $stdout
original_reporter = MCP.configuration.exception_reporter
begin
$stdin = input
$stdout = output
MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] }
transport.open
refute(transport.instance_variable_get(:@open))
assert_equal(1, reported.size)
error, _ctx = reported.first
assert_instance_of(RequestHandlerError, error)
assert_match(/exceeds 64 bytes without a newline/, error.message)
ensure
$stdin = original_stdin
$stdout = original_stdout
MCP.configuration.exception_reporter = original_reporter
end
end
test "open processes a valid final frame that ends at EOF without a trailing newline" do
request = { jsonrpc: "2.0", method: "ping", id: "eof" }
input = StringIO.new(JSON.generate(request)) # No trailing newline.
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
@transport.open
response = JSON.parse(output.string, symbolize_names: true)
assert_equal("eof", response[:id])
assert_equal({}, response[:result])
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "send_request raises when a response frame exceeds max_line_bytes without a newline" do
transport = StdioTransport.new(@server, max_line_bytes: 64)
input = StringIO.new("A" * 200) # No newline: an unbounded frame.
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
transport.instance_variable_set(:@open, true)
error = assert_raises(RequestHandlerError) do
transport.send_request("sampling/createMessage", { messages: [] })
end
assert_match(/exceeds 64 bytes without a newline/, error.message)
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "locks the legacy era on a successful initialize and rejects a later modern envelope" do
responses = run_transport_session([
initialize_request(id: 1),
modern_tools_list_request(id: 2),
])
refute responses[0].key?(:error)
assert_equal :legacy, session_era
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
end
test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do
# Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on
# the legacy lifecycle it selected, so a later modern envelope is still rejected.
responses = run_transport_session([
initialize_request(id: 1, protocol_version: "2026-07-28"),
modern_tools_list_request(id: 2),
])
assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, responses[0].dig(:result, :protocolVersion)
assert_equal :legacy, session_era
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
end
test "locks the modern era on a successful server/discover and rejects a later initialize with -32601" do
responses = run_transport_session([
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
initialize_request(id: 2),
modern_tools_list_request(id: 3),
])
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal :modern, session_era
# SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection
# answers it with Method not found.
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code)
refute responses[2].key?(:error)
end
test "locks the modern era on a successful request carrying the modern envelope" do
responses = run_transport_session([modern_tools_list_request(id: 1)])
refute responses[0].key?(:error)
assert_equal :modern, session_era
end
test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do
# The era locks only after a response succeeds, so this frame reaches the server unlocked.
# SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern.
responses = run_transport_session([
{
jsonrpc: "2.0",
method: "resources/subscribe",
id: 1,
params: {
uri: "https://example.invalid/resource",
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
"io.modelcontextprotocol/clientCapabilities": {},
},
},
},
])
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code)
refute_equal :legacy, session_era
end
test "does not lock an era when the era-distinctive request fails" do
# An unsupported envelope version fails with -32022, so the connection stays unlocked
# and a legacy initialize can still succeed afterwards.
responses = run_transport_session([
modern_tools_list_request(id: 1, version: "2027-01-01"),
initialize_request(id: 2),
])
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[0].dig(:error, :code)
refute responses[1].key?(:error)
assert_equal :legacy, session_era
end
test "requires the modern envelope after a modern era lock" do
responses = run_transport_session([
modern_tools_list_request(id: 1),
{ jsonrpc: "2.0", method: "tools/list", id: 2 },
])
refute responses[0].key?(:error)
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code)
end
test "answers removed lifecycle methods with -32601 after a modern era lock" do
removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index|
{ jsonrpc: "2.0", method: method, id: index + 2 }
end
responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests)
refute responses[0].key?(:error)
responses[1..].each_with_index do |response, index|
method = Methods::MODERN_REMOVED_METHODS[index]
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
end
end
test "subscriptions/listen is not served over stdio and answers -32601" do
# Like the Python SDK, the stream-pair transport does not serve the SEP-2575 notification subscription stream.
listen = {
jsonrpc: "2.0",
method: "subscriptions/listen",
id: "listen-1",
params: {
notifications: { toolsListChanged: true },
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}
responses = run_transport_session([listen])
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code)
end
test "#send_request raises on a modern-locked session" do
run_transport_session([modern_tools_list_request(id: 1)])
error = assert_raises(RuntimeError) do
@transport.send_request("roots/list")
end
assert_match(/modern lifecycle/, error.message)
end
private
def initialize_request(id:, protocol_version: "2025-11-25")
{
jsonrpc: "2.0",
method: "initialize",
id: id,
params: initialize_params(
protocolVersion: protocol_version,
clientInfo: { name: "legacy_client", version: "1.0" },
),
}
end
# `tools/list` stands in as the era-distinctive modern request: `ping` cannot,
# because SEP-2575 removes it from the modern lifecycle (-32601 there).
def modern_tools_list_request(id:, version: "2026-07-28")
{
jsonrpc: "2.0",
method: "tools/list",
id: id,
params: {
_meta: {
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}
end
# Feeds the frames to a fresh transport session over swapped stdio and returns the parsed responses in order.
def run_transport_session(frames)
input = StringIO.new(frames.map { |frame| JSON.generate(frame) }.join("\n") + "\n")
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
thread = Thread.new { @transport.open }
sleep(0.1)
@transport.close
thread.join
ensure
$stdin = original_stdin
$stdout = original_stdout
end
output.string.each_line.map { |line| JSON.parse(line, symbolize_names: true) }
end
def session_era
@transport.instance_variable_get(:@session).era
end
end
end
end
end