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

691 lines
23 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_ping_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 negotiating 2026-07-28 still locks the legacy era" do
# 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
responses = run_transport_session([
initialize_request(id: 1, protocol_version: "2026-07-28"),
modern_ping_request(id: 2),
])
assert_equal "2026-07-28", 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 -32022" do
responses = run_transport_session([
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
initialize_request(id: 2),
modern_ping_request(id: 3),
])
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal :modern, session_era
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)
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_ping_request(id: 1)])
refute responses[0].key?(:error)
assert_equal :modern, 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_ping_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_ping_request(id: 1),
{ jsonrpc: "2.0", method: "ping", id: 2 },
])
refute responses[0].key?(:error)
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
end
test "#send_request raises on a modern-locked session" do
run_transport_session([modern_ping_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
def modern_ping_request(id:, version: "2026-07-28")
{
jsonrpc: "2.0",
method: "ping",
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