From e757931a233e8ea238ce4d2f819ce96ce4e797f0 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 21 Oct 2026 22:05:08 +0700 Subject: [PATCH] Strip stdio client frames as bytes ## Motivation and Context `MCP::Client::Stdio` reads the server's stdout as UTF-8 and strips each frame before parsing it. `String#strip` raises for a UTF-8 string holding an invalid byte at either end on every supported Ruby, and anywhere in it from Ruby 3.2 on, and the read loop rescued only `JSON::ParserError`. A server writing such a frame therefore failed the awaited request with `ArgumentError` or `Encoding::CompatibilityError` instead of the transport's own `RequestHandlerError`, which callers rescue. The server transport strips its frames the same way and has the same change separately. Frames are now stripped as bytes. A frame that is not JSON fails the request with the transport's parse failure, as any other unparsable frame does, and a response holding such a byte in a value is returned with the byte kept. ## How Has This Been Tested? `test/mcp/client/stdio_test.rb`: a frame starting with an invalid byte and a response holding one in a value. Against the previous library the first raises `ArgumentError` and the second `Encoding::CompatibilityError`. ## Breaking Changes None. --- lib/mcp/client/stdio.rb | 10 +++++++++- test/mcp/client/stdio_test.rb | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index ba4558f8..a2156c0a 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -434,7 +434,7 @@ def read_response(request) line = read_line(method, params) raise_connection_error!(method, params) if line.nil? - parsed = JSON.parse(line.strip) + parsed = JSON.parse(strip_frame(line)) # A frame carrying `method` is a request or notification, never the awaited response, # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id @@ -534,6 +534,14 @@ def read_line(method, params) ) end + # Strips a frame as bytes. The server's stdout is read as UTF-8, and `String#strip` raises for a UTF-8 string + # holding an invalid byte at either end on every supported Ruby, and anywhere in it from Ruby 3.2 on, which would + # surface as that exception instead of the client's own error. As bytes, the frame reaches the parser, which + # refuses what is not JSON with the client's parse failure. + def strip_frame(line) + line.b.strip.force_encoding(Encoding::UTF_8) + end + def raise_connection_error!(method, params) raise RequestHandlerError.new( "Server process closed stdout unexpectedly", diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index fd5d3823..76f5c862 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -709,6 +709,41 @@ def test_send_request_raises_error_on_invalid_json stdout_write.close end + def test_send_request_raises_its_parse_error_on_a_frame_starting_with_an_invalid_byte + server = lambda do |requests, frames| + answer_handshake(requests, frames) + request = JSON.parse(requests.gets) + frames.write("\xFF".b + JSON.generate({ jsonrpc: "2.0", id: request["id"], result: {} }) + "\n") + frames.flush + end + + with_scripted_server(server) do |transport| + transport.connect + error = assert_raises(RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) + end + + assert_equal("Failed to parse server response", error.message) + assert_instance_of(JSON::ParserError, error.original_error) + end + end + + def test_send_request_returns_a_response_holding_an_invalid_byte_in_a_value + server = lambda do |requests, frames| + answer_handshake(requests, frames) + request = JSON.parse(requests.gets) + frames.write("{\"jsonrpc\":\"2.0\",\"id\":#{JSON.generate(request["id"])},\"result\":{\"x\":\"a\xFFb\"}}\n".b) + frames.flush + end + + with_scripted_server(server) do |transport| + transport.connect + response = transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) + + assert_equal("a\xFFb".b, response.dig("result", "x").b) + end + end + def test_send_request_raises_error_on_a_response_that_is_not_json_rpc_2_0 answers = [ { result: { tools: [] } },