Serve both lifecycle eras over stdio with an era lock per SEP-2575

## Motivation and Context

Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.

- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
  and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
  keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
  A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
  Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
  so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
  a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
  the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
  a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
  server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
  The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.

`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.

## Breaking Changes

None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
This commit is contained in:
Koichi ITO 2026-07-02 12:13:44 +09:00
commit 09545f1272

View file

@ -7,6 +7,7 @@ module MCP
module Transports
class StdioTransportTest < ActiveSupport::TestCase
include InstrumentationTestHelper
include InitializeParamsTestHelper
setup do
configuration = MCP::Configuration.new
@ -550,6 +551,140 @@ module MCP
$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_STABLE_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