Skip to content

fix(transport): bound the incoming line buffer in AsyncRwTransport#1049

Open
onatozmenn wants to merge 1 commit into
modelcontextprotocol:mainfrom
onatozmenn:fix/bound-async-rw-line-buffer
Open

fix(transport): bound the incoming line buffer in AsyncRwTransport#1049
onatozmenn wants to merge 1 commit into
modelcontextprotocol:mainfrom
onatozmenn:fix/bound-async-rw-line-buffer

Conversation

@onatozmenn

Copy link
Copy Markdown

Fixes #1030

Motivation and Context

AsyncRwTransport::receive reads a line with read_until, which has no ceiling. A peer that never sends a newline makes line_buf grow until the process runs out of memory. That read path backs every stdio server built on rmcp::transport::io::stdio() and every client using TokioChildProcess, so one unterminated line from an untrusted peer is enough to take the process down.

JsonRpcMessageCodec already has max_length and enforces it correctly, but that code lives in Decoder::decode and this transport never calls the decoder, so the check was unreachable from here.

How

The limit can't be bolted onto read_until. It doesn't return until it hits a delimiter or EOF, so by the time you could look at the length the memory is already committed. receive now reads through fill_buf/consume, which lets the limit be checked before each append.

An oversized line is dropped and logged and the connection stays open, which is what the codec's decoder already does with is_discarding rather than tearing the session down.

The default is 16 MiB. I picked that to match DEFAULT_MAX_SSE_EVENT_SIZE, the bound the streamable HTTP client already applies to a single SSE event, so a payload that's fine over HTTP is also fine over stdio and callers sending large embedded resources don't get broken by the bound appearing. with_max_line_length overrides it, and usize::MAX gives back the old unbounded behaviour.

Cancellation safety is preserved, which matters because #947 depends on it. fill_buf consumes nothing if the future is dropped, and the copy into line_buf and the matching consume have no await between them, so a partially read line still survives to the next receive call.

How Has This Been Tested?

New crates/rmcp/tests/test_async_rw_max_line_length.rs, 7 tests:

  • an oversized but valid message is dropped and the next one still arrives. Valid is the important part: unparsable junk gets discarded by the existing parse error handling either way, so only a well formed oversized message tells a bounded read apart from an unbounded one
  • unbounded_limit_still_delivers_an_oversized_message is the control for that. With usize::MAX the exact same message comes through, so the assertion above is really testing the bound and not something incidental
  • the shape from the report: 128 KiB with no newline anywhere, then a real message, which has to arrive
  • a message right at the limit is still delivered
  • a 1 MiB message passes on the default limit
  • a line arriving in 97 byte chunks is reassembled, which guards the read_until to fill_buf swap
  • two oversized messages back to back don't wedge the transport

Also run on Rust 1.96, the pinned toolchain:

  • test_stdio_response_concurrency, 2 passed. That's the fix(transport): make AsyncRwTransport::receive cancel-safe (#941) #947 cancellation regression, and it's the one I most wanted to see stay green after touching this loop
  • the CI "no local feature" job command. Everything green except test_with_js, which fails in my container only because there's no Node in it
  • cargo clippy --all-targets --all-features -- -D warnings, clean
  • cargo fmt clean on both changed files

Breaking Changes

No API break. It is a behaviour change though: a single message over 16 MiB used to be delivered and is now dropped. Anyone relying on that can call .with_max_line_length(usize::MAX).

If you'd rather have a different default, or would rather the bound be opt-in for now, that's an easy change. 16 MiB was the most defensible number I could find inside the repo rather than one I made up.

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

Investigated and prepared with AI assistance.

`AsyncRwTransport::receive` read a line with `read_until`, which has no
ceiling, so a peer that never sends a newline makes `line_buf` grow until
the process runs out of memory. This affects every stdio server built on
`rmcp::transport::io::stdio()` and every client using `TokioChildProcess`.

`JsonRpcMessageCodec` already implements this bound correctly, but that
logic lives in `Decoder::decode`, which this transport never calls.

Reading through `fill_buf`/`consume` lets the limit be checked before each
append, which `read_until` cannot do: it does not return until it reaches a
delimiter or EOF, so by the time its result could be inspected the memory is
already committed. An oversized line is dropped and logged and the
connection stays open, matching the discard behaviour the codec's decoder
already uses.

The default is 16 MiB, the same bound the streamable HTTP client applies to
a single SSE event, so a payload that is acceptable over HTTP is also
acceptable over stdio. `with_max_line_length` overrides it and `usize::MAX`
restores the previous unbounded behaviour.

Cancellation safety is preserved. `fill_buf` consumes nothing if the future
is dropped, and the copy into `line_buf` and the matching `consume` have no
await between them, so a partially read line still survives to the next
call, which is what modelcontextprotocol#947 relies on.

Fixes modelcontextprotocol#1030
@github-actions github-actions Bot added T-test Testing related changes T-core Core library changes T-transport Transport layer changes labels Jul 24, 2026
@onatozmenn
onatozmenn marked this pull request as ready for review July 24, 2026 19:57
@onatozmenn
onatozmenn requested a review from a team as a code owner July 24, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unbounded line buffer in AsyncRwTransport::receive (stdio transport) — memory-exhaustion denial-of-service

1 participant