Skip to content

Bound OAuth response bodies in the client - #520

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:bound_oauth_response_bodies
Open

Bound OAuth response bodies in the client#520
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:bound_oauth_response_bodies

Conversation

@koic

@koic koic commented Aug 15, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic
koic force-pushed the bound_oauth_response_bodies branch from be96023 to 454c07e Compare August 15, 2026 18:27
## 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.
@koic
koic force-pushed the bound_oauth_response_bodies branch from 454c07e to 02db089 Compare August 15, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant