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