Parse HTTP client response bodies without Faraday's JSON middleware

## Motivation and Context

json 3.0, released on 2026-09-07, accepts the options of `JSON.parse` as keywords only, and Faraday's json response middleware,
through 2.14.3, hands them over as a positional Hash, which Ruby 3 no longer converts. With both installed, every JSON body
the client receives fails inside that middleware, and `MCP::Client::HTTP` reports each request as an internal error.
Faraday's main branch passes the options as keywords, but no release carries that yet.

The client already parses streamed bodies and buffered SSE bodies itself; only a JSON body delivered
whole by an adapter without streaming support still went through the middleware. The client now parses that body too
and no longer registers the middleware, so every body takes `parse_json_buffer` and a malformed one answers
`:parse_error` whichever way it arrived. A JSON middleware added through the Faraday customizer block still works,
since a body it has parsed already is used as is.

## How Has This Been Tested?

A new test drives a buffered JSON response through a stand-in `JSON.parse` with the json 3.0 signature, options as keywords only,
so on Ruby 3 it failed with the middleware registered whatever json version is installed and passes now.
The existing buffered-body test keeps covering the plain path. The full suite passes on Ruby 3.0.7 with json 3.0.1 and faraday 2.14.3,
where five client tests failed before.

## Breaking Changes

None. A malformed JSON body delivered by an adapter without streaming support now raises `RequestHandlerError` with `error_type: :parse_error`,
as the streamed path already did, instead of `:internal_error` wrapping a `Faraday::ParsingError`.
This commit is contained in:
Koichi ITO 2026-09-08 17:04:43 +09:00
commit 09c89aef14
2 changed files with 24 additions and 3 deletions

View file

@ -723,7 +723,8 @@ module MCP
require_faraday!
@client ||= Faraday.new(url) do |faraday|
faraday.request(:json)
faraday.response(:json)
# The client parses response bodies itself (`resolve_response_body`), so streamed and buffered responses
# take the same path; Faraday's json response middleware is deliberately left out.
faraday.response(:raise_error)
faraday.headers["Accept"] = ACCEPT_HEADER
@ -1074,8 +1075,8 @@ module MCP
elsif content_type&.include?("application/json")
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
# Adapters without `on_data` support deliver the body via `response.body`,
# already parsed by the json response middleware.
# Adapters without `on_data` support deliver the body via `response.body`; a JSON middleware
# added by the Faraday customizer may have parsed it already.
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
else
raise RequestHandlerError.new(

View file

@ -940,6 +940,26 @@ module MCP
assert_equal({ "result" => { "tools" => [] } }, response)
end
def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_only
# json 3.0 accepts parser options as keywords only, and Faraday's json response middleware
# passes them as a positional Hash, which Ruby 3 no longer converts. The stand-in parser has
# the json 3.0 signature, so the body must reach `JSON.parse` through the client's own call.
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
stub.post("/") do
[200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json]
end
end
client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
parse = JSON.method(:parse)
keyword_options_only_parse = ->(source, **options) { parse.call(source, **options) }
response = JSON.stub(:parse, keyword_options_only_parse) do
client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" })
end
assert_equal({ "result" => { "tools" => [] } }, response)
end
def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers
# SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header`
# declarations, and the following `tools/call` mirrors the annotated arguments into