Skip to content

Bound client-side message buffering in the HTTP transport#459

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

Bound client-side message buffering in the HTTP transport#459
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:bound_client_sse_event_buffering

Conversation

@koic

@koic koic commented Jul 15, 2026

Copy link
Copy Markdown
Member

Motivation and Context

MCP::Client::HTTP buffers server bytes with no upper bound in two places:

  • SSE responses are parsed incrementally, but the parser accumulates data: lines until a blank line dispatches the event, so a server that never terminates an event grows the buffer indefinitely. The abort-on-response signal does not help here, since it only fires after a complete response event has been dispatched.
  • Non-SSE (JSON) bodies accumulate in SSEStream#buffer chunk by chunk with no limit.

Both the POST response stream and the standalone GET listening stream are affected. The stdio client already bounds server frames (max_line_bytes:, 4 MiB default) and the server transports bound request bodies (max_request_bytes:, 4 MiB default); this brings the HTTP client in line.

MCP::Client::HTTP.new gains max_message_bytes: (default 4 MiB), which caps the bytes buffered for a single message - an SSE event or a JSON response body. External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters) from consumed-and-retained ones, so the check measures the parser's own String buffers after each chunk. An over-limit message raises RequestHandlerError on the request path and stops the GET listening stream instead of reconnecting to the same over-limit event.

How Has This Been Tested?

New tests in test/mcp/client/http_test.rb cover the SSE and JSON rejection paths end-to-end, the per-event reset (events that individually fit must not count against later ones), the env-less on_data fallback, and the constructor validation. bundle exec rake (tests, RuboCop, and conformance baseline) passes.

Breaking Changes

Responses larger than 4 MiB are now rejected by default; pass a larger gmax_message_bytes: to accept them.

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

Additional context

## Motivation and Context

`MCP::Client::HTTP` buffers server bytes with no upper bound in two places:

- SSE responses are parsed incrementally, but the parser accumulates `data:` lines until
  a blank line dispatches the event, so a server that never terminates an event grows
  the buffer indefinitely. The abort-on-response signal does not help here, since it only fires
  after a complete response event has been dispatched.
- Non-SSE (JSON) bodies accumulate in `SSEStream#buffer` chunk by chunk with no limit.

Both the POST response stream and the standalone GET listening stream are affected.
The stdio client already bounds server frames (`max_line_bytes:`, 4 MiB default)
and the server transports bound request bodies (`max_request_bytes:`, 4 MiB default);
this brings the HTTP client in line.

`MCP::Client::HTTP.new` gains `max_message_bytes:` (default 4 MiB), which caps the bytes buffered
for a single message - an SSE event or a JSON response body. External byte counting cannot tell
consumed-and-discarded bytes (comments, field names, delimiters) from consumed-and-retained ones,
so the check measures the parser's own String buffers after each chunk. An over-limit message raises
`RequestHandlerError` on the request path and stops the GET listening stream instead of reconnecting to
the same over-limit event.

## How Has This Been Tested?

New tests in `test/mcp/client/http_test.rb` cover the SSE and JSON rejection paths end-to-end,
the per-event reset (events that individually fit must not count against later ones),
the env-less `on_data` fallback, and the constructor validation. `bundle exec rake`
(tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

Responses larger than 4 MiB are now rejected by default; pass a larger g`max_message_bytes:` to accept them.
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.

2 participants