Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion lib/mcp/client/stdio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions test/mcp/client/stdio_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] } },
Expand Down
Loading