Skip to content

Commit 09545f1

Browse files
committed
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 modelcontextprotocol#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.
1 parent f4d939d commit 09545f1

3 files changed

Lines changed: 189 additions & 8 deletions

File tree

lib/mcp/server.rb

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -615,23 +615,32 @@ def handle_request(request, method, session: nil, related_request_id: nil)
615615
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
616616
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
617617
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
618-
# pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is
619-
# rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for
620-
# every other request.
618+
# pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules:
619+
# on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake)
620+
# and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as
621+
# an invalid request because a connection can never change eras.
621622
def lift_request_envelope(params, method:, session:)
622623
return if Methods.notification?(method)
623624
return if method == Methods::SERVER_DISCOVER
624625

625-
modern_session = session.respond_to?(:era) && session.era == :modern
626+
era = session.respond_to?(:era) ? session.era : nil
626627

627-
if modern_session && method == Methods::INITIALIZE
628+
if era == :modern && method == Methods::INITIALIZE
628629
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
629630
raise UnsupportedProtocolVersionError.new(requested, params)
630631
end
631632

632633
if RequestEnvelope.modern?(params)
634+
if era == :legacy
635+
raise RequestHandlerError.new(
636+
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
637+
params,
638+
error_type: :invalid_request,
639+
)
640+
end
641+
633642
RequestEnvelope.parse!(params, request: params)
634-
elsif modern_session
643+
elsif era == :modern
635644
raise RequestHandlerError.new(
636645
"Invalid Request: modern sessions require the SEP-2575 `_meta` envelope",
637646
params,

lib/mcp/server/transports/stdio_transport.rb

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ def open
4949
end
5050
break if line.nil?
5151

52-
response = @session.handle_json(line.strip)
52+
line = line.strip
53+
parsed = parse_line(line)
54+
response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
5355
send_response(response) if response
5456
end
5557
rescue Interrupt
@@ -90,6 +92,12 @@ def send_notification(method, params = nil)
9092
# cancellation has very limited value here regardless; servers that need cancellation propagation for nested
9193
# server-to-client requests should use `StreamableHTTPTransport`.
9294
def send_request(method, params = nil)
95+
# The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
96+
# multi round-trip `input_required` results (SEP-2322) replace them.
97+
if @session && @session.era == :modern
98+
raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
99+
end
100+
93101
request_id = generate_request_id
94102
request = { jsonrpc: "2.0", id: request_id, method: method }
95103
request[:params] = params if params
@@ -116,7 +124,7 @@ def send_request(method, params = nil)
116124

117125
return parsed[:result]
118126
else
119-
response = @session ? @session.handle(parsed) : @server.handle(parsed)
127+
response = @session ? dispatch_with_era(parsed) : @server.handle(parsed)
120128
send_response(response) if response
121129
end
122130
end
@@ -143,6 +151,35 @@ def read_line(io)
143151

144152
line
145153
end
154+
155+
# Parses a frame once so era classification can inspect its method and `_meta`.
156+
# Returns `nil` for frames that are not JSON objects; those fall back to
157+
# `ServerSession#handle_json` so protocol-level error responses stay identical.
158+
def parse_line(line)
159+
parsed = JSON.parse(line, symbolize_names: true)
160+
parsed.is_a?(Hash) ? parsed : nil
161+
rescue JSON::ParserError
162+
nil
163+
end
164+
165+
# Serves one frame under the dual-era model (SEP-2575): the first era-distinctive message to succeed locks
166+
# the connection era. A successful `initialize` locks `:legacy` inside `Server#init`; a successful `server/discover`
167+
# or a successful request carrying the full modern `_meta` triple locks `:modern`. Era-violating frames
168+
# (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock)
169+
# are rejected in-band by `Server#lift_request_envelope`.
170+
def dispatch_with_era(parsed)
171+
response = @session.handle(parsed)
172+
lock_modern_era_on_success(parsed, response)
173+
response
174+
end
175+
176+
def lock_modern_era_on_success(parsed, response)
177+
return if @session.era
178+
return if !response.is_a?(Hash) || response.key?(:error)
179+
return if parsed[:method] != Methods::SERVER_DISCOVER && !RequestEnvelope.modern?(parsed[:params])
180+
181+
@session.lock_era!(:modern)
182+
end
146183
end
147184
end
148185
end

test/mcp/server/transports/stdio_transport_test.rb

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Server
77
module Transports
88
class StdioTransportTest < ActiveSupport::TestCase
99
include InstrumentationTestHelper
10+
include InitializeParamsTestHelper
1011

1112
setup do
1213
configuration = MCP::Configuration.new
@@ -550,6 +551,140 @@ class StdioTransportTest < ActiveSupport::TestCase
550551
$stdout = original_stdout
551552
end
552553
end
554+
555+
test "locks the legacy era on a successful initialize and rejects a later modern envelope" do
556+
responses = run_transport_session([
557+
initialize_request(id: 1),
558+
modern_ping_request(id: 2),
559+
])
560+
561+
refute responses[0].key?(:error)
562+
assert_equal :legacy, session_era
563+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
564+
end
565+
566+
test "initialize negotiating 2026-07-28 still locks the legacy era" do
567+
# 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through
568+
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
569+
responses = run_transport_session([
570+
initialize_request(id: 1, protocol_version: "2026-07-28"),
571+
modern_ping_request(id: 2),
572+
])
573+
574+
assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion)
575+
assert_equal :legacy, session_era
576+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
577+
end
578+
579+
test "locks the modern era on a successful server/discover and rejects a later initialize with -32022" do
580+
responses = run_transport_session([
581+
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
582+
initialize_request(id: 2),
583+
modern_ping_request(id: 3),
584+
])
585+
586+
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
587+
assert_equal :modern, session_era
588+
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
589+
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)
590+
refute responses[2].key?(:error)
591+
end
592+
593+
test "locks the modern era on a successful request carrying the modern envelope" do
594+
responses = run_transport_session([modern_ping_request(id: 1)])
595+
596+
refute responses[0].key?(:error)
597+
assert_equal :modern, session_era
598+
end
599+
600+
test "does not lock an era when the era-distinctive request fails" do
601+
# An unsupported envelope version fails with -32022, so the connection stays unlocked
602+
# and a legacy initialize can still succeed afterwards.
603+
responses = run_transport_session([
604+
modern_ping_request(id: 1, version: "2027-01-01"),
605+
initialize_request(id: 2),
606+
])
607+
608+
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[0].dig(:error, :code)
609+
refute responses[1].key?(:error)
610+
assert_equal :legacy, session_era
611+
end
612+
613+
test "requires the modern envelope after a modern era lock" do
614+
responses = run_transport_session([
615+
modern_ping_request(id: 1),
616+
{ jsonrpc: "2.0", method: "ping", id: 2 },
617+
])
618+
619+
refute responses[0].key?(:error)
620+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
621+
end
622+
623+
test "#send_request raises on a modern-locked session" do
624+
run_transport_session([modern_ping_request(id: 1)])
625+
626+
error = assert_raises(RuntimeError) do
627+
@transport.send_request("roots/list")
628+
end
629+
assert_match(/modern lifecycle/, error.message)
630+
end
631+
632+
private
633+
634+
def initialize_request(id:, protocol_version: "2025-11-25")
635+
{
636+
jsonrpc: "2.0",
637+
method: "initialize",
638+
id: id,
639+
params: initialize_params(
640+
protocolVersion: protocol_version,
641+
clientInfo: { name: "legacy_client", version: "1.0" },
642+
),
643+
}
644+
end
645+
646+
def modern_ping_request(id:, version: "2026-07-28")
647+
{
648+
jsonrpc: "2.0",
649+
method: "ping",
650+
id: id,
651+
params: {
652+
_meta: {
653+
"io.modelcontextprotocol/protocolVersion": version,
654+
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
655+
"io.modelcontextprotocol/clientCapabilities": {},
656+
},
657+
},
658+
}
659+
end
660+
661+
# Feeds the frames to a fresh transport session over swapped stdio and returns the parsed responses in order.
662+
def run_transport_session(frames)
663+
input = StringIO.new(frames.map { |frame| JSON.generate(frame) }.join("\n") + "\n")
664+
output = StringIO.new
665+
666+
original_stdin = $stdin
667+
original_stdout = $stdout
668+
669+
begin
670+
$stdin = input
671+
$stdout = output
672+
673+
thread = Thread.new { @transport.open }
674+
sleep(0.1)
675+
@transport.close
676+
thread.join
677+
ensure
678+
$stdin = original_stdin
679+
$stdout = original_stdout
680+
end
681+
682+
output.string.each_line.map { |line| JSON.parse(line, symbolize_names: true) }
683+
end
684+
685+
def session_era
686+
@transport.instance_variable_get(:@session).era
687+
end
553688
end
554689
end
555690
end

0 commit comments

Comments
 (0)