Bound OAuth response bodies in the client

## Motivation and Context

`MCP::Client::HTTP` caps a single message from the server at 4 MiB, but the OAuth flow talks to
authorization endpoints over its own Faraday connection, and that one had no cap. Discovery,
dynamic client registration, and token responses were buffered in full and handed to `JSON.parse`,
so a server that answered an OAuth request with an endless body grew the client's memory for as
long as it kept sending.

`Flow` and `IDJAGTokenExchange` each build their own connection, so both needed the bound.
The new `BoundedBody` counts bytes in a Faraday `on_data` callback and refuses the response once
it passes `MAX_RESPONSE_BYTES`, which matches the 4 MiB of `MCP::Client::HTTP::MAX_MESSAGE_BYTES`
and `MCP::Client::Stdio::MAX_LINE_BYTES`. It hands back the status paired with the bounded body
rather than the Faraday response, so no later caller can reach the unbounded `response.body`.

Those bytes are counted after decompression. The default Net::HTTP adapter negotiates `Accept-Encoding`
itself  and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body that expands past
the cap is refused partway through the expansion. That holds only while the connection leaves
`Accept-Encoding` alone, which is now recorded in a comment on both `default_http_client` definitions.

An over-cap response is refused rather than truncated: a partial discovery or token document cannot be validated.
Protected resource metadata is the one place where refusing is not fatal, since any PRM discovery failure
already selects the legacy authorization path by design.

## How Has This Been Tested?

New unit tests in `test/mcp/client/oauth/bounded_body_test.rb` cover the streaming path,
the at-limit boundary, and the fallback for adapters that ignore `on_data`. One of them serves
a gzip body over a real socket rather than through WebMock, which returns a stubbed body verbatim
and never runs the inflater; it fails if the cap ever starts measuring compressed bytes. New tests in
`test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/id_jag_token_exchange_test.rb` drive each of
the four request wrappers end to end with an over-cap body; all five fail without the cap in place.

## Breaking Changes

An OAuth endpoint response larger than 4 MiB is now rejected instead of parsed.
This commit is contained in:
Koichi ITO 2026-08-16 03:18:32 +09:00
commit 02db089038
4 changed files with 353 additions and 11 deletions

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true
require_relative "oauth/bounded_body"
require_relative "oauth/discovery"
require_relative "oauth/flow"
require_relative "oauth/in_memory_storage"

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
module MCP
class Client
module OAuth
# Bounds an OAuth response body while it arrives, rather than after it has been buffered.
# Discovery documents, registration responses, and token responses are all small by definition,
# so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of
# `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`.
class BoundedBody
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
# Raised while the body is read. Each caller translates it into its own error type,
# so this never reaches an embedder.
class TooLargeError < StandardError; end
# What the OAuth code reads from a response. The Faraday response itself is not passed on,
# so a later caller cannot reach the unbounded `response.body` by accident.
Response = Struct.new(:status, :body)
def initialize(max_bytes: MAX_RESPONSE_BYTES)
@max_bytes = max_bytes
@buffer = +""
end
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
# the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`.
def on_data
proc do |chunk, _received_bytes, _env|
@buffer << chunk
raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes
end
end
# The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver
# the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then,
# but refusing them still keeps an over-cap document out of `JSON.parse`.
def response_for(response)
Response.new(response.status, bounded_body(response))
end
private
def bounded_body(response)
return @buffer unless @buffer.empty?
body = response.body
body = body.is_a?(String) ? body : body.to_s
raise TooLargeError, too_large_message if body.bytesize > @max_bytes
body
end
def too_large_message
# Not "the authorization server": protected resource metadata comes from the MCP server's own origin,
# so this message covers endpoints on both sides of the flow.
"Response body from the OAuth endpoint exceeds #{@max_bytes} bytes"
end
end
private_constant :BoundedBody
end
end
end

View file

@ -1021,26 +1021,54 @@ module MCP
end
def http_get(url)
http_client.get(url)
bounded_request do |on_data|
http_client.get(url) do |req|
req.options.on_data = on_data
end
end
end
def http_post_json(url, body)
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/json"
req.headers["Accept"] = "application/json"
req.body = JSON.generate(body)
bounded_request do |on_data|
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/json"
req.headers["Accept"] = "application/json"
req.options.on_data = on_data
req.body = JSON.generate(body)
end
end
end
def http_post_form(url, form, headers: {})
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
req.headers["Accept"] = "application/json"
headers.each { |key, value| req.headers[key] = value }
req.body = URI.encode_www_form(form)
bounded_request do |on_data|
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
req.headers["Accept"] = "application/json"
headers.each do |key, value|
req.headers[key] = value
end
req.options.on_data = on_data
req.body = URI.encode_www_form(form)
end
end
end
# Issues a request with the response body bounded as it arrives, and returns the status paired with
# that body. An over-cap response is refused rather than truncated: a partial discovery or token document
# cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
# since the same server would serve the same body.
def bounded_request
bounded = BoundedBody.new
response = yield(bounded.on_data)
bounded.response_for(response)
rescue BoundedBody::TooLargeError => e
raise AuthorizationError, "#{e.message}."
end
def http_client
@http_client ||= @http_client_factory.call
end
@ -1050,8 +1078,14 @@ module MCP
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
# and the guards above only cover the first hop.
#
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
# after the check.
def default_http_client
require "faraday"
Faraday.new do |faraday|
faraday.headers["Accept"] = "application/json"
end

View file

@ -34,10 +34,13 @@ module MCP
def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
http_client ||= default_http_client
bounded = BoundedBody.new
response = begin
http_client.post(token_endpoint) do |req|
raw_response = http_client.post(token_endpoint) do |req|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
req.headers["Accept"] = "application/json"
req.options.on_data = bounded.on_data
req.body = URI.encode_www_form(
"grant_type" => GRANT_TYPE,
"subject_token" => id_token,
@ -48,6 +51,10 @@ module MCP
"client_id" => client_id,
)
end
bounded.response_for(raw_response)
rescue BoundedBody::TooLargeError => e
raise ExchangeError, "#{e.message}."
rescue Faraday::Error => e
raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
end
@ -88,8 +95,12 @@ module MCP
assertion
end
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
# onto compressed bytes.
def default_http_client
require "faraday"
Faraday.new do |faraday|
faraday.headers["Accept"] = "application/json"
end