From 42e8cd59acc9d50bf902a732513a0caaa4561a86 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 22 Oct 2026 10:00:56 +0700 Subject: [PATCH] Skip a stdio ping whose id cannot be encoded ## Motivation and Context `MCP::Client::Stdio` answers a server's `ping` inline while it waits for a response, and it already skips a ping whose id is not a JSON-RPC id. A String id holding bytes that are not valid UTF-8 passed that check, and the pong echoing it failed in `JSON.generate` with `JSON::GeneratorError`. The pong's own rescue covers only a pong that cannot be written, so the error escaped and failed the unrelated request whose response the loop was reading. Such a ping is now skipped like one with a malformed id, since a pong could not carry the id back. A number such as `1e400` fails the same way: the parser reads it as an infinite Float, which `JSON.generate` refuses, so a ping carrying one is skipped as well. A String id that is not valid UTF-8 can arrive in a plain ASCII frame too: the json gem's 2.x releases decode a lone surrogate escape into one. ## How Has This Been Tested? `test/mcp/client/stdio_test.rb`: a ping whose id is not valid UTF-8 is not answerable, and one with a valid id still is. Against the previous library the first is answerable. A ping whose id is `1e400`, sent ahead of the awaited response, is not answered and the request completes; against the previous library it raises `JSON::GeneratorError`. ## Breaking Changes None. --- lib/mcp/client/stdio.rb | 4 +++- test/mcp/client/stdio_test.rb | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index ba4558f8..eba19bb5 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -484,8 +484,10 @@ def answerable_ping?(parsed) parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"]) end + # An id the pong could not write back is skipped too: a String that is not valid UTF-8, or a number + # such as `1e400`, which the parser reads as an infinite Float that JSON cannot represent. def json_rpc_id?(id) - id.is_a?(String) || id.is_a?(Numeric) + (id.is_a?(String) && id.valid_encoding?) || id.is_a?(Integer) || (id.is_a?(Float) && id.finite?) end def ensure_running! diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index fd5d3823..d4d95c64 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -324,6 +324,38 @@ def test_a_ping_with_a_malformed_id_is_not_answered stdout_write.close end + def test_a_ping_whose_id_is_not_valid_utf_8_is_not_answered + # Checked on the parsed frame, since how such a String arrives depends on the json gem and the Ruby version + # (a raw byte, or a lone surrogate escape on json 2.x); the pong for it would fail in `JSON.generate`. + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + ping = { "jsonrpc" => "2.0", "id" => "p\xFF".dup.force_encoding(Encoding::UTF_8), "method" => "ping" } + + refute(transport.send(:answerable_ping?, ping)) + assert(transport.send(:answerable_ping?, ping.merge("id" => "p"))) + end + + def test_a_ping_whose_id_parses_to_an_infinite_float_is_not_answered_and_the_request_completes + written = [] + server = lambda do |requests, frames| + answer_handshake(requests, frames) + request = JSON.parse(requests.gets) + # `1e400` is valid JSON that the parser reads as `Float::INFINITY`, which `JSON.generate` refuses. + frames.puts('{"jsonrpc":"2.0","id":1e400,"method":"ping"}') + write_frame(frames, { jsonrpc: "2.0", id: "srv-ping-2", method: "ping" }) + # The single pong must answer the second ping; the response follows only after it arrives. + written << JSON.parse(requests.gets) + write_frame(frames, { jsonrpc: "2.0", id: request["id"], result: { tools: [] } }) + 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("test-id", response["id"]) + end + assert_equal([{ "jsonrpc" => "2.0", "id" => "srv-ping-2", "result" => {} }], written) + end + def test_send_request_skips_notifications stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe