Bound HTTP client reads

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-07-08 11:48:22 +02:00 • committed by Daniel Garnier-Moiroux
commit ef037dae7b
4 changed files with 1198 additions and 28 deletions

View file

@ -88,6 +88,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
/** Default SSE endpoint path */
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
/**
* Default maximum number of bytes read for a single inbound message.
*/
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
/** Base URI for the MCP server */
private final URI baseUri;
@ -128,6 +133,12 @@ public class HttpClientSseClientTransport implements McpClientTransport {
*/
private final SseMessageEndpointValidator messageEndpointValidator;
/**
* Maximum number of bytes read for a single inbound message, whether it arrives on
* the SSE stream or as the response to a posted message.
*/
private final int maxResponseSize;
/**
* Creates a new transport instance with custom HTTP client builder, object mapper,
* and headers.
@ -139,11 +150,13 @@ public class HttpClientSseClientTransport implements McpClientTransport {
* @param httpRequestCustomizer customizer for the requestBuilder before executing
* requests
* @param messageEndpointValidator validator for the message endpoint
* @param maxResponseSize the maximum number of bytes read for a single inbound
* message
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
*/
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
SseMessageEndpointValidator messageEndpointValidator) {
SseMessageEndpointValidator messageEndpointValidator, int maxResponseSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
@ -151,6 +164,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
Assert.notNull(requestBuilder, "requestBuilder must not be null");
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.baseUri = URI.create(baseUri);
this.sseEndpoint = sseEndpoint;
this.jsonMapper = jsonMapper;
@ -158,6 +172,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
this.requestBuilder = requestBuilder;
this.httpRequestCustomizer = httpRequestCustomizer;
this.messageEndpointValidator = messageEndpointValidator;
this.maxResponseSize = maxResponseSize;
}
@Override
@ -195,6 +210,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
/**
* Creates a new builder instance.
*/
@ -326,6 +343,26 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return this;
}
/**
* Sets the maximum number of bytes read for a single inbound message, whether it
* arrives on the SSE stream or as the response to a posted message. A peer that
* sends a larger message (or never terminates one) has its stream aborted instead
* of forcing the transport to buffer it in memory. Defaults to 16MiB.
*
* <p>
* The bound applies per message, not to the stream as a whole: a long-lived SSE
* stream may deliver any number of messages, each up to this size. SSE field
* framing is allowed a small amount of headroom on top of this size, so a message
* of exactly this many bytes is still accepted.
* @param maxResponseSize the maximum inbound message size, in bytes
* @return this builder
*/
public Builder maxResponseSize(int maxResponseSize) {
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.maxResponseSize = maxResponseSize;
return this;
}
/**
* Builds a new {@link HttpClientSseClientTransport} instance.
* @return a new transport instance
@ -334,7 +371,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
messageEndpointValidator);
messageEndpointValidator, maxResponseSize);
}
}
@ -353,13 +390,15 @@ public class HttpClientSseClientTransport implements McpClientTransport {
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
}).flatMap(requestBuilder -> Mono.create(sink -> {
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
.sendAsync(requestBuilder.build(),
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
.exceptionallyCompose(e -> {
sseSink.error(e);
return CompletableFuture.failedFuture(e);
}))
Disposable connection = Flux.<ResponseEvent>create(
sseSink -> this.httpClient
.sendAsync(requestBuilder.build(),
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink,
this.maxResponseSize))
.exceptionallyCompose(e -> {
sseSink.error(e);
return CompletableFuture.failedFuture(e);
}))
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
.flatMap(responseEvent -> {
if (isClosing) {
@ -490,7 +529,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
}).flatMap(customizedBuilder -> {
var request = customizedBuilder.build();
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
return Mono.fromFuture(
httpClient.sendAsync(request, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
});
}

View file

@ -87,6 +87,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final String DEFAULT_ENDPOINT = "/mcp";
/**
* Default maximum number of bytes read for a single inbound message.
*/
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
/**
* HTTP client for sending messages to the server. Uses HTTP POST over the message
* endpoint
@ -161,11 +166,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private final String latestSupportedProtocolVersion;
/**
* Maximum number of bytes read for a single inbound message, whether it arrives on an
* SSE stream or as a JSON response body.
*/
private final int maxResponseSize;
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler,
List<String> supportedProtocolVersions) {
List<String> supportedProtocolVersions, int maxResponseSize) {
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.jsonMapper = jsonMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
@ -181,6 +193,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
.sorted(Comparator.reverseOrder())
.findFirst()
.get();
this.maxResponseSize = maxResponseSize;
}
@Override
@ -229,7 +242,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
}).flatMap(requestBuilder -> {
var request = requestBuilder.build();
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
return Mono.fromFuture(() -> this.httpClient.sendAsync(request,
ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
}).then();
}
@ -470,16 +484,16 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
if (contentType.contains(TEXT_EVENT_STREAM)) {
// For SSE streams, use line subscriber that returns Void
logger.debug("Received SSE stream response, using line subscriber");
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink, this.maxResponseSize);
}
else if (contentType.contains(APPLICATION_JSON)) {
// For JSON responses and others, use string subscriber
logger.debug("Received response, using string subscriber");
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink, this.maxResponseSize);
}
logger.debug("Received Bodyless response, using discarding subscriber");
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
};
return responseBodyHandler;
@ -754,6 +768,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP;
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
/**
* Creates a new builder with the specified base URI.
* @param baseUri the base URI of the MCP server
@ -952,6 +968,26 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return this;
}
/**
* Sets the maximum number of bytes read for a single inbound message, whether it
* arrives on an SSE stream or as a JSON response body. A peer that sends a larger
* message (or never terminates one) has its stream aborted instead of forcing the
* transport to buffer it in memory. Defaults to 16MiB.
*
* <p>
* The bound applies per message, not to the stream as a whole: a long-lived SSE
* stream may deliver any number of messages, each up to this size. SSE field
* framing is allowed a small amount of headroom on top of this size, so a message
* of exactly this many bytes is still accepted.
* @param maxResponseSize the maximum inbound message size, in bytes
* @return this builder
*/
public Builder maxResponseSize(int maxResponseSize) {
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.maxResponseSize = maxResponseSize;
return this;
}
/**
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
* the current builder configuration.
@ -961,7 +997,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize);
}
}

View file

@ -5,8 +5,13 @@
package io.modelcontextprotocol.client.transport;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.BodySubscriber;
import java.net.http.HttpResponse.ResponseInfo;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
@ -30,11 +35,21 @@ import reactor.core.publisher.FluxSink;
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
* @author Daniel Garnier-Moiroux
*/
class ResponseSubscribers {
private static final Logger logger = LoggerFactory.getLogger(ResponseSubscribers.class);
/**
* Bytes of SSE field framing a single line may carry on top of the message payload:
* {@code "event: "} is the longest field prefix this parser recognises. Line
* terminators are not counted, as they reset the running line length. Without this
* allowance, an event carrying exactly the maximum message size would be rejected
* because of the bytes the SSE wire format adds around it.
*/
private static final int SSE_FRAMING_OVERHEAD = "event: ".length();
record SseEvent(String id, String event, String data) {
}
@ -54,19 +69,80 @@ class ResponseSubscribers {
record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent {
}
static BodySubscriber<Void> sseToBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
return HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink)));
/**
* Creates a {@link BodySubscriber} that parses a Server-Sent Events stream, bounding
* how much memory a single inbound message may occupy. Both the size of an individual
* line (as read off the wire before a terminator is seen) and the accumulated size of
* a multi-line SSE event are capped at {@code maxSize}; a peer exceeding either limit
* has its stream aborted instead of forcing the transport to buffer it in memory. The
* line bound is allowed {@link #SSE_FRAMING_OVERHEAD} extra bytes so that the SSE
* framing around a payload does not count against the payload's own budget.
* @param responseInfo the HTTP response information
* @param sink the sink to emit parsed events to
* @param maxSize the maximum number of bytes read for a single inbound message
*/
static BodySubscriber<Void> sseToBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink,
int maxSize) {
BodySubscriber<Void> lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink, maxSize)));
return new BoundedLineBodySubscriber(lineSubscriber, plusFramingOverhead(maxSize));
}
static BodySubscriber<Void> aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
return HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink)));
/**
* Adds {@link #SSE_FRAMING_OVERHEAD} to {@code maxSize}, saturating at
* {@link Integer#MAX_VALUE} rather than overflowing into a negative bound that would
* reject everything.
*/
private static int plusFramingOverhead(int maxSize) {
return maxSize > Integer.MAX_VALUE - SSE_FRAMING_OVERHEAD ? Integer.MAX_VALUE : maxSize + SSE_FRAMING_OVERHEAD;
}
static BodySubscriber<Void> bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
return HttpResponse.BodySubscribers
/**
* Creates a {@link BodySubscriber} that aggregates the whole response body into a
* single event, bounding how much memory it may occupy. Both the size of an
* individual line (as read off the wire before a terminator is seen) and the total
* accumulated body are capped at {@code maxSize}; a peer exceeding either limit has
* its response aborted instead of forcing the transport to buffer it in memory.
* @param responseInfo the HTTP response information
* @param sink the sink to emit the aggregated event to
* @param maxSize the maximum number of bytes read for the response body
*/
static BodySubscriber<Void> aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink,
int maxSize) {
BodySubscriber<Void> lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink, maxSize)));
return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
}
/**
* Creates a {@link BodySubscriber} that discards the response body, bounding how much
* memory reading it may occupy. The body is discarded as it arrives, but the
* underlying line subscriber still buffers each line before handing it over, so a
* peer sending a line longer than {@code maxSize} has its response aborted.
* @param responseInfo the HTTP response information
* @param sink the sink to emit the completion event to
* @param maxSize the maximum number of bytes read for a single line
*/
static BodySubscriber<Void> bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink,
int maxSize) {
BodySubscriber<Void> lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink)));
return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
}
/**
* Creates a {@link BodyHandler} that reads the response body into a string, bounding
* how much memory it may occupy. A peer sending more than {@code maxSize} bytes has
* its response aborted instead of forcing the transport to buffer it in memory.
*
* <p>
* Decoding matches {@link HttpResponse.BodyHandlers#ofString()}, including its
* handling of the charset declared in the {@code Content-Type} header.
* @param maxSize the maximum number of bytes read for the response body
*/
static BodyHandler<String> boundedStringBodyHandler(int maxSize) {
BodyHandler<String> delegate = HttpResponse.BodyHandlers.ofString();
return responseInfo -> new BoundedTotalBodySubscriber<>(delegate.apply(responseInfo), maxSize);
}
static class SseLineSubscriber extends BaseSubscriber<String> {
@ -112,18 +188,30 @@ class ResponseSubscribers {
*/
private ResponseInfo responseInfo;
/**
* The maximum number of bytes that may accumulate for a single SSE event. A peer
* that never terminates an event (e.g. an endless stream of {@code data:} lines)
* has its stream aborted instead of exhausting memory. The accumulated data is
* measured in characters, which for UTF-8 is never more than the number of bytes
* it was decoded from.
*/
private final int maxSize;
/**
* Creates a new LineSubscriber that will emit parsed SSE events to the provided
* sink.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to
* @param maxSize the maximum number of bytes that may accumulate for a single SSE
* event
*/
public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink, int maxSize) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.currentEventId = new AtomicReference<>();
this.currentEventType = new AtomicReference<>();
this.responseInfo = responseInfo;
this.maxSize = maxSize;
}
@Override
@ -155,7 +243,18 @@ class ResponseSubscribers {
if (line.startsWith("data:")) {
var matcher = EVENT_DATA_PATTERN.matcher(line);
if (matcher.find()) {
this.eventBuilder.append(matcher.group(1).trim()).append("\n");
String data = matcher.group(1).trim();
// Measured before appending, so that an event carrying exactly
// maxSize of data is accepted: the trailing separator below is
// stripped again before the event is emitted.
if (this.eventBuilder.length() + data.length() > this.maxSize) {
upstream().cancel();
this.sink.error(
new McpTransportException("Inbound SSE event exceeds the maximum allowed size of "
+ this.maxSize + " bytes"));
return;
}
this.eventBuilder.append(data).append("\n");
}
upstream().request(1);
}
@ -225,15 +324,26 @@ class ResponseSubscribers {
volatile boolean hasRequestedDemand = false;
/**
* The maximum number of bytes that may accumulate for the aggregated response
* body. A peer that sends a larger body has its response aborted instead of
* exhausting memory. The accumulated body is measured in characters, which for
* UTF-8 is never more than the number of bytes it was decoded from.
*/
private final int maxSize;
/**
* Creates a new JsonLineSubscriber that will emit parsed JSON-RPC messages.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to
* @param maxSize the maximum number of bytes that may accumulate for the
* aggregated response body
*/
public AggregateSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
public AggregateSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink, int maxSize) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.responseInfo = responseInfo;
this.maxSize = maxSize;
}
@Override
@ -252,6 +362,15 @@ class ResponseSubscribers {
@Override
protected void hookOnNext(String line) {
// Measured before appending, so that a body of exactly maxSize is accepted.
// The separator this adds back for each line stands in for the terminator the
// peer sent, which the line subscriber has already stripped.
if (this.eventBuilder.length() + line.length() > this.maxSize) {
upstream().cancel();
this.sink.error(new McpTransportException(
"Inbound response body exceeds the maximum allowed size of " + this.maxSize + " bytes"));
return;
}
this.eventBuilder.append(line).append("\n");
}
@ -324,4 +443,177 @@ class ResponseSubscribers {
}
/**
* Base for {@link BodySubscriber} wrappers that transparently forward the response
* body to a delegate, but abort it once the peer exceeds a size bound.
*
* <p>
* Aborting cancels the upstream subscription, which closes the connection, and
* signals a {@link McpTransportException} to the delegate so the failure surfaces
* both through the body's {@link CompletionStage} and through any sink the delegate
* feeds.
*/
abstract static class BoundedBodySubscriber<T> implements BodySubscriber<T> {
private final BodySubscriber<T> delegate;
protected final int maxSize;
/**
* What the bound applies to, e.g. {@code "Inbound line"}, used to build the
* failure message.
*/
private final String boundedEntity;
private Flow.Subscription subscription;
private volatile boolean done = false;
BoundedBodySubscriber(BodySubscriber<T> delegate, int maxSize, String boundedEntity) {
this.delegate = delegate;
this.maxSize = maxSize;
this.boundedEntity = boundedEntity;
}
@Override
public CompletionStage<T> getBody() {
return this.delegate.getBody();
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
this.delegate.onSubscribe(subscription);
}
@Override
public void onNext(List<ByteBuffer> buffers) {
if (this.done) {
return;
}
for (ByteBuffer buffer : buffers) {
if (!checkSize(buffer)) {
this.done = true;
this.subscription.cancel();
this.delegate.onError(new McpTransportException(
this.boundedEntity + " exceeds the maximum allowed size of " + this.maxSize + " bytes"));
return;
}
}
this.delegate.onNext(buffers);
}
/**
* Accounts for the bytes in {@code buffer}, which must be inspected with absolute
* reads only so the delegate still sees the original position.
* @param buffer the buffer about to be handed to the delegate
* @return {@code true} to accept the buffer, or {@code false} to abort the
* response because the bound has been exceeded
*/
protected abstract boolean checkSize(ByteBuffer buffer);
@Override
public void onError(Throwable throwable) {
if (this.done) {
return;
}
this.done = true;
this.delegate.onError(throwable);
}
@Override
public void onComplete() {
if (this.done) {
return;
}
this.done = true;
this.delegate.onComplete();
}
}
/**
* A {@link BoundedBodySubscriber} that aborts the response once a single line (a run
* of bytes with no CR/LF terminator) exceeds {@code maxSize} bytes.
*
* <p>
* {@link HttpResponse.BodySubscribers#fromLineSubscriber} buffers characters until it
* encounters a line terminator, so a peer that never terminates a line (or sends an
* enormous one) would force the transport to buffer it in memory. This wrapper counts
* bytes as they arrive off the wire and cancels the subscription before that buffer
* can grow without bound.
*/
static final class BoundedLineBodySubscriber extends BoundedBodySubscriber<Void> {
private long bytesSinceLineTerminator = 0;
BoundedLineBodySubscriber(BodySubscriber<Void> delegate, int maxSize) {
super(delegate, maxSize, "Inbound line");
}
@Override
protected boolean checkSize(ByteBuffer buffer) {
int position = buffer.position();
int limit = buffer.limit();
if (position == limit) {
return true;
}
if (this.bytesSinceLineTerminator + (limit - position) <= this.maxSize) {
// No line ending in this buffer can exceed the limit, because there are
// not enough bytes since the last terminator for one to. Only the
// trailing (still unterminated) run matters, so scan back to the last
// terminator instead of walking every byte.
this.bytesSinceLineTerminator = lengthOfTrailingRun(buffer, position, limit);
return true;
}
// The limit is within reach, so account for every line exactly.
for (int i = position; i < limit; i++) {
byte b = buffer.get(i);
if (b == '\n' || b == '\r') {
this.bytesSinceLineTerminator = 0;
}
else if (++this.bytesSinceLineTerminator > this.maxSize) {
return false;
}
}
return true;
}
/**
* Returns the number of bytes after the last line terminator in the buffer, or
* the whole span added to the running count when the buffer holds no terminator.
*/
private long lengthOfTrailingRun(ByteBuffer buffer, int position, int limit) {
for (int i = limit - 1; i >= position; i--) {
byte b = buffer.get(i);
if (b == '\n' || b == '\r') {
return limit - 1 - i;
}
}
return this.bytesSinceLineTerminator + (limit - position);
}
}
/**
* A {@link BoundedBodySubscriber} that aborts the response once the body as a whole
* exceeds {@code maxSize} bytes. Suitable for delegates that aggregate the entire
* body in memory, such as {@link HttpResponse.BodyHandlers#ofString()}.
*/
static final class BoundedTotalBodySubscriber<T> extends BoundedBodySubscriber<T> {
private long totalBytes = 0;
BoundedTotalBodySubscriber(BodySubscriber<T> delegate, int maxSize) {
super(delegate, maxSize, "Inbound response body");
}
@Override
protected boolean checkSize(ByteBuffer buffer) {
this.totalBytes += buffer.remaining();
return this.totalBytes <= this.maxSize;
}
}
}

View file

@ -0,0 +1,334 @@
/*
* Copyright 2024-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.net.http.HttpResponse.BodySubscriber;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.BoundedLineBodySubscriber;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.BoundedTotalBodySubscriber;
import io.modelcontextprotocol.spec.McpTransportException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests the size accounting in {@link ResponseSubscribers.BoundedBodySubscriber} and its
* two implementations. These bound how much of a response the transport will buffer, so
* the accounting is exercised directly rather than only through a live HTTP exchange:
* buffer boundaries, line terminators split across buffers, and the exact limit are all
* places where an off-by-one either lets a peer past the bound or rejects a legitimate
* message.
*
* @author Daniel Garnier-Moiroux
*/
class BoundedBodySubscriberTests {
private static final int MAX_SIZE = 16;
private final RecordingBodySubscriber delegate = new RecordingBodySubscriber();
private final RecordingSubscription subscription = new RecordingSubscription();
// --- BoundedLineBodySubscriber: per-line accounting -----------------------
@Test
void lineSubscriberAcceptsEmptyBuffer() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer(""))).isTrue();
}
@Test
void lineSubscriberAcceptsLineOfExactlyMaxSize() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
}
@Test
void lineSubscriberRejectsLineOneByteOverMaxSize() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE + 1)))).isFalse();
}
@Test
void lineSubscriberAccumulatesAcrossBuffers() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(10)))).isTrue();
assertThat(subscriber.checkSize(buffer("a".repeat(6)))).isTrue();
// 17th byte of the same unterminated line.
assertThat(subscriber.checkSize(buffer("a"))).isFalse();
}
@Test
void lineSubscriberAcceptsUnboundedTotalOfTerminatedLines() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
// Far more than MAX_SIZE in total, but no single line comes close to it.
for (int i = 0; i < 100; i++) {
assertThat(subscriber.checkSize(buffer("aaaa\n"))).isTrue();
}
}
@Test
void lineSubscriberResetsOnTerminatorAtEndOfBuffer() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(10) + "\n"))).isTrue();
// A fresh line, so the previous 10 bytes must not count towards it.
assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
}
@Test
void lineSubscriberResetsOnTerminatorAtStartOfBuffer() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
assertThat(subscriber.checkSize(buffer("\n" + "a".repeat(MAX_SIZE)))).isTrue();
}
@Test
void lineSubscriberHandlesCrLfSplitAcrossBuffers() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(12) + "\r"))).isTrue();
assertThat(subscriber.checkSize(buffer("\n" + "a".repeat(MAX_SIZE)))).isTrue();
}
@Test
void lineSubscriberAcceptsBufferLargerThanMaxSizeHoldingOnlyShortLines() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
// Forces the exact per-byte accounting path: the buffer alone is well over the
// limit, yet every line in it is legitimate.
assertThat(subscriber.checkSize(buffer("aaaa\n".repeat(20)))).isTrue();
}
@Test
void lineSubscriberRejectsRunSpanningManyBuffers() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
boolean accepted = true;
for (int i = 0; i < 10 && accepted; i++) {
accepted = subscriber.checkSize(buffer("aa"));
}
assertThat(accepted).isFalse();
}
@Test
void lineSubscriberOnlyCountsFromTheBufferPosition() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2));
partiallyConsumed.position(MAX_SIZE * 2 - 4);
assertThat(subscriber.checkSize(partiallyConsumed)).isTrue();
}
@Test
void lineSubscriberDoesNotConsumeTheBuffer() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
ByteBuffer buffer = buffer("aaaa\nbbbb");
buffer.position(2);
subscriber.checkSize(buffer);
assertThat(buffer.position()).isEqualTo(2);
assertThat(buffer.limit()).isEqualTo(9);
}
// --- BoundedTotalBodySubscriber: whole-body accounting --------------------
@Test
void totalSubscriberAcceptsBodyOfExactlyMaxSize() {
BoundedTotalBodySubscriber<Void> subscriber = totalSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
}
@Test
void totalSubscriberRejectsBodyOneByteOverMaxSize() {
BoundedTotalBodySubscriber<Void> subscriber = totalSubscriber();
assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
assertThat(subscriber.checkSize(buffer("a".repeat(9)))).isFalse();
}
@Test
void totalSubscriberIsNotResetByLineTerminators() {
BoundedTotalBodySubscriber<Void> subscriber = totalSubscriber();
// Unlike the per-line bound, terminated lines still count towards the total.
assertThat(subscriber.checkSize(buffer("aaaa\n".repeat(3)))).isTrue();
assertThat(subscriber.checkSize(buffer("aaaa\n"))).isFalse();
}
@Test
void totalSubscriberOnlyCountsFromTheBufferPosition() {
BoundedTotalBodySubscriber<Void> subscriber = totalSubscriber();
ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2));
partiallyConsumed.position(MAX_SIZE);
assertThat(subscriber.checkSize(partiallyConsumed)).isTrue();
}
@Test
void totalSubscriberDoesNotConsumeTheBuffer() {
BoundedTotalBodySubscriber<Void> subscriber = totalSubscriber();
ByteBuffer buffer = buffer("aaaa");
subscriber.checkSize(buffer);
assertThat(buffer.position()).isZero();
assertThat(buffer.remaining()).isEqualTo(4);
}
// --- onNext: what a failed check does ------------------------------------
@Test
void forwardsBuffersWhileWithinBounds() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
List<ByteBuffer> buffers = List.of(buffer("aaaa\n"), buffer("bbbb\n"));
subscriber.onNext(buffers);
assertThat(this.delegate.received).containsExactly(buffers);
assertThat(this.delegate.error).isNull();
assertThat(this.subscription.cancellations).isZero();
}
@Test
void abortsTheResponseWhenTheLineBoundIsExceeded() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
assertThat(this.subscription.cancellations).isEqualTo(1);
assertThat(this.delegate.received).isEmpty();
assertThat(this.delegate.error).isInstanceOf(McpTransportException.class)
.hasMessage("Inbound line exceeds the maximum allowed size of " + MAX_SIZE + " bytes");
}
@Test
void abortsTheResponseWhenTheTotalBoundIsExceeded() {
BoundedTotalBodySubscriber<Void> subscriber = totalSubscriber();
subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
assertThat(this.subscription.cancellations).isEqualTo(1);
assertThat(this.delegate.received).isEmpty();
assertThat(this.delegate.error).isInstanceOf(McpTransportException.class)
.hasMessage("Inbound response body exceeds the maximum allowed size of " + MAX_SIZE + " bytes");
}
@Test
void withholdsTheWholeListWhenALaterBufferExceedsTheBound() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
subscriber.onNext(List.of(buffer("a".repeat(8)), buffer("a".repeat(9))));
assertThat(this.delegate.received).isEmpty();
assertThat(this.delegate.error).isInstanceOf(McpTransportException.class);
}
@Test
void ignoresFurtherSignalsOnceAborted() {
BoundedLineBodySubscriber subscriber = lineSubscriber();
subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
Throwable firstError = this.delegate.error;
// The HTTP client may still signal after the subscription is cancelled.
subscriber.onNext(List.of(buffer("aaaa")));
subscriber.onError(new RuntimeException("late failure"));
subscriber.onComplete();
assertThat(this.subscription.cancellations).isEqualTo(1);
assertThat(this.delegate.received).isEmpty();
assertThat(this.delegate.error).isSameAs(firstError);
assertThat(this.delegate.completed).isFalse();
}
// --- fixtures ------------------------------------------------------------
private BoundedLineBodySubscriber lineSubscriber() {
BoundedLineBodySubscriber subscriber = new BoundedLineBodySubscriber(this.delegate, MAX_SIZE);
subscriber.onSubscribe(this.subscription);
return subscriber;
}
private BoundedTotalBodySubscriber<Void> totalSubscriber() {
BoundedTotalBodySubscriber<Void> subscriber = new BoundedTotalBodySubscriber<>(this.delegate, MAX_SIZE);
subscriber.onSubscribe(this.subscription);
return subscriber;
}
private static ByteBuffer buffer(String content) {
return ByteBuffer.wrap(content.getBytes(StandardCharsets.US_ASCII));
}
private static final class RecordingBodySubscriber implements BodySubscriber<Void> {
private final List<List<ByteBuffer>> received = new ArrayList<>();
private final CompletableFuture<Void> body = new CompletableFuture<>();
private Throwable error;
private boolean completed;
@Override
public CompletionStage<Void> getBody() {
return this.body;
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
}
@Override
public void onNext(List<ByteBuffer> item) {
this.received.add(item);
}
@Override
public void onError(Throwable throwable) {
this.error = throwable;
this.body.completeExceptionally(throwable);
}
@Override
public void onComplete() {
this.completed = true;
this.body.complete(null);
}
}
private static final class RecordingSubscription implements Flow.Subscription {
private int cancellations;
@Override
public void request(long n) {
}
@Override
public void cancel() {
this.cancellations++;
}
}
}