Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 39 additions & 2 deletions lib/mcp/server/transports/stdio_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
135 changes: 135 additions & 0 deletions test/mcp/server/transports/stdio_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Server
module Transports
class StdioTransportTest < ActiveSupport::TestCase
include InstrumentationTestHelper
include InitializeParamsTestHelper

setup do
configuration = MCP::Configuration.new
Expand Down Expand Up @@ -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
Expand Down