## 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.
186 lines
7.5 KiB
Ruby
186 lines
7.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require_relative "../../transport"
|
|
|
|
module MCP
|
|
class Server
|
|
module Transports
|
|
class StdioTransport < Transport
|
|
STATUS_INTERRUPTED = Signal.list["INT"] + 128
|
|
|
|
# Default upper bound on a single newline-delimited frame. CRuby's `IO#gets`
|
|
# without a limit accumulates bytes until a newline arrives, so a peer that
|
|
# never emits one can grow a single String until the process is OOM-killed.
|
|
# 4 MiB is large enough for any realistic JSON-RPC frame, including
|
|
# base64-embedded images.
|
|
MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
|
|
def initialize(server, max_line_bytes: MAX_LINE_BYTES)
|
|
super(server)
|
|
@open = false
|
|
@session = nil
|
|
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
|
|
# limit read without an upper bound, which would silently disable the
|
|
# protection this option exists to provide.
|
|
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
|
|
raise ArgumentError, "max_line_bytes must be a positive Integer"
|
|
end
|
|
|
|
@max_line_bytes = max_line_bytes
|
|
$stdin.set_encoding("UTF-8")
|
|
$stdout.set_encoding("UTF-8")
|
|
end
|
|
|
|
def open
|
|
@open = true
|
|
@session = ServerSession.new(server: @server, transport: self)
|
|
while @open
|
|
begin
|
|
line = read_line($stdin)
|
|
rescue RequestHandlerError => e
|
|
# Stop accumulating and end the connection gracefully rather than
|
|
# letting an unbounded read exhaust memory or escape as an uncaught
|
|
# backtrace. Scoped to the read so genuine request errors raised while
|
|
# handling a frame are not swallowed here.
|
|
@open = false
|
|
MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
|
|
break
|
|
end
|
|
break if line.nil?
|
|
|
|
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
|
|
warn("\nExiting...")
|
|
|
|
exit(STATUS_INTERRUPTED)
|
|
end
|
|
|
|
def close
|
|
@open = false
|
|
end
|
|
|
|
def send_response(message)
|
|
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
|
$stdout.puts(json_message)
|
|
$stdout.flush
|
|
end
|
|
|
|
def send_notification(method, params = nil)
|
|
notification = {
|
|
jsonrpc: "2.0",
|
|
method: method,
|
|
}
|
|
notification[:params] = params if params
|
|
|
|
send_response(notification)
|
|
true
|
|
rescue => e
|
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
|
|
false
|
|
end
|
|
|
|
# NOTE: This signature deliberately matches the abstract `Transport#send_request` contract
|
|
# (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts.
|
|
# On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with
|
|
# explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be
|
|
# auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request
|
|
# 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
|
|
|
|
begin
|
|
send_response(request)
|
|
rescue => e
|
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to send request" })
|
|
raise
|
|
end
|
|
|
|
while @open && (line = read_line($stdin))
|
|
begin
|
|
parsed = JSON.parse(line.strip, symbolize_names: true)
|
|
rescue JSON::ParserError => e
|
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
|
|
raise
|
|
end
|
|
|
|
if parsed[:id] == request_id && !parsed.key?(:method)
|
|
if parsed[:error]
|
|
raise StandardError, "Client returned an error for #{method} request (code: #{parsed[:error][:code]}): #{parsed[:error][:message]}"
|
|
end
|
|
|
|
return parsed[:result]
|
|
else
|
|
response = @session ? dispatch_with_era(parsed) : @server.handle(parsed)
|
|
send_response(response) if response
|
|
end
|
|
end
|
|
|
|
raise "Transport closed while waiting for response to #{method} request."
|
|
end
|
|
|
|
private
|
|
|
|
# Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns
|
|
# the line (including its trailing newline) or `nil` at EOF. Raises when the
|
|
# limit is reached before a newline arrives, which signals a peer streaming
|
|
# an unbounded frame. A short final frame without a trailing newline (EOF) is
|
|
# still returned, since its length stays under the limit.
|
|
def read_line(io)
|
|
line = io.gets("\n", @max_line_bytes)
|
|
if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
|
|
raise RequestHandlerError.new(
|
|
"stdio frame exceeds #{@max_line_bytes} bytes without a newline",
|
|
nil,
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
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
|
|
end
|