# frozen_string_literal: true require "test_helper" module MCP class Server module Transports class StdioTransportTest < ActiveSupport::TestCase include InstrumentationTestHelper include InitializeParamsTestHelper setup do configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback @server = Server.new(name: "test_server", configuration: configuration) @transport = StdioTransport.new(@server) end test "initializes with server and closed state" do server = @transport.instance_variable_get(:@server) assert_equal @server.object_id, server.object_id refute @transport.instance_variable_get(:@open) end test "processes JSON-RPC requests from stdin and sends responses to stdout" do request = { jsonrpc: "2.0", method: "ping", id: "123", } input = StringIO.new(JSON.generate(request) + "\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 response = JSON.parse(output.string, symbolize_names: true) assert_equal("2.0", response[:jsonrpc]) assert_equal("123", response[:id]) assert_equal({}, response[:result]) refute(@transport.instance_variable_get(:@open)) ensure $stdin = original_stdin $stdout = original_stdout end end test "sends string responses to stdout" do output = StringIO.new original_stdout = $stdout begin $stdout = output @transport.send_response("test response") assert_equal("test response\n", output.string) ensure $stdout = original_stdout end end test "sends JSON responses to stdout" do output = StringIO.new original_stdout = $stdout begin $stdout = output response = { key: "value" } @transport.send_response(response) assert_equal(JSON.generate(response) + "\n", output.string) ensure $stdout = original_stdout end end test "handles valid JSON-RPC requests" do request = { jsonrpc: "2.0", method: "ping", id: "123", } output = StringIO.new original_stdout = $stdout begin $stdout = output @transport.send(:handle_request, JSON.generate(request)) response = JSON.parse(output.string, symbolize_names: true) assert_equal("2.0", response[:jsonrpc]) assert_nil(response[:id]) assert_nil(response[:result]) ensure $stdout = original_stdout end end test "open creates a ServerSession and processes requests through it" do request = { jsonrpc: "2.0", method: "initialize", id: "1", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "stdio-client", version: "1.0" }, }, } input = StringIO.new(JSON.generate(request) + "\n") output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = input $stdout = output @transport.open # Verify a session was created. session = @transport.instance_variable_get(:@session) assert_instance_of(ServerSession, session) # Verify client info was stored on the session, not on the server. assert_equal({ name: "stdio-client", version: "1.0" }, session.client) ensure $stdin = original_stdin $stdout = original_stdout end end test "open handles a malformed JSON-RPC batch element and emits an error response" do input = StringIO.new("[[]]\n") output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = input $stdout = output @transport.open response = JSON.parse(output.string, symbolize_names: true) assert_nil(response[:id]) assert_equal(-32600, response[:error][:code]) assert_equal("Invalid Request", response[:error][:message]) ensure $stdin = original_stdin $stdout = original_stdout end end test "rejects duplicate initialize on the same stdio session with -32600" do first = { jsonrpc: "2.0", method: "initialize", id: "first", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "original", version: "1.0" }, }, } second = { jsonrpc: "2.0", method: "initialize", id: "second", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "intruder", version: "9.9" }, }, } input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n") output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = input $stdout = output @transport.open lines = output.string.lines assert_equal(2, lines.length) first_response = JSON.parse(lines[0], symbolize_names: true) second_response = JSON.parse(lines[1], symbolize_names: true) assert_equal("first", first_response[:id]) refute_nil(first_response[:result]) assert_equal("second", second_response[:id]) assert_equal(-32600, second_response[:error][:code]) assert_equal("Invalid Request", second_response[:error][:message]) session = @transport.instance_variable_get(:@session) assert_equal({ name: "original", version: "1.0" }, session.client) ensure $stdin = original_stdin $stdout = original_stdout end end test "handles invalid JSON requests" do invalid_json = "invalid json" output = StringIO.new original_stdout = $stdout begin $stdout = output @transport.send(:handle_request, invalid_json) response = JSON.parse(output.string, symbolize_names: true) assert_equal("2.0", response[:jsonrpc]) assert_nil(response[:id]) assert_equal(-32600, response[:error][:code]) assert_equal("Invalid Request", response[:error][:message]) assert_equal("Request must be an array or a hash", response[:error][:data]) ensure $stdout = original_stdout end end test "send_request sends request to stdout and waits for response" do reader, writer = IO.pipe output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = reader $stdout = output @transport.instance_variable_set(:@open, true) # Send response from client in a thread. Thread.new do sleep(0.05) # Wait for request to be written to `StringIO`. request = JSON.parse(output.string.lines.first, symbolize_names: true) response = { jsonrpc: "2.0", id: request[:id], result: { content: "test response" }, } writer.puts(response.to_json) writer.flush end result = @transport.send_request("test/method", { param: "value" }) assert_equal({ content: "test response" }, result) # Verify request was sent. request = JSON.parse(output.string.lines.first, symbolize_names: true) assert_equal("2.0", request[:jsonrpc]) assert_equal("test/method", request[:method]) assert_equal({ param: "value" }, request[:params]) assert(request[:id]) ensure $stdin = original_stdin $stdout = original_stdout begin writer.close rescue nil end begin reader.close rescue nil end end end test "send_request raises on error response from client" do reader, writer = IO.pipe output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = reader $stdout = output @transport.instance_variable_set(:@open, true) Thread.new do sleep(0.05) # Wait for request to be written to `StringIO`. request = JSON.parse(output.string.lines.first, symbolize_names: true) error_response = { jsonrpc: "2.0", id: request[:id], error: { code: -1, message: "User rejected sampling request" }, } writer.puts(error_response.to_json) writer.flush end error = assert_raises(StandardError) do @transport.send_request("sampling/createMessage", { messages: [] }) end assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected sampling request", error.message) ensure $stdin = original_stdin $stdout = original_stdout begin writer.close rescue nil end begin reader.close rescue nil end end end test "send_request does not double-report intentional raises via exception_reporter" do reader, writer = IO.pipe output = StringIO.new original_stdin = $stdin original_stdout = $stdout reported_errors = [] original_reporter = MCP.configuration.exception_reporter begin MCP.configuration.exception_reporter = ->(e, ctx) { reported_errors << [e, ctx] } $stdin = reader $stdout = output @transport.instance_variable_set(:@open, true) Thread.new do sleep(0.05) # Wait for request to be written to `StringIO`. request = JSON.parse(output.string.lines.first, symbolize_names: true) error_response = { jsonrpc: "2.0", id: request[:id], error: { code: -1, message: "rejected" }, } writer.puts(error_response.to_json) writer.flush end assert_raises(StandardError) do @transport.send_request("sampling/createMessage", { messages: [] }) end assert_empty(reported_errors) ensure MCP.configuration.exception_reporter = original_reporter $stdin = original_stdin $stdout = original_stdout begin writer.close rescue nil end begin reader.close rescue nil end end end test "send_request processes interleaved requests via session" do reader, writer = IO.pipe output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = reader $stdout = output @transport.instance_variable_set(:@open, true) # Initialize a session so @session is set. session = MCP::ServerSession.new(server: @server, transport: @transport) @transport.instance_variable_set(:@session, session) Thread.new do sleep(0.05) # Wait for request to be written to `StringIO`. request = JSON.parse(output.string.lines.first, symbolize_names: true) # Send an interleaved ping request before the response. ping = { jsonrpc: "2.0", method: "ping", id: "ping-1" } writer.puts(ping.to_json) writer.flush sleep(0.05) # Wait for the ping to be processed. # Then send the actual response. response = { jsonrpc: "2.0", id: request[:id], result: { content: "done" }, } writer.puts(response.to_json) writer.flush end result = @transport.send_request("test/method", { param: "value" }) assert_equal({ content: "done" }, result) # Verify the interleaved ping was handled (response sent to output). lines = output.string.lines ping_response = lines.find { |l| l.include?("ping-1") } assert(ping_response, "Interleaved ping request should have been handled") ensure $stdin = original_stdin $stdout = original_stdout begin writer.close rescue nil end begin reader.close rescue nil end end end test "send_request raises when transport is closed while waiting" do reader, writer = IO.pipe output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = reader $stdout = output @transport.instance_variable_set(:@open, true) # Close transport while waiting for response. Thread.new do sleep(0.05) # Wait for request to be written to `StringIO`. @transport.instance_variable_set(:@open, false) writer.close end error = assert_raises(RuntimeError) do @transport.send_request("sampling/createMessage", { messages: [] }) end assert_equal("Transport closed while waiting for response to sampling/createMessage request.", error.message) ensure $stdin = original_stdin $stdout = original_stdout begin writer.close rescue IOError nil end begin reader.close rescue IOError nil end end end test "raises ArgumentError when max_line_bytes is not a positive Integer" do [nil, 0, -1, 1.5, "1024"].each do |invalid| error = assert_raises(ArgumentError) do StdioTransport.new(@server, max_line_bytes: invalid) end assert_equal("max_line_bytes must be a positive Integer", error.message) end end test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do transport = StdioTransport.new(@server, max_line_bytes: 64) input = StringIO.new("A" * 200) # No newline: an unbounded frame. output = StringIO.new reported = [] original_stdin = $stdin original_stdout = $stdout original_reporter = MCP.configuration.exception_reporter begin $stdin = input $stdout = output MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] } transport.open refute(transport.instance_variable_get(:@open)) assert_equal(1, reported.size) error, _ctx = reported.first assert_instance_of(RequestHandlerError, error) assert_match(/exceeds 64 bytes without a newline/, error.message) ensure $stdin = original_stdin $stdout = original_stdout MCP.configuration.exception_reporter = original_reporter end end test "open processes a valid final frame that ends at EOF without a trailing newline" do request = { jsonrpc: "2.0", method: "ping", id: "eof" } input = StringIO.new(JSON.generate(request)) # No trailing newline. output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = input $stdout = output @transport.open response = JSON.parse(output.string, symbolize_names: true) assert_equal("eof", response[:id]) assert_equal({}, response[:result]) ensure $stdin = original_stdin $stdout = original_stdout end end test "send_request raises when a response frame exceeds max_line_bytes without a newline" do transport = StdioTransport.new(@server, max_line_bytes: 64) input = StringIO.new("A" * 200) # No newline: an unbounded frame. output = StringIO.new original_stdin = $stdin original_stdout = $stdout begin $stdin = input $stdout = output transport.instance_variable_set(:@open, true) error = assert_raises(RequestHandlerError) do transport.send_request("sampling/createMessage", { messages: [] }) end assert_match(/exceeds 64 bytes without a newline/, error.message) ensure $stdin = original_stdin $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_tools_list_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 requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do # Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on # the legacy lifecycle it selected, so a later modern envelope is still rejected. responses = run_transport_session([ initialize_request(id: 1, protocol_version: "2026-07-28"), modern_tools_list_request(id: 2), ]) assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, 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 -32601" do responses = run_transport_session([ { jsonrpc: "2.0", method: "server/discover", id: 1 }, initialize_request(id: 2), modern_tools_list_request(id: 3), ]) assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) assert_equal :modern, session_era # SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection # answers it with Method not found. assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code) 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_tools_list_request(id: 1)]) refute responses[0].key?(:error) assert_equal :modern, session_era end test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do # The era locks only after a response succeeds, so this frame reaches the server unlocked. # SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern. responses = run_transport_session([ { jsonrpc: "2.0", method: "resources/subscribe", id: 1, params: { uri: "https://example.invalid/resource", _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, "io.modelcontextprotocol/clientCapabilities": {}, }, }, }, ]) assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) refute_equal :legacy, 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_tools_list_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_tools_list_request(id: 1), { jsonrpc: "2.0", method: "tools/list", id: 2 }, ]) refute responses[0].key?(:error) assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code) end test "answers removed lifecycle methods with -32601 after a modern era lock" do removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index| { jsonrpc: "2.0", method: method, id: index + 2 } end responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests) refute responses[0].key?(:error) responses[1..].each_with_index do |response, index| method = Methods::MODERN_REMOVED_METHODS[index] assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" end end test "subscriptions/listen is not served over stdio and answers -32601" do # Like the Python SDK, the stream-pair transport does not serve the SEP-2575 notification subscription stream. listen = { jsonrpc: "2.0", method: "subscriptions/listen", id: "listen-1", params: { notifications: { toolsListChanged: true }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, "io.modelcontextprotocol/clientCapabilities": {}, }, }, } responses = run_transport_session([listen]) assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) end test "#send_request raises on a modern-locked session" do run_transport_session([modern_tools_list_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 # `tools/list` stands in as the era-distinctive modern request: `ping` cannot, # because SEP-2575 removes it from the modern lifecycle (-32601 there). def modern_tools_list_request(id:, version: "2026-07-28") { jsonrpc: "2.0", method: "tools/list", 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 end