## 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.
691 lines
23 KiB
Ruby
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
|