From 2bb95bcd697cac8f2bd5593b0a4b055299a643d6 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 8 Jul 2026 11:48:22 +0200 Subject: [PATCH] Bound HTTP client reads Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientSseClientTransport.java | 60 +++- .../HttpClientStreamableHttpTransport.java | 50 ++- .../client/transport/ResponseSubscribers.java | 314 +++++++++++++++- .../transport/BoundedBodySubscriberTests.java | 334 ++++++++++++++++++ .../HttpClientBoundedReadTestSupport.java | 123 +++++++ ...entSseClientTransportBoundedReadTests.java | 108 ++++++ .../HttpClientSseClientTransportTests.java | 2 +- ...reamableHttpTransportBoundedReadTests.java | 111 ++++++ ...leHttpTransportDeleteBoundedReadTests.java | 126 +++++++ 9 files changed, 1199 insertions(+), 29 deletions(-) create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index 786b2ec..d4b0bbb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -82,6 +82,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; @@ -122,6 +127,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. @@ -133,11 +144,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"); @@ -145,6 +158,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; @@ -152,6 +166,7 @@ public class HttpClientSseClientTransport implements McpClientTransport { this.requestBuilder = requestBuilder; this.httpRequestCustomizer = httpRequestCustomizer; this.messageEndpointValidator = messageEndpointValidator; + this.maxResponseSize = maxResponseSize; } @Override @@ -189,6 +204,8 @@ public class HttpClientSseClientTransport implements McpClientTransport { private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator(); + private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE; + /** * Creates a new builder instance. */ @@ -338,6 +355,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. + * + *

+ * 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 @@ -346,7 +383,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); } } @@ -365,13 +402,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.create(sseSink -> this.httpClient - .sendAsync(requestBuilder.build(), - responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink)) - .exceptionallyCompose(e -> { - sseSink.error(e); - return CompletableFuture.failedFuture(e); - })) + Disposable connection = Flux.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) { @@ -502,7 +541,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))); }); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index 0854ac1..7f1d00f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -8,7 +8,6 @@ import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandler; import java.time.Duration; import java.util.Collections; @@ -85,6 +84,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 @@ -136,10 +140,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, - McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List supportedProtocolVersions) { + McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List supportedProtocolVersions, + int maxResponseSize) { + Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive"); this.jsonMapper = jsonMapper; this.httpClient = httpClient; this.requestBuilder = requestBuilder; @@ -155,6 +167,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { .sorted(Comparator.reverseOrder()) .findFirst() .get(); + this.maxResponseSize = maxResponseSize; } @Override @@ -211,7 +224,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(); } @@ -434,16 +448,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; @@ -701,6 +715,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.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 @@ -891,6 +907,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. + * + *

+ * 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. @@ -900,7 +936,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); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java index 29dc23c..b19904d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java @@ -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 sseToBodySubscriber(ResponseInfo responseInfo, FluxSink 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 sseToBodySubscriber(ResponseInfo responseInfo, FluxSink sink, + int maxSize) { + BodySubscriber lineSubscriber = HttpResponse.BodySubscribers + .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink, maxSize))); + return new BoundedLineBodySubscriber(lineSubscriber, plusFramingOverhead(maxSize)); } - static BodySubscriber aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink 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 bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink 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 aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink, + int maxSize) { + BodySubscriber 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 bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink, + int maxSize) { + BodySubscriber 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. + * + *

+ * 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 boundedStringBodyHandler(int maxSize) { + BodyHandler delegate = HttpResponse.BodyHandlers.ofString(); + return responseInfo -> new BoundedTotalBodySubscriber<>(delegate.apply(responseInfo), maxSize); } static class SseLineSubscriber extends BaseSubscriber { @@ -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 sink) { + public SseLineSubscriber(ResponseInfo responseInfo, FluxSink 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 sink) { + public AggregateSubscriber(ResponseInfo responseInfo, FluxSink 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. + * + *

+ * 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 implements BodySubscriber { + + private final BodySubscriber 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 delegate, int maxSize, String boundedEntity) { + this.delegate = delegate; + this.maxSize = maxSize; + this.boundedEntity = boundedEntity; + } + + @Override + public CompletionStage getBody() { + return this.delegate.getBody(); + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + this.delegate.onSubscribe(subscription); + } + + @Override + public void onNext(List 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. + * + *

+ * {@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 { + + private long bytesSinceLineTerminator = 0; + + BoundedLineBodySubscriber(BodySubscriber 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 extends BoundedBodySubscriber { + + private long totalBytes = 0; + + BoundedTotalBodySubscriber(BodySubscriber delegate, int maxSize) { + super(delegate, maxSize, "Inbound response body"); + } + + @Override + protected boolean checkSize(ByteBuffer buffer) { + this.totalBytes += buffer.remaining(); + return this.totalBytes <= this.maxSize; + } + + } + } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java new file mode 100644 index 0000000..5f35031 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java @@ -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 subscriber = totalSubscriber(); + + assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue(); + assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue(); + } + + @Test + void totalSubscriberRejectsBodyOneByteOverMaxSize() { + BoundedTotalBodySubscriber subscriber = totalSubscriber(); + + assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue(); + assertThat(subscriber.checkSize(buffer("a".repeat(9)))).isFalse(); + } + + @Test + void totalSubscriberIsNotResetByLineTerminators() { + BoundedTotalBodySubscriber 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 subscriber = totalSubscriber(); + ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2)); + partiallyConsumed.position(MAX_SIZE); + + assertThat(subscriber.checkSize(partiallyConsumed)).isTrue(); + } + + @Test + void totalSubscriberDoesNotConsumeTheBuffer() { + BoundedTotalBodySubscriber 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 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 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 totalSubscriber() { + BoundedTotalBodySubscriber 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 { + + private final List> received = new ArrayList<>(); + + private final CompletableFuture body = new CompletableFuture<>(); + + private Throwable error; + + private boolean completed; + + @Override + public CompletionStage getBody() { + return this.body; + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + } + + @Override + public void onNext(List 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++; + } + + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java new file mode 100644 index 0000000..a22dc13 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java @@ -0,0 +1,123 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +/** + * Shared fixture for the transport bounded-read tests: a bare {@link HttpServer} whose + * response body is written by a per-test {@link Responder}. + * + * @author Daniel Garnier-Moiroux + */ +abstract class HttpClientBoundedReadTestSupport { + + protected static final int MAX_SIZE = 1024; + + private HttpServer server; + + protected String host; + + /** + * Writes a response body to the exchange. + */ + @FunctionalInterface + protected interface Responder { + + void respond(OutputStream body) throws IOException; + + } + + /** + * The path the transport under test talks to. + */ + protected abstract String endpoint(); + + @BeforeEach + void startServer() throws IOException { + int port = TomcatTestUtil.findAvailablePort(); + this.host = "http://localhost:" + port; + this.server = HttpServer.create(new InetSocketAddress(port), 0); + this.server.setExecutor(Executors.newCachedThreadPool()); + this.server.start(); + } + + @AfterEach + void stopServer() { + if (this.server != null) { + this.server.stop(0); + } + } + + /** + * Registers a handler that responds with the given content type and body. + */ + protected void respondWith(String path, String contentType, Responder responder) { + this.server.createContext(path, exchange -> { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, 0); + try (OutputStream body = exchange.getResponseBody()) { + responder.respond(body); + } + catch (IOException ignored) { + // The client aborts the response once the limit is exceeded, which closes + // the connection and makes further writes fail. That is the behaviour + // under test. + } + finally { + exchange.close(); + } + }); + } + + /** + * A responder that writes {@code chunks} blocks of {@code 'a'} with no line + * terminator anywhere, so nothing downstream can ever flush a line. + */ + protected static Responder unterminatedLine(int chunks) { + return body -> { + byte[] chunk = new byte[MAX_SIZE]; + java.util.Arrays.fill(chunk, (byte) 'a'); + for (int i = 0; i < chunks; i++) { + body.write(chunk); + body.flush(); + } + }; + } + + /** + * A responder that writes enough short, properly terminated lines to exceed the limit + * in aggregate. + */ + protected static Responder manyShortLines(String prefix) { + return body -> { + byte[] line = (prefix + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n").getBytes(StandardCharsets.UTF_8); + for (int i = 0; i < (MAX_SIZE / line.length) + 64; i++) { + body.write(line); + body.flush(); + } + }; + } + + protected static boolean messageContains(Throwable t, String expected) { + for (Throwable current = t; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(expected)) { + return true; + } + } + return false; + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java new file mode 100644 index 0000000..df61326 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Verifies that {@link HttpClientSseClientTransport} bounds the amount of memory a single + * inbound message can occupy, so a malicious or buggy server cannot exhaust the client's + * memory by streaming an unterminated line, an endless event, or an oversized response to + * a posted message. + * + * @author Daniel Garnier-Moiroux + */ +@Timeout(15) +class HttpClientSseClientTransportBoundedReadTests extends HttpClientBoundedReadTestSupport { + + private static final String MESSAGE_ENDPOINT = "/message"; + + private final CountDownLatch keepStreamOpen = new CountDownLatch(1); + + @Override + protected String endpoint() { + return "/sse"; + } + + @AfterEach + void releaseStream() { + this.keepStreamOpen.countDown(); + } + + @Test + void shouldRejectSingleLineExceedingMaxSize() { + // A line that never terminates, so the line buffer underneath the SSE parser + // would grow without limit before any event could be flushed. + respondWith(endpoint(), "text/event-stream", unterminatedLine(8)); + + StepVerifier.create(connect()) + .verifyErrorMatches(t -> messageContains(t, "Inbound line exceeds the maximum allowed size")); + } + + @Test + void shouldRejectEventExceedingMaxSize() { + // Many short, terminated "data:" lines with no blank line to end the event. Each + // line is small, but the accumulated event data would grow without limit. + respondWith(endpoint(), "text/event-stream", manyShortLines("data:")); + + StepVerifier.create(connect()) + .verifyErrorMatches(t -> messageContains(t, "Inbound SSE event exceeds the maximum allowed size")); + } + + @Test + void shouldRejectPostResponseExceedingMaxSize() throws Exception { + // The response to a posted message is read into a string in full, so an oversized + // one must abort rather than accumulate. + respondWith(endpoint(), "text/event-stream", body -> { + body.write(("event:endpoint\ndata:" + MESSAGE_ENDPOINT + "\n\n").getBytes(StandardCharsets.UTF_8)); + body.flush(); + awaitTeardown(); + }); + respondWith(MESSAGE_ENDPOINT, "application/json", unterminatedLine(64)); + + HttpClientSseClientTransport transport = transport(); + transport.connect(Function.identity()).block(Duration.ofSeconds(5)); + + StepVerifier.create(sendMessage(transport)) + .verifyErrorMatches(t -> messageContains(t, "Inbound response body exceeds the maximum allowed size")); + } + + private void awaitTeardown() { + try { + this.keepStreamOpen.await(10, TimeUnit.SECONDS); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + private HttpClientSseClientTransport transport() { + return HttpClientSseClientTransport.builder(this.host).maxResponseSize(MAX_SIZE).build(); + } + + private Mono connect() { + return transport().connect(Function.identity()); + } + + private Mono sendMessage(HttpClientSseClientTransport transport) { + JSONRPCRequest request = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + return transport.sendMessage(request); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index 15df079..c06b0aa 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -80,7 +80,7 @@ class HttpClientSseClientTransportTests { SseMessageEndpointValidator sseMessageEndpointValidator) { super(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(), HttpRequest.newBuilder().header("Content-Type", "application/json"), baseUri, "/sse", JSON_MAPPER, - McpAsyncHttpClientRequestCustomizer.NOOP, sseMessageEndpointValidator); + McpAsyncHttpClientRequestCustomizer.NOOP, sseMessageEndpointValidator, 16 * 1024 * 1024); } public int getInboundMessageCount() { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java new file mode 100644 index 0000000..856dc88 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.function.Function; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Verifies that {@link HttpClientStreamableHttpTransport} bounds the amount of memory a + * single inbound message can occupy, so a malicious or buggy server cannot exhaust the + * client's memory by streaming an unterminated line or an endless event. + * + * @author Daniel Garnier-Moiroux + */ +@Timeout(15) +class HttpClientStreamableHttpTransportBoundedReadTests extends HttpClientBoundedReadTestSupport { + + @Override + protected String endpoint() { + return "/mcp"; + } + + @Test + void shouldRejectSingleLineExceedingMaxSize() { + // A line that never terminates, so the line buffer underneath the SSE parser + // would grow without limit before any event could be flushed. + respondWith(endpoint(), "text/event-stream", unterminatedLine(8)); + + StepVerifier.create(sendMessage()) + .verifyErrorMatches(t -> messageContains(t, "Inbound line exceeds the maximum allowed size")); + } + + @Test + void shouldRejectEventExceedingMaxSize() { + // Many short, terminated "data:" lines with no blank line to end the event. Each + // line is small, but the accumulated event data would grow without limit. + respondWith(endpoint(), "text/event-stream", manyShortLines("data:")); + + StepVerifier.create(sendMessage()) + .verifyErrorMatches(t -> messageContains(t, "Inbound SSE event exceeds the maximum allowed size")); + } + + @Test + void shouldRejectJsonResponseExceedingMaxSize() { + // A multi-line application/json response whose total size exceeds the limit. Each + // line is small, but the aggregated body would grow without limit. + respondWith(endpoint(), "application/json", manyShortLines("")); + + StepVerifier.create(sendMessage()) + .verifyErrorMatches(t -> messageContains(t, "Inbound response body exceeds the maximum allowed size")); + } + + @Test + void shouldRejectDiscardedResponseExceedingMaxSize() { + // A content type the transport neither parses as SSE nor as JSON, so the body is + // discarded. The line subscriber underneath still buffers each line, so an + // unterminated one must abort the response rather than accumulate. + respondWith(endpoint(), "text/plain", unterminatedLine(8)); + + StepVerifier.create(sendMessage()) + .verifyErrorMatches(t -> messageContains(t, "Inbound line exceeds the maximum allowed size")); + } + + @Test + void shouldAcceptEventOfExactlyMaxSize() { + // The bound is inclusive and the SSE framing around the payload is given its own + // headroom, so a message of exactly maxResponseSize must still be delivered. + respondWith(endpoint(), "text/event-stream", body -> body + .write(("data:" + jsonRpcResponseOfExactly(MAX_SIZE) + "\n\n").getBytes(StandardCharsets.UTF_8))); + + StepVerifier.create(sendMessage()).verifyComplete(); + } + + @Test + void shouldAcceptJsonResponseOfExactlyMaxSize() { + // Same inclusive bound on the aggregated body. + respondWith(endpoint(), "application/json", + body -> body.write(jsonRpcResponseOfExactly(MAX_SIZE).getBytes(StandardCharsets.UTF_8))); + + StepVerifier.create(sendMessage()).verifyComplete(); + } + + /** + * A single-line JSON-RPC response padded to exactly {@code size} bytes. + */ + private static String jsonRpcResponseOfExactly(int size) { + String prefix = "{\"jsonrpc\":\"2.0\",\"id\":\"test-id\",\"result\":{\"pad\":\""; + String suffix = "\"}}"; + return prefix + "a".repeat(size - prefix.length() - suffix.length()) + suffix; + } + + private Mono sendMessage() { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(this.host) + .maxResponseSize(MAX_SIZE) + .build(); + JSONRPCRequest request = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + return transport.connect(Function.identity()).then(transport.sendMessage(request)); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java new file mode 100644 index 0000000..29a18a6 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; +import io.modelcontextprotocol.spec.McpTransportException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies that {@link HttpClientStreamableHttpTransport} bounds the response to the + * session-terminating DELETE, so a server cannot exhaust the client's memory on the way + * out. Checks both that the failure surfaces to the caller and that the client actually + * stops reading rather than draining whatever the server sends. + * + * @author Daniel Garnier-Moiroux + */ +@Timeout(30) +class HttpClientStreamableHttpTransportDeleteBoundedReadTests { + + private static final int MAX_SIZE = 1024; + + private static final int FLOOD_BYTES = 32 * 1024 * 1024; + + private HttpServer server; + + private String host; + + private final AtomicLong deleteBytesWritten = new AtomicLong(); + + private final CountDownLatch deleteFinished = new CountDownLatch(1); + + @BeforeEach + void setUp() throws IOException { + int port = TomcatTestUtil.findAvailablePort(); + this.host = "http://localhost:" + port; + this.server = HttpServer.create(new InetSocketAddress(port), 0); + this.server.createContext("/mcp", exchange -> { + if ("DELETE".equals(exchange.getRequestMethod())) { + floodDeleteResponse(exchange); + return; + } + // Hand out a session id so the transport issues a DELETE on close. + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.getResponseHeaders().set("Mcp-Session-Id", "test-session"); + byte[] body = "{\"jsonrpc\":\"2.0\",\"id\":\"test-id\",\"result\":{}}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + this.server.setExecutor(Executors.newCachedThreadPool()); + this.server.start(); + } + + private void floodDeleteResponse(com.sun.net.httpserver.HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + try (OutputStream body = exchange.getResponseBody()) { + byte[] chunk = new byte[64 * 1024]; + java.util.Arrays.fill(chunk, (byte) 'a'); // no line terminator, ever + while (this.deleteBytesWritten.get() < FLOOD_BYTES) { + body.write(chunk); + body.flush(); + this.deleteBytesWritten.addAndGet(chunk.length); + } + } + catch (IOException ignored) { + // Expected: the client aborts the response once the limit is exceeded. + } + finally { + exchange.close(); + this.deleteFinished.countDown(); + } + } + + @AfterEach + void tearDown() { + if (this.server != null) { + this.server.stop(0); + } + } + + @Test + void shouldStopReadingDeleteResponseOnceLimitExceeded() throws Exception { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(this.host) + .maxResponseSize(MAX_SIZE) + .build(); + transport.connect(m -> m).block(Duration.ofSeconds(5)); + JSONRPCRequest request = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + transport.sendMessage(request).block(Duration.ofSeconds(5)); + + assertThatThrownBy(() -> transport.closeGracefully().block(Duration.ofSeconds(10))) + .isInstanceOf(McpTransportException.class) + .hasMessageContaining("Inbound response body exceeds the maximum allowed size"); + + assertThat(this.deleteFinished.await(15, TimeUnit.SECONDS)) + .as("the server's DELETE response should be cut short by the client") + .isTrue(); + assertThat(this.deleteBytesWritten.get()).as("bytes the client let the server stream on DELETE") + .isLessThan(FLOOD_BYTES); + } + +}