diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 910c8d8f..d1800d1c 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -615,23 +615,32 @@ def handle_request(request, method, session: nil, related_request_id: nil) # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is - # pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is - # rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for - # every other request. + # pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules: + # on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake) + # and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as + # an invalid request because a connection can never change eras. def lift_request_envelope(params, method:, session:) return if Methods.notification?(method) return if method == Methods::SERVER_DISCOVER - modern_session = session.respond_to?(:era) && session.era == :modern + era = session.respond_to?(:era) ? session.era : nil - if modern_session && method == Methods::INITIALIZE + if era == :modern && method == Methods::INITIALIZE requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil raise UnsupportedProtocolVersionError.new(requested, params) end if RequestEnvelope.modern?(params) + if era == :legacy + raise RequestHandlerError.new( + "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", + params, + error_type: :invalid_request, + ) + end + RequestEnvelope.parse!(params, request: params) - elsif modern_session + elsif era == :modern raise RequestHandlerError.new( "Invalid Request: modern sessions require the SEP-2575 `_meta` envelope", params, diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index ec2bd6dd..8c758124 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -49,7 +49,9 @@ def open end break if line.nil? - response = @session.handle_json(line.strip) + line = line.strip + parsed = parse_line(line) + response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line) send_response(response) if response end rescue Interrupt @@ -90,6 +92,12 @@ def send_notification(method, params = nil) # cancellation has very limited value here regardless; servers that need cancellation propagation for nested # server-to-client requests should use `StreamableHTTPTransport`. def send_request(method, params = nil) + # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; + # multi round-trip `input_required` results (SEP-2322) replace them. + if @session && @session.era == :modern + raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." + end + request_id = generate_request_id request = { jsonrpc: "2.0", id: request_id, method: method } request[:params] = params if params @@ -116,7 +124,7 @@ def send_request(method, params = nil) return parsed[:result] else - response = @session ? @session.handle(parsed) : @server.handle(parsed) + response = @session ? dispatch_with_era(parsed) : @server.handle(parsed) send_response(response) if response end end @@ -143,6 +151,35 @@ def read_line(io) line end + + # Parses a frame once so era classification can inspect its method and `_meta`. + # Returns `nil` for frames that are not JSON objects; those fall back to + # `ServerSession#handle_json` so protocol-level error responses stay identical. + def parse_line(line) + parsed = JSON.parse(line, symbolize_names: true) + parsed.is_a?(Hash) ? parsed : nil + rescue JSON::ParserError + nil + end + + # Serves one frame under the dual-era model (SEP-2575): the first era-distinctive message to succeed locks + # the connection era. A successful `initialize` locks `:legacy` inside `Server#init`; a successful `server/discover` + # or a successful request carrying the full modern `_meta` triple locks `:modern`. Era-violating frames + # (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock) + # are rejected in-band by `Server#lift_request_envelope`. + def dispatch_with_era(parsed) + response = @session.handle(parsed) + lock_modern_era_on_success(parsed, response) + response + end + + def lock_modern_era_on_success(parsed, response) + return if @session.era + return if !response.is_a?(Hash) || response.key?(:error) + return if parsed[:method] != Methods::SERVER_DISCOVER && !RequestEnvelope.modern?(parsed[:params]) + + @session.lock_era!(:modern) + end end end end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index cdca209e..1b7d66d3 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -7,6 +7,7 @@ class Server module Transports class StdioTransportTest < ActiveSupport::TestCase include InstrumentationTestHelper + include InitializeParamsTestHelper setup do configuration = MCP::Configuration.new @@ -550,6 +551,140 @@ class StdioTransportTest < ActiveSupport::TestCase $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