Summary
The streamable-HTTP client bounds SSE bodies but not the two non-SSE response bodies it reads on the POST path. bounded_sse_stream(..., max_sse_event_size) is applied to every text/event-stream response, so the streaming shape is protected — but in the same function, a success response with Content-Type: application/json is read with response.json(), and any non-success response is read with response.text() and then interpolated whole into an error message. Neither is bounded, and reqwest applies no response-size limit of its own, so a remote server can make the client buffer an arbitrary amount of memory from one HTTP response.
This is the HTTP counterpart of #1030 (unbounded line buffer in AsyncRwTransport::receive, stdio). Filing separately because it is a different transport, a different file, and a different fix — and because the peer is materially more untrusted here: a stdio peer is a child process the client chose to spawn, whereas a streamable-HTTP peer is a remote server.
Severity: Medium (denial-of-service, untrusted input, no data exposure).
Affected versions
Confirmed in rmcp 3.1.2 (current latest, published 2026-08-07), with the client-transport-streamable-http-reqwest feature.
Code reference
src/transport/common/reqwest/streamable_http_client.rs (crates/rmcp/... in the repo), inside post_message_with_max_sse_event_size:
// :278-297 — any non-success status
if !status.is_success() {
let body = response
.text() // <-- unbounded
.await
.unwrap_or_else(|_| "<failed to read response body>".to_owned());
...
return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned(
format!("HTTP {status}: {body}"), // <-- and copied whole into an error
)));
}
match content_type.as_deref() {
Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => {
let event_stream = bounded_sse_stream(response.bytes_stream(), max_sse_event_size);
Ok(...) // <-- bounded, correctly
}
Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => {
match response.json::<ServerJsonRpcMessage>().await { // :309, unbounded
For contrast, get_stream_with_max_sse_event_size in the same file routes every accepted response through bounded_sse_stream (:143), so the GET path has no equivalent gap.
Impact
A server (or anything in a position to answer for one) can return a multi-gigabyte application/json body, or fail a request with a multi-gigabyte error body, and the client buffers it. The error path is the cheaper of the two for an attacker: it needs no valid JSON-RPC payload at all, just a non-2xx status and a large body, and the bytes are copied a second time into the format! result before propagating to the caller as an Err.
max_sse_event_size (default 16 MiB, configurable via StreamableHttpClientTransportConfig) gives a caller a way to bound the streaming shape and no way to bound these two.
Suggested fix
The pattern is already in this file, which is what makes this look like an oversight rather than a design decision:
- Read both bodies as bounded streams rather than with
.text() / .json() — accumulate from response.bytes_stream() with the same ceiling bounded_sse_stream uses, and fail with a size-named error once it is crossed.
- A shared limit is probably right: the existing
max_sse_event_size already means "the largest single message this client will accept", and a JSON-RPC response body is the same kind of thing as an SSE event carrying one. Reusing it needs no new configuration surface; a separately named max_response_size would be clearer at the cost of one more knob.
- The error branch can be bounded more aggressively than the success branch — an error body is diagnostic text, so truncating it to a few KiB with a marker loses nothing a reader wants. It is also worth truncating before the
format!, so a large body is never copied.
response.content_length() is already read at :257 for a different purpose and could serve as a cheap early rejection, but only as an optimisation: it is absent for chunked responses and is server-supplied, so the stream-side bound is the one that actually holds.
Workaround
Callers cannot currently work around this from outside: StreamableHttpClientTransport::from_config constructs the reqwest::Client internally, so there is no seam to inject a size-limiting middleware or a wrapped client. Implementing StreamableHttpClient by hand is the only route, which means reimplementing the whole POST/GET/DELETE surface.
Found while auditing message-size bounds in a client that uses both transports. Happy to provide a runnable repro or test a candidate patch. If you would prefer this folded into #1030 as one "bound every read" issue, say so and I will move it.
Summary
The streamable-HTTP client bounds SSE bodies but not the two non-SSE response bodies it reads on the POST path.
bounded_sse_stream(..., max_sse_event_size)is applied to everytext/event-streamresponse, so the streaming shape is protected — but in the same function, a success response withContent-Type: application/jsonis read withresponse.json(), and any non-success response is read withresponse.text()and then interpolated whole into an error message. Neither is bounded, andreqwestapplies no response-size limit of its own, so a remote server can make the client buffer an arbitrary amount of memory from one HTTP response.This is the HTTP counterpart of #1030 (unbounded line buffer in
AsyncRwTransport::receive, stdio). Filing separately because it is a different transport, a different file, and a different fix — and because the peer is materially more untrusted here: a stdio peer is a child process the client chose to spawn, whereas a streamable-HTTP peer is a remote server.Severity: Medium (denial-of-service, untrusted input, no data exposure).
Affected versions
Confirmed in
rmcp3.1.2 (current latest, published 2026-08-07), with theclient-transport-streamable-http-reqwestfeature.Code reference
src/transport/common/reqwest/streamable_http_client.rs(crates/rmcp/...in the repo), insidepost_message_with_max_sse_event_size:For contrast,
get_stream_with_max_sse_event_sizein the same file routes every accepted response throughbounded_sse_stream(:143), so the GET path has no equivalent gap.Impact
A server (or anything in a position to answer for one) can return a multi-gigabyte
application/jsonbody, or fail a request with a multi-gigabyte error body, and the client buffers it. The error path is the cheaper of the two for an attacker: it needs no valid JSON-RPC payload at all, just a non-2xx status and a large body, and the bytes are copied a second time into theformat!result before propagating to the caller as anErr.max_sse_event_size(default 16 MiB, configurable viaStreamableHttpClientTransportConfig) gives a caller a way to bound the streaming shape and no way to bound these two.Suggested fix
The pattern is already in this file, which is what makes this look like an oversight rather than a design decision:
.text()/.json()— accumulate fromresponse.bytes_stream()with the same ceilingbounded_sse_streamuses, and fail with a size-named error once it is crossed.max_sse_event_sizealready means "the largest single message this client will accept", and a JSON-RPC response body is the same kind of thing as an SSE event carrying one. Reusing it needs no new configuration surface; a separately namedmax_response_sizewould be clearer at the cost of one more knob.format!, so a large body is never copied.response.content_length()is already read at :257 for a different purpose and could serve as a cheap early rejection, but only as an optimisation: it is absent for chunked responses and is server-supplied, so the stream-side bound is the one that actually holds.Workaround
Callers cannot currently work around this from outside:
StreamableHttpClientTransport::from_configconstructs thereqwest::Clientinternally, so there is no seam to inject a size-limiting middleware or a wrapped client. ImplementingStreamableHttpClientby hand is the only route, which means reimplementing the whole POST/GET/DELETE surface.Found while auditing message-size bounds in a client that uses both transports. Happy to provide a runnable repro or test a candidate patch. If you would prefer this folded into #1030 as one "bound every read" issue, say so and I will move it.