(), (accumulated, result) -> {
+ accumulated.addAll(result.prompts());
+ return accumulated;
+ }).map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
}
/**
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 874da905e..3e4b613ff 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
@@ -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 static class Builder {
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
+ private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
+
/**
* Creates a new builder instance.
*/
@@ -326,6 +343,26 @@ public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpo
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
@@ -334,7 +371,7 @@ public HttpClientSseClientTransport build() {
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 Mono connect(Function, Mono> h
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) {
@@ -490,7 +529,8 @@ private Mono> sendHttpPost(final String endpoint, final Str
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 48462c0db..07a8f5e23 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
@@ -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
@@ -114,6 +119,29 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
public static int BAD_REQUEST = 400;
+ /**
+ * Determines whether an SSE event should be treated as a "message" event carrying a
+ * JSON-RPC payload.
+ *
+ *
+ * Per the
+ * SSE specification (WHATWG HTML Living Standard §9.2.6), an event with no
+ * explicit {@code event:} field MUST be dispatched as a {@code message} event by
+ * default. This method applies that rule by treating {@code null} or empty event
+ * names as equivalent to {@link #MESSAGE_EVENT_TYPE}.
+ *
+ *
+ * This alignment ensures interoperability with MCP servers that emit bare
+ * {@code data:} frames without an accompanying {@code event:} line, which are valid
+ * per the SSE spec.
+ * @param eventName the SSE event name, which may be {@code null} or empty
+ * @return {@code true} if the event should be parsed as a JSON-RPC message
+ */
+ static boolean isMessageEvent(String eventName) {
+ return eventName == null || eventName.isEmpty() || MESSAGE_EVENT_TYPE.equals(eventName);
+ }
+
private final McpJsonMapper jsonMapper;
private final URI baseUri;
@@ -138,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 supportedProtocolVersions) {
+ List supportedProtocolVersions, int maxResponseSize) {
+ Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.jsonMapper = jsonMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
@@ -158,6 +193,7 @@ private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient h
.sorted(Comparator.reverseOrder())
.findFirst()
.get();
+ this.maxResponseSize = maxResponseSize;
}
@Override
@@ -206,7 +242,8 @@ private Publisher createDelete(String sessionId) {
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();
}
@@ -323,7 +360,7 @@ else if (statusCode == METHOD_NOT_ALLOWED) {
+ statusCode));
}
else if (statusCode >= 200 && statusCode < 300) {
- if (MESSAGE_EVENT_TYPE.equals(sseResponseEvent.sseEvent().event())) {
+ if (isMessageEvent(sseResponseEvent.sseEvent().event())) {
String data = sseResponseEvent.sseEvent().data();
// Per 2025-11-25 spec (SEP-1699), servers may
// send SSE events
@@ -447,16 +484,16 @@ private BodyHandler toSendMessageBodySubscriber(FluxSink si
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;
@@ -658,6 +695,10 @@ else if (statusCode == BAD_REQUEST) {
return Flux.error(new McpTransportException(
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
}
+ else if (statusCode >= 400 && statusCode < 500) {
+ return Flux.error(
+ new McpTransportException("Invalid request. Status code: " + statusCode));
+ }
return Flux.error(
new RuntimeException("Failed to send message: " + responseEvent));
@@ -727,6 +768,8 @@ public static class Builder {
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
@@ -925,6 +968,26 @@ public Builder supportedProtocolVersions(List supportedProtocolVersions)
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.
@@ -934,7 +997,7 @@ public HttpClientStreamableHttpTransport build() {
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/MaxSizeExceededException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/MaxSizeExceededException.java
new file mode 100644
index 000000000..807034057
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/MaxSizeExceededException.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+/**
+ * Thrown when reading an inbound message would exceed the configured maximum size.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class MaxSizeExceededException extends Exception {
+
+ MaxSizeExceededException(String message) {
+ super(message);
+ }
+
+}
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 29dc23c35..b19904de6 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 @@
*
* @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 @@ record SseResponseEvent(ResponseInfo responseInfo, SseEvent sseEvent) implements
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));
+ }
+
+ /**
+ * 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 aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
- .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink)));
+ /**
+ * 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);
}
- static BodySubscriber bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
+ /**
+ * 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 @@ static class SseLineSubscriber extends BaseSubscriber {
*/
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 @@ protected void hookOnNext(String line) {
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 @@ static class AggregateSubscriber extends BaseSubscriber {
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 @@ protected void hookOnSubscribe(Subscription subscription) {
@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 @@ protected void hookOnError(Throwable throwable) {
}
+ /**
+ * 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/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
index e73e43ef5..73522ae6d 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
@@ -44,6 +44,8 @@ public class StdioClientTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(StdioClientTransport.class);
+ private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; // 16MB
+
// @formatter:off
private static final Set EXIT_SUCCESS_CODES = Set.of(
0, // success
@@ -76,6 +78,8 @@ public class StdioClientTransport implements McpClientTransport {
private final Sinks.Many errorSink;
+ private final int inputMaxSize;
+
private volatile boolean isClosing = false;
// visible for tests
@@ -87,8 +91,21 @@ public class StdioClientTransport implements McpClientTransport {
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
*/
public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper) {
+ this(params, jsonMapper, DEFAULT_INPUT_MAX_SIZE);
+ }
+
+ /**
+ * Creates a new StdioClientTransport with the specified parameters and JsonMapper.
+ * @param params The parameters for configuring the server process
+ * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
+ * @param inputMaxSize The maximum number of characters read for a single inbound
+ * message. A peer that sends a longer message (or never terminates a line) has its
+ * message rejected instead of forcing the transport to buffer it in memory.
+ */
+ public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper, int inputMaxSize) {
Assert.notNull(params, "The params can not be null");
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
+ Assert.isTrue(inputMaxSize > 0, "inputMaxSize must be positive");
this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
@@ -97,6 +114,8 @@ public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
+ this.inputMaxSize = inputMaxSize;
+
this.errorSink = Sinks.many().unicast().onBackpressureBuffer();
// Start threads
@@ -260,7 +279,7 @@ private void startInboundProcessing() {
this.inboundScheduler.schedule(() -> {
try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
- while (!isClosing && (line = processReader.readLine()) != null) {
+ while (!isClosing && (line = readLine(processReader, inputMaxSize)) != null) {
try {
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, line);
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
@@ -278,6 +297,11 @@ private void startInboundProcessing() {
}
}
}
+ catch (MaxSizeExceededException e) {
+ if (!isClosing) {
+ logger.error("Inbound message exceeds the maximum allowed size", e);
+ }
+ }
catch (IOException e) {
if (!isClosing) {
logger.error("Error reading from input stream", e);
@@ -290,6 +314,37 @@ private void startInboundProcessing() {
});
}
+ /**
+ * Reads a single line, mirroring {@link BufferedReader#readLine()}, but aborting once
+ * more than {@code maxSize} characters have been read without encountering a line
+ * terminator. This bounds how much memory a single inbound message can occupy.
+ */
+ static String readLine(BufferedReader reader, int maxSize) throws IOException, MaxSizeExceededException {
+ StringBuilder sb = new StringBuilder();
+ int c;
+ while ((c = reader.read()) != -1) {
+ if (c == '\n') {
+ return sb.toString();
+ }
+ if (c == '\r') {
+ // Consume an optional trailing '\n' so that "\r\n" is treated as a
+ // single terminator, mirroring BufferedReader#readLine().
+ reader.mark(1);
+ int next = reader.read();
+ if (next != '\n' && next != -1) {
+ reader.reset();
+ }
+ return sb.toString();
+ }
+ if (sb.length() >= maxSize) {
+ throw new MaxSizeExceededException(
+ "Inbound message exceeds the maximum allowed size of " + maxSize + " characters");
+ }
+ sb.append((char) c);
+ }
+ return sb.isEmpty() ? null : sb.toString();
+ }
+
/**
* Starts the outbound processing thread that writes JSON-RPC messages to the
* process's output stream. Messages are serialized to JSON and written with a newline
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java
index cde637b15..322aa3a07 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java
@@ -20,7 +20,7 @@ class DefaultMcpTransportContext implements McpTransportContext {
DefaultMcpTransportContext(Map metadata) {
Assert.notNull(metadata, "The metadata cannot be null");
- this.metadata = metadata;
+ this.metadata = Map.copyOf(metadata);
}
@Override
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java
index def40d58d..5cd5de7ad 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java
@@ -32,9 +32,9 @@ public Mono handleRequest(McpTransportContext transpo
McpSchema.JSONRPCRequest request) {
McpStatelessRequestHandler> requestHandler = this.requestHandlers.get(request.method());
if (requestHandler == null) {
- return Mono.error(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
- .message("Missing handler for request type: " + request.method())
- .build());
+ return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
+ new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
+ "Method not found: " + request.method(), null)));
}
return requestHandler.handle(transportContext, request.params())
.map(result -> McpSchema.JSONRPCResponse.result(request.id(), result))
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
index 83aa8f5ba..ac78c4ff0 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
@@ -1090,9 +1090,7 @@ private McpRequestHandler completionCompleteRequestHan
McpServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref());
if (specification == null) {
- return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS)
- .message("AsyncCompletionSpecification not found: " + request.ref())
- .build());
+ return EMPTY_COMPLETION_RESULT;
}
return Mono.defer(() -> specification.completionHandler().apply(exchange, request));
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
index c6105267d..42112334e 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
@@ -133,10 +133,26 @@ public class McpStatelessAsyncServer {
this.protocolVersions = new ArrayList<>(mcpTransport.protocolVersions());
- McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, Map.of());
+ Map notificationHandlers = prepareNotificationHandlers();
+ McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, notificationHandlers);
mcpTransport.setMcpHandler(handler);
}
+ private Map prepareNotificationHandlers() {
+ Map notificationHandlers = new HashMap<>();
+
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (exchange, params) -> {
+ logger.debug("Received {}", McpSchema.METHOD_NOTIFICATION_INITIALIZED);
+ return Mono.empty();
+ });
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED, (exchange, params) -> {
+ logger.debug("Received {}", McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED);
+ return Mono.empty();
+ });
+
+ return notificationHandlers;
+ }
+
// ---------------------------------------
// Lifecycle Management
// ---------------------------------------
@@ -815,9 +831,7 @@ private McpStatelessRequestHandler completionCompleteR
McpStatelessServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref());
if (specification == null) {
- return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS)
- .message("AsyncCompletionSpecification not found: " + request.ref())
- .build());
+ return EMPTY_COMPLETION_RESULT;
}
return specification.completionHandler().apply(ctx, request);
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
index 32246948c..721bdf195 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
@@ -4,6 +4,10 @@
package io.modelcontextprotocol.server.transport;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
@@ -37,4 +41,32 @@ static Map> extractHeaders(HttpServletRequest request) {
return headers;
}
+ /**
+ * Reads the request body, decoded using the request's character encoding (or UTF-8 if
+ * not specified), while bounding the number of bytes read.
+ * @param request The HTTP servlet request
+ * @param maxSize The maximum number of bytes to read from the request body
+ * @return The decoded request body
+ * @throws MaxSizeExceededException If the body exceeds {@code maxSize}
+ * @throws IOException If an I/O error occurs while reading the request body
+ */
+ static String readBody(HttpServletRequest request, int maxSize) throws MaxSizeExceededException, IOException {
+ InputStream inputStream = request.getInputStream();
+ ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int totalBytes = 0;
+ int readBytes;
+ while ((readBytes = inputStream.read(buf, 0, buf.length)) != -1) {
+ totalBytes += readBytes;
+ if (totalBytes > maxSize) {
+ throw new MaxSizeExceededException(
+ "Request body exceeds the maximum allowed size of " + maxSize + " bytes");
+ }
+ bodyBytes.write(buf, 0, readBytes);
+ }
+ String charset = request.getCharacterEncoding() != null ? request.getCharacterEncoding()
+ : StandardCharsets.UTF_8.name();
+ return bodyBytes.toString(charset);
+ }
+
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
index 69d73f7ab..05dd862e9 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
@@ -4,7 +4,6 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
@@ -76,6 +75,11 @@
@WebServlet(asyncSupported = true)
public class HttpServletSseServerTransportProvider extends HttpServlet implements McpServerTransportProvider {
+ /**
+ * Default maximum size of a single request body: 16 MiB (16 * 1024 * 1024 bytes).
+ */
+ private static final int DEFAULT_REQUEST_MAX_SIZE = 16 * 1024 * 1024;
+
/**
* Logger for this class
*/
@@ -111,6 +115,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
*/
private final McpJsonMapper jsonMapper;
+ /**
+ * Maximum size, in bytes, of a single request body accepted by this transport.
+ */
+ private final int requestMaxSize;
+
/**
* Base URL for the server transport
*/
@@ -166,17 +175,20 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* keep-alive functionality
* @param contextExtractor The extractor for transport context from the request.
* @param securityValidator The security validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
*/
private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor contextExtractor,
- ServerTransportSecurityValidator securityValidator) {
+ ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(messageEndpoint, "messageEndpoint must not be null");
Assert.notNull(sseEndpoint, "sseEndpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.baseUrl = baseUrl;
@@ -184,6 +196,7 @@ private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String b
this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
+ this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
@@ -346,6 +359,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
return;
}
+ if (request.getContentLengthLong() > this.requestMaxSize) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ return;
+ }
+
String requestURI = request.getRequestURI();
if (!requestURI.endsWith(messageEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
@@ -392,15 +410,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
}
try {
- BufferedReader reader = request.getReader();
- StringBuilder body = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- body.append(line);
- }
+ String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
final McpTransportContext transportContext = this.contextExtractor.extract(request);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
// Process the message through the session's handle method
// Block for Servlet compatibility
@@ -408,6 +421,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
response.setStatus(HttpServletResponse.SC_OK);
}
+ catch (MaxSizeExceededException e) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
catch (Exception e) {
logger.error("Error processing message: {}", e.getMessage());
try {
@@ -605,6 +621,8 @@ public static class Builder {
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
/**
* Sets the JsonMapper implementation to use for serialization/deserialization. If
* not specified, a JacksonJsonMapper will be created from the configured
@@ -691,6 +709,19 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida
return this;
}
+ /**
+ * Sets the maximum size, in bytes, of a single request body accepted by this
+ * transport. Requests whose body exceeds this size are rejected with a 413
+ * (Payload Too Large) response. Defaults to 16 MiB if not set.
+ * @param requestMaxSize The maximum request body size, in bytes. Must be
+ * positive.
+ * @return This builder instance
+ */
+ public Builder maxRequestSize(int requestMaxSize) {
+ this.requestMaxSize = requestMaxSize;
+ return this;
+ }
+
/**
* Builds a new instance of HttpServletSseServerTransportProvider with the
* configured settings.
@@ -703,7 +734,7 @@ public HttpServletSseServerTransportProvider build() {
}
return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint,
- sseEndpoint, keepAliveInterval, contextExtractor, securityValidator);
+ sseEndpoint, keepAliveInterval, contextExtractor, securityValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
index 047aeebe8..54f0ac030 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
@@ -4,7 +4,6 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
@@ -39,6 +38,11 @@
@WebServlet(asyncSupported = true)
public class HttpServletStatelessServerTransport extends HttpServlet implements McpStatelessServerTransport {
+ /**
+ * Default maximum size of a single request body: 16 MiB (16 * 1024 * 1024 bytes).
+ */
+ private static final int DEFAULT_REQUEST_MAX_SIZE = 16 * 1024 * 1024;
+
private static final Logger logger = LoggerFactory.getLogger(HttpServletStatelessServerTransport.class);
public static final String UTF_8 = "UTF-8";
@@ -66,18 +70,37 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
*/
private final ServerTransportSecurityValidator securityValidator;
+ /**
+ * Maximum size, in bytes, of a single request body accepted by this transport.
+ */
+ private final int requestMaxSize;
+
+ /**
+ * Constructs a new HttpServletStatelessServerTransport instance.
+ * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of
+ * messages.
+ * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
+ * messages.
+ * @param contextExtractor The extractor for transport context from the request.
+ * @param securityValidator The security validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
+ * @throws IllegalArgumentException if any parameter is null
+ */
private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor contextExtractor,
- ServerTransportSecurityValidator securityValidator) {
+ ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
+ this.requestMaxSize = requestMaxSize;
}
@Override
@@ -133,6 +156,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
return;
}
+ if (request.getContentLengthLong() > this.requestMaxSize) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ return;
+ }
+
try {
Map> headers = HttpServletRequestUtils.extractHeaders(request);
this.securityValidator.validateHeaders(headers);
@@ -154,14 +182,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
}
try {
- BufferedReader reader = request.getReader();
- StringBuilder body = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- body.append(line);
- }
+ String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
@@ -209,6 +232,9 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
.build());
}
}
+ catch (MaxSizeExceededException e) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
@@ -276,6 +302,8 @@ public static class Builder {
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
private Builder() {
// used by a static method
}
@@ -333,6 +361,19 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida
return this;
}
+ /**
+ * Sets the maximum size, in bytes, of a single request body accepted by this
+ * transport. Requests whose body exceeds this size are rejected with a 413
+ * (Payload Too Large) response. Defaults to 16 MiB if not set.
+ * @param requestMaxSize The maximum request body size, in bytes. Must be
+ * positive.
+ * @return this builder instance
+ */
+ public Builder maxRequestSize(int requestMaxSize) {
+ this.requestMaxSize = requestMaxSize;
+ return this;
+ }
+
/**
* Builds a new instance of {@link HttpServletStatelessServerTransport} with the
* configured settings.
@@ -343,7 +384,7 @@ public HttpServletStatelessServerTransport build() {
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
- securityValidator);
+ securityValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
index e6af4fd0f..324a2ecd3 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
@@ -4,7 +4,6 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
@@ -14,12 +13,10 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import io.modelcontextprotocol.json.TypeRef;
-
import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpError;
@@ -28,8 +25,6 @@
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
import io.modelcontextprotocol.util.Assert;
-import io.modelcontextprotocol.json.McpJsonDefaults;
-import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.util.KeepAliveScheduler;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.ServletException;
@@ -37,6 +32,8 @@
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -62,6 +59,11 @@
public class HttpServletStreamableServerTransportProvider extends HttpServlet
implements McpStreamableServerTransportProvider {
+ /**
+ * Default maximum size of a single request body: 16 MiB (16 * 1024 * 1024 bytes).
+ */
+ private static final int DEFAULT_REQUEST_MAX_SIZE = 16 * 1024 * 1024;
+
private static final Logger logger = LoggerFactory.getLogger(HttpServletStreamableServerTransportProvider.class);
/**
@@ -100,6 +102,11 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private final McpJsonMapper jsonMapper;
+ /**
+ * Maximum size, in bytes, of a single request body accepted by this transport.
+ */
+ private final int requestMaxSize;
+
private McpStreamableServerSession.Factory sessionFactory;
/**
@@ -136,21 +143,25 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive
* will be scheduled.
* @param securityValidator The security validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor contextExtractor,
- Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
+ Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
+ this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
@@ -397,6 +408,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down");
return;
}
+ if (request.getContentLengthLong() > this.requestMaxSize) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ return;
+ }
try {
Map> headers = HttpServletRequestUtils.extractHeaders(request);
@@ -420,14 +435,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
McpTransportContext transportContext = this.contextExtractor.extract(request);
try {
- BufferedReader reader = request.getReader();
- StringBuilder body = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- body.append(line);
- }
+ String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
// Handle initialization request
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
@@ -535,6 +545,9 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Unknown message type").build());
}
}
+ catch (MaxSizeExceededException e) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
@@ -830,6 +843,8 @@ public static class Builder {
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
/**
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
* messages.
@@ -901,6 +916,19 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida
return this;
}
+ /**
+ * Sets the maximum size, in bytes, of a single request body accepted by this
+ * transport. Requests whose body exceeds this size are rejected with a 413
+ * (Payload Too Large) response. Defaults to 16 MiB if not set.
+ * @param requestMaxSize The maximum request body size, in bytes. Must be
+ * positive.
+ * @return this builder instance
+ */
+ public Builder maxRequestSize(int requestMaxSize) {
+ this.requestMaxSize = requestMaxSize;
+ return this;
+ }
+
/**
* Builds a new instance of {@link HttpServletStreamableServerTransportProvider}
* with the configured settings.
@@ -911,7 +939,7 @@ public HttpServletStreamableServerTransportProvider build() {
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete,
- contextExtractor, keepAliveInterval, securityValidator);
+ contextExtractor, keepAliveInterval, securityValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java
new file mode 100644
index 000000000..ddca5dbdc
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+/**
+ * Thrown when reading an inbound message would exceed the configured maximum size.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class MaxSizeExceededException extends Exception {
+
+ MaxSizeExceededException(String message) {
+ super(message);
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
index 045d7e3a9..cc5123336 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
@@ -41,6 +41,8 @@
*/
public class StdioServerTransportProvider implements McpServerTransportProvider {
+ private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; // 16MB
+
private static final Logger logger = LoggerFactory.getLogger(StdioServerTransportProvider.class);
private final McpJsonMapper jsonMapper;
@@ -49,6 +51,8 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
private final OutputStream outputStream;
+ private final int inputMaxSize;
+
private McpServerSession session;
private final AtomicBoolean isClosing = new AtomicBoolean(false);
@@ -72,13 +76,29 @@ public StdioServerTransportProvider(McpJsonMapper jsonMapper) {
* @param outputStream The output stream to write to
*/
public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream) {
+ this(jsonMapper, inputStream, outputStream, DEFAULT_INPUT_MAX_SIZE);
+ }
+
+ /**
+ * Creates a new StdioServerTransportProvider.
+ * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
+ * @param inputStream The input stream to read from
+ * @param outputStream The output stream to write to
+ * @param inputMaxSize The maximum number of characters read for a single inbound
+ * message. A peer that sends a longer message (or never terminates a line) has its
+ * message rejected instead of forcing the transport to buffer it in memory.
+ */
+ public StdioServerTransportProvider(McpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream,
+ int inputMaxSize) {
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
Assert.notNull(inputStream, "The InputStream can not be null");
Assert.notNull(outputStream, "The OutputStream can not be null");
+ Assert.isTrue(inputMaxSize > 0, "inputMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.inputStream = inputStream;
this.outputStream = outputStream;
+ this.inputMaxSize = inputMaxSize;
}
@Override
@@ -211,7 +231,7 @@ private void startInboundProcessing() {
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
while (!isClosing.get()) {
try {
- String line = reader.readLine();
+ String line = readLine(reader, inputMaxSize);
if (line == null || isClosing.get()) {
break;
}
@@ -232,6 +252,10 @@ private void startInboundProcessing() {
break;
}
}
+ catch (MaxSizeExceededException e) {
+ logIfNotClosing("Inbound message exceeds the maximum allowed size", e);
+ break;
+ }
catch (IOException e) {
logIfNotClosing("Error reading from stdin", e);
break;
@@ -304,6 +328,36 @@ else if (isClosing.get()) {
outboundConsumer.apply(outboundSink.asFlux()).subscribe();
} // @formatter:on
+ /**
+ * Read line with a max size.
+ */
+ private static String readLine(BufferedReader reader, int maxSize)
+ throws IOException, MaxSizeExceededException {
+ StringBuilder sb = new StringBuilder();
+ int c;
+ while ((c = reader.read()) != -1) {
+ if (c == '\n') {
+ return sb.toString();
+ }
+ if (c == '\r') {
+ // Consume an optional trailing '\n' so that "\r\n" is treated as a
+ // single terminator, mirroring BufferedReader#readLine().
+ reader.mark(1);
+ int next = reader.read();
+ if (next != '\n' && next != -1) {
+ reader.reset();
+ }
+ return sb.toString();
+ }
+ if (sb.length() >= maxSize) {
+ throw new MaxSizeExceededException(
+ "Inbound message exceeds the maximum allowed size of " + maxSize + " characters");
+ }
+ sb.append((char) c);
+ }
+ return sb.isEmpty() ? null : sb.toString();
+ }
+
private void logIfNotClosing(String message, Exception e) {
if (!isClosing.get()) {
logger.error(message, e);
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
index 5bb5c3812..e7fac7b0d 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
@@ -214,9 +214,13 @@ public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
// (sink)
if (requestHandler == null) {
MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method());
- return transport.sendMessage(
- McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), new McpSchema.JSONRPCResponse.JSONRPCError(
- McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data())));
+ return transport
+ .sendMessage(
+ McpSchema.JSONRPCResponse
+ .error(jsonrpcRequest.id(),
+ new McpSchema.JSONRPCResponse.JSONRPCError(
+ McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data())))
+ .then(transport.closeGracefully());
}
return requestHandler
.handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(),
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 000000000..5f350319e
--- /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-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportSseEventTypeTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportSseEventTypeTest.java
new file mode 100644
index 000000000..d5f7196bd
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportSseEventTypeTest.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link HttpClientStreamableHttpTransport#isMessageEvent(String)}.
+ *
+ *
+ * Verifies that SSE event classification follows the
+ * WHATWG HTML Living Standard §9.2.6: an event without an explicit {@code event:}
+ * field must be dispatched as a {@code message} event.
+ *
+ * @author jiajingda
+ * @see #885
+ */
+class HttpClientStreamableHttpTransportSseEventTypeTest {
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ void shouldTreatNullOrEmptyEventAsMessage(String eventName) {
+ assertThat(HttpClientStreamableHttpTransport.isMessageEvent(eventName))
+ .as("SSE frame with null/empty event field must be treated as a 'message' event per SSE spec")
+ .isTrue();
+ }
+
+ @Test
+ void shouldTreatExplicitMessageEventAsMessage() {
+ assertThat(HttpClientStreamableHttpTransport.isMessageEvent("message"))
+ .as("Explicit 'message' event must be parsed as a JSON-RPC message")
+ .isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "ping", "error", "notification", "MESSAGE", "Message", "custom-event" })
+ void shouldNotTreatOtherEventsAsMessage(String eventName) {
+ assertThat(HttpClientStreamableHttpTransport.isMessageEvent(eventName))
+ .as("Non-'message' SSE event '%s' must not be parsed as a JSON-RPC message", eventName)
+ .isFalse();
+ }
+
+}
\ No newline at end of file
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java
new file mode 100644
index 000000000..a19b1015d
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.common;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link McpTransportContext#create(Map)}, which is documented to return an
+ * unmodifiable context.
+ */
+class McpTransportContextTests {
+
+ @Test
+ void createdContextShouldNotSeeLaterWritesToTheSourceMap() {
+ Map metadata = new HashMap<>();
+ metadata.put("tenant", "acme");
+
+ McpTransportContext context = McpTransportContext.create(metadata);
+ metadata.put("tenant", "other");
+
+ assertThat(context.get("tenant")).isEqualTo("acme");
+ }
+
+ @Test
+ void createdContextShouldNotSeeLaterAdditionsToTheSourceMap() {
+ Map metadata = new HashMap<>();
+ metadata.put("tenant", "acme");
+
+ McpTransportContext context = McpTransportContext.create(metadata);
+ metadata.put("added-after-the-fact", "surprise");
+
+ assertThat(context.get("added-after-the-fact")).isNull();
+ }
+
+ @Test
+ void createdContextShouldNotBeEmptiedByClearingTheSourceMap() {
+ Map metadata = new HashMap<>();
+ metadata.put("tenant", "acme");
+
+ McpTransportContext context = McpTransportContext.create(metadata);
+ metadata.clear();
+
+ assertThat(context.get("tenant")).isEqualTo("acme");
+ }
+
+ @Test
+ void createdContextShouldRemainUsableAsAMapKey() {
+ Map metadata = new HashMap<>();
+ metadata.put("tenant", "acme");
+ McpTransportContext context = McpTransportContext.create(metadata);
+
+ Map byContext = new HashMap<>();
+ byContext.put(context, "value");
+ metadata.put("tenant", "other");
+
+ assertThat(byContext.get(context)).isEqualTo("value");
+ }
+
+ @Test
+ void twoContextsCreatedFromEqualMapsShouldStayEqual() {
+ Map first = new HashMap<>();
+ first.put("tenant", "acme");
+ Map second = new HashMap<>();
+ second.put("tenant", "acme");
+
+ McpTransportContext firstContext = McpTransportContext.create(first);
+ McpTransportContext secondContext = McpTransportContext.create(second);
+ assertThat(firstContext).isEqualTo(secondContext);
+
+ first.put("tenant", "other");
+
+ assertThat(firstContext).isEqualTo(secondContext);
+ }
+
+ @Test
+ void createdContextFromAnImmutableMapIsAlreadyCorrect() {
+ McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme"));
+
+ assertThat(context.get("tenant")).isEqualTo("acme");
+ }
+
+}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java
new file mode 100644
index 000000000..267aca504
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DefaultMcpStatelessServerHandlerTests {
+
+ @Test
+ void testHandleRequestWithUnregisteredMethod() {
+ // no request/initialization handlers
+ DefaultMcpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(Collections.emptyMap(),
+ Collections.emptyMap());
+
+ // unregistered method
+ McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "resources/list",
+ "test-id-123", null);
+
+ StepVerifier.create(handler.handleRequest(McpTransportContext.EMPTY, request)).assertNext(response -> {
+ assertThat(response).isNotNull();
+ assertThat(response.jsonrpc()).isEqualTo(McpSchema.JSONRPC_VERSION);
+ assertThat(response.id()).isEqualTo("test-id-123");
+ assertThat(response.result()).isNull();
+
+ assertThat(response.error()).isNotNull();
+ assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND);
+ assertThat(response.error().message()).isEqualTo("Method not found: resources/list");
+ }).verifyComplete();
+ }
+
+}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java
new file mode 100644
index 000000000..48c997c04
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Daniel Garnier-Moiroux
+ */
+class HttpServletRequestUtilsTests {
+
+ @Test
+ void readsBodyWithinLimit() throws Exception {
+ HttpServletRequest request = requestWithBody("hello world", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEqualTo("hello world");
+ }
+
+ @Test
+ void readsEmptyBody() throws Exception {
+ HttpServletRequest request = requestWithBody("", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEmpty();
+ }
+
+ @Test
+ void allowsBodyExactlyAtLimit() throws Exception {
+ HttpServletRequest request = requestWithBody("12345", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 5);
+
+ assertThat(body).isEqualTo("12345");
+ }
+
+ @Test
+ void rejectsBodyLargerThanLimit() throws Exception {
+ HttpServletRequest request = requestWithBody("123456", null);
+
+ assertThatThrownBy(() -> HttpServletRequestUtils.readBody(request, 5))
+ .isInstanceOf(MaxSizeExceededException.class);
+ }
+
+ @Test
+ void rejectsBodySpanningMultipleReadBuffers() throws Exception {
+ // larger than the internal 8192-byte read buffer, to exercise multiple loop
+ // iterations before the limit is exceeded
+ String largeBody = "a".repeat(9000);
+ HttpServletRequest request = requestWithBody(largeBody, null);
+
+ assertThatThrownBy(() -> HttpServletRequestUtils.readBody(request, 8500))
+ .isInstanceOf(MaxSizeExceededException.class);
+ }
+
+ @Test
+ void readsBodySpanningMultipleReadBuffersWithinLimit() throws Exception {
+ String largeBody = "a".repeat(9000);
+ HttpServletRequest request = requestWithBody(largeBody, null);
+
+ String body = HttpServletRequestUtils.readBody(request, 9000);
+
+ assertThat(body).isEqualTo(largeBody);
+ }
+
+ @Test
+ void defaultsToUtf8WhenCharacterEncodingIsMissing() throws Exception {
+ HttpServletRequest request = requestWithBody("héllo wörld", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEqualTo("héllo wörld");
+ }
+
+ @Test
+ void honorsRequestCharacterEncoding() throws Exception {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ byte[] bytes = "café".getBytes(StandardCharsets.ISO_8859_1);
+ when(request.getInputStream()).thenReturn(servletInputStream(bytes));
+ when(request.getCharacterEncoding()).thenReturn("ISO-8859-1");
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEqualTo("café");
+ }
+
+ private static HttpServletRequest requestWithBody(String body, String characterEncoding) throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getInputStream()).thenReturn(servletInputStream(body.getBytes(StandardCharsets.UTF_8)));
+ when(request.getCharacterEncoding()).thenReturn(characterEncoding);
+ return request;
+ }
+
+ private static ServletInputStream servletInputStream(byte[] data) {
+ ByteArrayInputStream delegate = new ByteArrayInputStream(data);
+ return new ServletInputStream() {
+
+ @Override
+ public boolean isFinished() {
+ return delegate.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(ReadListener readListener) {
+ }
+
+ @Override
+ public int read() {
+ return delegate.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ return delegate.read(b, off, len);
+ }
+
+ };
+ }
+
+}
diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml
index f26715ae8..5a6d0d89e 100644
--- a/mcp-json-jackson2/pom.xml
+++ b/mcp-json-jackson2/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 2.0.0
+ 2.0.1
mcp-json-jackson2
jar
@@ -74,7 +74,7 @@
io.modelcontextprotocol.sdk
mcp-core
- 2.0.0
+ 2.0.1
com.networknt
diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml
index 6d38cd67c..5eded9e0e 100644
--- a/mcp-json-jackson3/pom.xml
+++ b/mcp-json-jackson3/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 2.0.0
+ 2.0.1
mcp-json-jackson3
jar
@@ -68,7 +68,7 @@
io.modelcontextprotocol.sdk
mcp-core
- 2.0.0
+ 2.0.1
tools.jackson.core
diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml
index c04ec7360..6574e8232 100644
--- a/mcp-test/pom.xml
+++ b/mcp-test/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 2.0.0
+ 2.0.1
mcp-test
jar
@@ -24,7 +24,7 @@
io.modelcontextprotocol.sdk
mcp-core
- 2.0.0
+ 2.0.1
@@ -159,7 +159,7 @@
io.modelcontextprotocol.sdk
mcp-json-jackson3
- 2.0.0
+ 2.0.1
test
@@ -170,7 +170,7 @@
io.modelcontextprotocol.sdk
mcp-json-jackson2
- 2.0.0
+ 2.0.1
test
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
index 969869176..f6b547aa1 100644
--- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
@@ -12,7 +12,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -50,11 +49,10 @@
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.MethodSource;
-import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
@@ -72,26 +70,22 @@
public abstract class AbstractMcpClientServerIntegrationTests {
- protected ConcurrentHashMap clientBuilders = new ConcurrentHashMap<>();
-
- abstract protected void prepareClients(int port, String mcpEndpoint);
+ protected static final int MAX_REQUEST_SIZE = 2048;
abstract protected McpServer.AsyncSpecification> prepareAsyncServerBuilder();
abstract protected McpServer.SyncSpecification> prepareSyncServerBuilder();
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void simple(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
+ abstract protected McpClient.SyncSpec getMcpClientBuilder();
+ @Test
+ void simple() {
var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
.requestTimeout(Duration.ofSeconds(1000))
.build();
try (
// Create client without sampling capabilities
- var client = clientBuilder
+ var client = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build())
.requestTimeout(Duration.ofSeconds(1000))
.build()) {
@@ -107,12 +101,8 @@ void simple(String clientType) {
// ---------------------------------------
// Sampling Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateMessageWithoutSamplingCapabilities(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateMessageWithoutSamplingCapabilities() {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
@@ -125,7 +115,7 @@ void testCreateMessageWithoutSamplingCapabilities(String clientType) {
try (
// Create client without sampling capabilities
- var client = clientBuilder
+ var client = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build())
.build()) {
@@ -144,12 +134,8 @@ void testCreateMessageWithoutSamplingCapabilities(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateMessageSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateMessageSuccess() {
Function samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
@@ -190,7 +176,7 @@ void testCreateMessageSuccess(String clientType) {
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
@@ -219,14 +205,8 @@ void testCreateMessageSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws InterruptedException {
-
- // Client
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateMessageWithRequestTimeoutSuccess() {
Function samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
@@ -276,7 +256,7 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr
.requestTimeout(Duration.ofSeconds(4))
.tools(tool)
.build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
@@ -305,12 +285,8 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateMessageWithRequestTimeoutFail(String clientType) throws InterruptedException {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateMessageWithRequestTimeoutFail() {
Function samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
@@ -355,7 +331,7 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt
.tools(tool)
.build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
@@ -376,12 +352,8 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt
// ---------------------------------------
// Elicitation Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationWithoutElicitationCapabilities(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationWithoutElicitationCapabilities() {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> exchange.createElicitation(mock(McpSchema.ElicitFormRequest.class))
@@ -391,7 +363,8 @@ void testCreateElicitationWithoutElicitationCapabilities(String clientType) {
var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
// Create client without elicitation capabilities
- try (var client = clientBuilder.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
+ try (var client = getMcpClientBuilder()
+ .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.build()) {
assertThat(client.initialize()).isNotNull();
@@ -409,12 +382,8 @@ void testCreateElicitationWithoutElicitationCapabilities(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationSuccess() {
Function formElicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
@@ -447,7 +416,7 @@ void testCreateElicitationSuccess(String clientType) {
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().elicitation().build())
.elicitation(formElicitationHandler)
@@ -472,12 +441,8 @@ void testCreateElicitationSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationWithApplyDefaults(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationWithApplyDefaults() {
// Client handler returns empty content — SDK should apply defaults
Function elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
@@ -511,7 +476,7 @@ void testCreateElicitationWithApplyDefaults(String clientType) {
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().elicitation(true, false).build())
.elicitation(elicitationHandler)
.applyElicitationDefaults(true)
@@ -537,12 +502,8 @@ void testCreateElicitationWithApplyDefaults(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap() {
// Client handler returns an unmodifiable map (Map.of()) — SDK must copy into a
// mutable map before applying defaults.
Function elicitationHandler = request -> new McpSchema.ElicitResult(
@@ -573,7 +534,7 @@ void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType)
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().elicitation(true, false).build())
.elicitation(elicitationHandler)
.applyElicitationDefaults(true)
@@ -597,12 +558,8 @@ void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType)
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched() {
Function elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>());
@@ -629,7 +586,7 @@ void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String cli
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
// applyElicitationDefaults intentionally NOT called — default false.
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().elicitation(true, false).build())
.elicitation(elicitationHandler)
.build()) {
@@ -651,12 +608,8 @@ void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String cli
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationApplyDefaultsSkippedOnDecline() {
Function elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.DECLINE, new HashMap<>());
@@ -682,7 +635,7 @@ void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) {
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().elicitation(true, false).build())
.elicitation(elicitationHandler)
.applyElicitationDefaults(true)
@@ -705,12 +658,8 @@ void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationApplyDefaultsPreservesMeta() {
Map meta = Map.of("trace-id", "abc-123");
Function elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta);
@@ -737,7 +686,7 @@ void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) {
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().elicitation(true, false).build())
.elicitation(elicitationHandler)
.applyElicitationDefaults(true)
@@ -761,12 +710,8 @@ void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCreateElicitationWithRequestTimeoutSuccess() {
Function elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(((McpSchema.ElicitFormRequest) request).requestedSchema()).isNotNull();
@@ -801,7 +746,7 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().elicitation().build())
.elicitation(elicitationHandler)
@@ -826,14 +771,10 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateElicitationWithRequestTimeoutFail(String clientType) {
-
+ @Test
+ void testCreateElicitationWithRequestTimeoutFail() {
var latch = new CountDownLatch(1);
- var clientBuilder = clientBuilders.get(clientType);
-
Function elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
@@ -877,7 +818,7 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().elicitation().build())
.elicitation(elicitationHandler)
@@ -898,15 +839,12 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testCreateUrlElicitationSuccess(String clientType) {
+ @Test
+ void testCreateUrlElicitationSuccess() {
var elicitationRequest = McpSchema.ElicitUrlRequest
.builder("Test message", "https://example.com/auth", "elicitation-123")
.build();
- var clientBuilder = clientBuilders.get(clientType);
-
Function urlElicitationHandler = request -> {
assertThat(request.message()).isEqualTo("Test message");
assertThat(request.url()).isEqualTo("https://example.com/auth");
@@ -930,7 +868,7 @@ void testCreateUrlElicitationSuccess(String clientType) {
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().elicitation(false, true).build())
.urlElicitation(urlElicitationHandler)
@@ -950,12 +888,8 @@ void testCreateUrlElicitationSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testElicitationCompleteNotification(String clientType) throws InterruptedException {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testElicitationCompleteNotification() throws InterruptedException {
CountDownLatch notificationLatch = new CountDownLatch(1);
AtomicReference notificationRef = new AtomicReference<>();
AtomicReference sessionId = new AtomicReference<>();
@@ -980,7 +914,7 @@ void testElicitationCompleteNotification(String clientType) throws InterruptedEx
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.elicitationCompleteConsumer(elicitationCompleteConsumer)
// enable elicitation so that we can register an elicitation complete consumer
@@ -1006,12 +940,8 @@ void testElicitationCompleteNotification(String clientType) throws InterruptedEx
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testElicitationRequiredError(String clientType) throws InterruptedException {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testElicitationRequiredError() {
// Capture the session ID so we can trigger an "elicitation complete" notification
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
@@ -1027,7 +957,7 @@ void testElicitationRequiredError(String clientType) throws InterruptedException
Function elicitationHandler = request -> ElicitResult
.builder(ElicitResult.Action.ACCEPT)
.build();
- try (var mcpClient = clientBuilder
+ try (var mcpClient = getMcpClientBuilder()
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.urlElicitation(elicitationHandler)
.build()) {
@@ -1060,11 +990,8 @@ void testElicitationRequiredError(String clientType) throws InterruptedException
// ---------------------------------------
// Roots Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testRootsSuccess(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testRootsSuccess() {
List roots = List.of(Root.builder("uri1://").name("root1").build(),
Root.builder("uri2://").name("root2").build());
@@ -1074,7 +1001,7 @@ void testRootsSuccess(String clientType) {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
- try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
+ try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build()) {
@@ -1109,12 +1036,8 @@ void testRootsSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testRootsWithoutCapability(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testRootsWithoutCapability() {
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
@@ -1131,7 +1054,7 @@ void testRootsWithoutCapability(String clientType) {
try (
// Create client without roots capability
// No roots capability
- var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) {
+ var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().build()).build()) {
assertThat(mcpClient.initialize()).isNotNull();
@@ -1148,19 +1071,15 @@ void testRootsWithoutCapability(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testRootsNotificationWithEmptyRootsList(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testRootsNotificationWithEmptyRootsList() {
AtomicReference> rootsRef = new AtomicReference<>();
var mcpServer = prepareSyncServerBuilder()
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
- try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
+ try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.build()) {
@@ -1178,12 +1097,8 @@ void testRootsNotificationWithEmptyRootsList(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testRootsWithMultipleHandlers(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testRootsWithMultipleHandlers() {
List roots = List.of(Root.builder("uri1://").name("root1").build());
AtomicReference> rootsRef1 = new AtomicReference<>();
@@ -1194,7 +1109,7 @@ void testRootsWithMultipleHandlers(String clientType) {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
.build();
- try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
+ try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build()) {
@@ -1212,12 +1127,8 @@ void testRootsWithMultipleHandlers(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testRootsServerCloseWithActiveSubscription(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testRootsServerCloseWithActiveSubscription() {
List roots = List.of(Root.builder("uri1://").name("root1").build());
AtomicReference> rootsRef = new AtomicReference<>();
@@ -1226,7 +1137,7 @@ void testRootsServerCloseWithActiveSubscription(String clientType) {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
- try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
+ try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build()) {
@@ -1247,12 +1158,8 @@ void testRootsServerCloseWithActiveSubscription(String clientType) {
// ---------------------------------------
// Tools Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testToolCallSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testToolCallSuccess() {
var responseBodyIsNullOrBlank = new AtomicBoolean(false);
var callResponse = McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("CALL RESPONSE; ctx=importantValue").build())
@@ -1283,7 +1190,7 @@ void testToolCallSuccess(String clientType) {
.tools(tool1)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1301,12 +1208,8 @@ void testToolCallSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testThrowingToolCallIsCaughtBeforeTimeout() {
McpSyncServer mcpServer = prepareSyncServerBuilder()
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(McpServerFeatures.SyncToolSpecification.builder()
@@ -1319,7 +1222,7 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) {
.build())
.build();
- try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) {
+ try (var mcpClient = getMcpClientBuilder().requestTimeout(Duration.ofMillis(6666)).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1334,12 +1237,8 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testToolCallSuccessWithTranportContextExtraction(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testToolCallSuccessWithTranportContextExtraction() {
var transportContextIsNull = new AtomicBoolean(false);
var transportContextIsEmpty = new AtomicBoolean(false);
var responseBodyIsNullOrBlank = new AtomicBoolean(false);
@@ -1374,7 +1273,7 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) {
.tools(tool1)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1394,11 +1293,8 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testToolWithNonAsciiCharacters(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testToolWithNonAsciiCharacters() {
String inputSchema = """
{
"type": "object",
@@ -1423,7 +1319,7 @@ void testToolWithNonAsciiCharacters(String clientType) {
.tools(nonAsciiTool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1446,12 +1342,8 @@ void testToolWithNonAsciiCharacters(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testToolListChangeHandlingSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testToolListChangeHandlingSuccess() {
var callResponse = McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("CALL RESPONSE").build())
.build();
@@ -1483,7 +1375,7 @@ void testToolListChangeHandlingSuccess(String clientType) {
.tools(tool1)
.build();
- try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
+ try (var mcpClient = getMcpClientBuilder().toolsChangeConsumer(toolsUpdate -> {
// perform a blocking call to a remote service
try {
HttpResponse response = HttpClient.newHttpClient()
@@ -1538,15 +1430,11 @@ void testToolListChangeHandlingSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testInitialize(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testInitialize() {
var mcpServer = prepareSyncServerBuilder().build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1559,16 +1447,13 @@ void testInitialize(String clientType) {
// ---------------------------------------
// Logging Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testLoggingNotification(String clientType) throws InterruptedException {
+ @Test
+ void testLoggingNotification() throws InterruptedException {
int expectedNotificationsCount = 3;
CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
// Create a list to store received logging notifications
List receivedNotifications = new CopyOnWriteArrayList<>();
- var clientBuilder = clientBuilders.get(clientType);
-
// Create server with a tool that sends logging notifications
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("logging-test", EMPTY_JSON_SCHEMA).description("Test logging notifications").build())
@@ -1617,7 +1502,7 @@ void testLoggingNotification(String clientType) throws InterruptedException {
try (
// Create client with logging notification handler
- var mcpClient = clientBuilder.loggingConsumer(notification -> {
+ var mcpClient = getMcpClientBuilder().loggingConsumer(notification -> {
receivedNotifications.add(notification);
latch.countDown();
}).build()) {
@@ -1667,17 +1552,14 @@ void testLoggingNotification(String clientType) throws InterruptedException {
// ---------------------------------------
// Progress Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testProgressNotification(String clientType) throws InterruptedException {
+ @Test
+ void testProgressNotification() throws InterruptedException {
int expectedNotificationsCount = 4; // 3 notifications + 1 for another progress
// token
CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
// Create a list to store received logging notifications
List receivedNotifications = new CopyOnWriteArrayList<>();
- var clientBuilder = clientBuilders.get(clientType);
-
// Create server with a tool that sends logging notifications
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(McpSchema.Tool.builder("progress-test", EMPTY_JSON_SCHEMA)
@@ -1720,7 +1602,7 @@ void testProgressNotification(String clientType) throws InterruptedException {
try (
// Create client with progress notification handler
- var mcpClient = clientBuilder.progressConsumer(notification -> {
+ var mcpClient = getMcpClientBuilder().progressConsumer(notification -> {
receivedNotifications.add(notification);
latch.countDown();
}).build()) {
@@ -1781,11 +1663,8 @@ void testProgressNotification(String clientType) throws InterruptedException {
// ---------------------------------------
// Completion Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : Completion call")
- @MethodSource("clientsForTesting")
- void testCompletionShouldReturnExpectedSuggestions(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCompletionShouldReturnExpectedSuggestions() {
var expectedValues = List.of("python", "pytorch", "pyside");
var completionResponse = new McpSchema.CompleteResult(
new CompleteResult.CompleteCompletion(expectedValues, 10, true));
@@ -1811,7 +1690,7 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) {
McpSchema.PromptReference.builder("code_review").title("Code review").build(), completionHandler))
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1837,12 +1716,8 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) {
// ---------------------------------------
// Ping Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testPingSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testPingSuccess() {
// Create server with a tool that uses ping functionality
AtomicReference executionOrder = new AtomicReference<>("");
@@ -1876,7 +1751,7 @@ void testPingSuccess(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
// Initialize client
InitializeResult initResult = mcpClient.initialize();
@@ -1900,11 +1775,8 @@ void testPingSuccess(String clientType) {
// ---------------------------------------
// Tool Structured Output Schema Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testStructuredOutputValidationSuccess(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputValidationSuccess() {
// Create a tool with output schema
Map outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
@@ -1933,7 +1805,7 @@ void testStructuredOutputValidationSuccess(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -1973,11 +1845,8 @@ void testStructuredOutputValidationSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputOfObjectArrayValidationSuccess() {
// Create a tool with output schema that returns an array of objects
Map outputSchema = Map
.of( // @formatter:off
@@ -2008,7 +1877,7 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
assertThat(mcpClient.initialize()).isNotNull();
// Call tool with valid structured output of type array
@@ -2032,11 +1901,8 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputWithInHandlerError(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputWithInHandlerError() {
// Create a tool with output schema
Map outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
@@ -2062,7 +1928,7 @@ void testStructuredOutputWithInHandlerError(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -2088,12 +1954,8 @@ void testStructuredOutputWithInHandlerError(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputValidationFailure(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputValidationFailure() {
// Create a tool with output schema
Map outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
@@ -2121,7 +1983,7 @@ void testStructuredOutputValidationFailure(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -2142,12 +2004,8 @@ void testStructuredOutputValidationFailure(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testStructuredOutputMissingStructuredContent(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputMissingStructuredContent() {
// Create a tool with output schema
Map outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
@@ -2170,7 +2028,7 @@ void testStructuredOutputMissingStructuredContent(String clientType) {
.tools(tool)
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -2192,18 +2050,14 @@ void testStructuredOutputMissingStructuredContent(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testStructuredOutputRuntimeToolAddition(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputRuntimeToolAddition() {
// Start server without tools
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.build();
- try (var mcpClient = clientBuilder.build()) {
+ try (var mcpClient = getMcpClientBuilder().build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
@@ -2273,12 +2127,8 @@ void testStructuredOutputRuntimeToolAddition(String clientType) {
// Resource Subscription Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testResourceSubscription(String clientType) throws InterruptedException {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testResourceSubscription() throws InterruptedException {
String resourceUri = "test://subscribable-resource";
var receivedContents = new AtomicReference>();
var latch = new CountDownLatch(1);
@@ -2296,7 +2146,7 @@ void testResourceSubscription(String clientType) throws InterruptedException {
.resources(resourceSpec)
.build();
- try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> {
+ try (var mcpClient = getMcpClientBuilder().resourcesUpdateConsumer(contents -> {
receivedContents.set(contents);
latch.countDown();
}).build()) {
@@ -2317,12 +2167,8 @@ void testResourceSubscription(String clientType) throws InterruptedException {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @MethodSource("clientsForTesting")
- void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) throws InterruptedException {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testResourceSubscription_afterUnsubscribe_noNotification() {
String resourceUri = "test://subscribable-resource-unsub";
var notificationCount = new java.util.concurrent.atomic.AtomicInteger(0);
@@ -2337,7 +2183,8 @@ void testResourceSubscription_afterUnsubscribe_noNotification(String clientType)
.resources(resourceSpec)
.build();
- try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet())
+ try (var mcpClient = getMcpClientBuilder()
+ .resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet())
.build()) {
mcpClient.initialize();
@@ -2354,6 +2201,48 @@ void testResourceSubscription_afterUnsubscribe_noNotification(String clientType)
}
}
+ // Bounded read
+ @Test
+ void testRejectsWhenContentLengthHeaderExceedsLimit() throws Exception {
+ String inputSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" }
+ },
+ "required": ["message"]
+ }
+ """;
+
+ McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
+ .tool(Tool.builder("tool1", McpJsonDefaults.getMapper(), inputSchema)
+ .description("tool1 description")
+ .build())
+ .callHandler((exchange, request) -> CallToolResult.builder()
+ .addContent(TextContent.builder(request.arguments().get("message").toString()).build())
+ .build())
+ .build();
+
+ var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(false).build())
+ .tools(tool1)
+ .build();
+
+ try (var mcpClient = getMcpClientBuilder().build()) {
+ String oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1);
+
+ mcpClient.initialize();
+ assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
+
+ assertThatThrownBy(() -> mcpClient.callTool(
+ McpSchema.CallToolRequest.builder("tool1").arguments(Map.of("message", oversizedBody)).build()))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("413");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
index 493b5812a..c2496e204 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
@@ -8,6 +8,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -298,6 +299,42 @@ void testListPromptsWithCursorAndMeta() {
}
+ @Test
+ void listResourcesStopsOnEmptyNextCursor() {
+ var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_LIST);
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ McpSchema.ListResourcesResult result = client.listResources().block();
+
+ assertThat(result).isNotNull();
+ assertThat(result.resources()).extracting(McpSchema.Resource::name).containsExactly("test.txt");
+ assertThat(transport.getRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ void listResourceTemplatesStopsOnEmptyNextCursor() {
+ var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST);
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates().block();
+
+ assertThat(result).isNotNull();
+ assertThat(result.resourceTemplates()).extracting(McpSchema.ResourceTemplate::name).containsExactly("template");
+ assertThat(transport.getRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ void listPromptsStopsOnEmptyNextCursor() {
+ var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_PROMPT_LIST);
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ McpSchema.ListPromptsResult result = client.listPrompts().block();
+
+ assertThat(result).isNotNull();
+ assertThat(result.prompts()).extracting(McpSchema.Prompt::name).containsExactly("test-prompt");
+ assertThat(transport.getRequestCount()).isEqualTo(1);
+ }
+
static class TestMcpClientTransport implements McpClientTransport {
private Function, Mono> handler;
@@ -397,4 +434,90 @@ public McpSchema.PaginatedRequest getCapturedRequest() {
}
+ static class EmptyCursorTestMcpClientTransport implements McpClientTransport {
+
+ private final String listMethod;
+
+ private final AtomicInteger requestCount = new AtomicInteger();
+
+ private Function, Mono> handler;
+
+ EmptyCursorTestMcpClientTransport(String listMethod) {
+ this.listMethod = listMethod;
+ }
+
+ @Override
+ public Mono connect(Function, Mono> handler) {
+ this.handler = handler;
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder()
+ .prompts(false)
+ .resources(false, false)
+ .tools(false)
+ .build();
+
+ McpSchema.InitializeResult initResult = McpSchema.InitializeResult
+ .builder(ProtocolVersions.MCP_2024_11_05, caps, MOCK_SERVER_INFO)
+ .build();
+ response = McpSchema.JSONRPCResponse.result(request.id(), initResult);
+ }
+ else if (this.listMethod.equals(request.method())) {
+ this.requestCount.incrementAndGet();
+ response = McpSchema.JSONRPCResponse.result(request.id(), resultForMethod(request.method()));
+ }
+ else {
+ return Mono.empty();
+ }
+
+ return this.handler.apply(Mono.just(response)).then();
+ }
+
+ private Object resultForMethod(String method) {
+ if (McpSchema.METHOD_RESOURCES_LIST.equals(method)) {
+ McpSchema.Resource resource = McpSchema.Resource.builder("file:///test.txt", "test.txt").build();
+ return McpSchema.ListResourcesResult.builder(List.of(resource)).nextCursor("").build();
+ }
+ if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(method)) {
+ McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{name}", "template")
+ .build();
+ return McpSchema.ListResourceTemplatesResult.builder(List.of(template)).nextCursor("").build();
+ }
+ if (McpSchema.METHOD_PROMPT_LIST.equals(method)) {
+ McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").build();
+ return McpSchema.ListPromptsResult.builder(List.of(prompt)).nextCursor("").build();
+ }
+ throw new IllegalArgumentException("Unsupported method: " + method);
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+
+ int getRequestCount() {
+ return this.requestCount.get();
+ }
+
+ }
+
}
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 000000000..a22dc1301
--- /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 000000000..df613265f
--- /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 7e6dc094c..8735aaaf1 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
@@ -81,7 +81,7 @@ public TestHttpClientSseClientTransport(final String baseUri,
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 000000000..856dc88fb
--- /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 000000000..29a18a635
--- /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);
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioClientTransportTests.java
new file mode 100644
index 000000000..0aad3934a
--- /dev/null
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/StdioClientTransportTests.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for {@link StdioClientTransport}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class StdioClientTransportTests {
+
+ private final PrintStream originalOut = System.out;
+
+ private final PrintStream originalErr = System.err;
+
+ private ByteArrayOutputStream testErr;
+
+ @BeforeEach
+ void setUp() {
+ testErr = new ByteArrayOutputStream();
+ PrintStream testOutPrintStream = new PrintStream(testErr, true);
+ System.setOut(testOutPrintStream);
+ System.setErr(testOutPrintStream);
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.setOut(originalOut);
+ System.setErr(originalErr);
+ }
+
+ @Test
+ void shouldRejectInboundMessageExceedingMaxSize() throws Exception {
+ // A server process that emits an endless line with no newline terminator. A
+ // plain BufferedReader#readLine would buffer it all; the bounded reader must
+ // abort instead of exhausting memory.
+ int maxSize = 1024;
+ ServerParameters params = ServerParameters.builder("sh").args("-c", "while :; do printf a; done").build();
+
+ StdioClientTransport transport = new StdioClientTransport(params, JSON_MAPPER, maxSize);
+ try {
+ StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(5))
+ .pollInterval(Duration.ofMillis(100))
+ .untilAsserted(() -> assertThat(testErr.toString())
+ .contains("Inbound message exceeds the maximum allowed size"));
+ }
+ finally {
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
index 5841c13da..c1ebe7bf1 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
@@ -4,6 +4,12 @@
package io.modelcontextprotocol.server;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.stream.Stream;
@@ -17,11 +23,13 @@
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
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 org.junit.jupiter.params.provider.Arguments;
@@ -51,6 +59,7 @@ public void before() {
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
+ .maxRequestSize(MAX_REQUEST_SIZE)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
@@ -61,12 +70,6 @@ public void before() {
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
-
- clientBuilders
- .put("httpclient",
- McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
- .sseEndpoint(CUSTOM_SSE_ENDPOINT)
- .build()).requestTimeout(Duration.ofHours(10)));
}
@Override
@@ -79,6 +82,15 @@ protected SyncSpecification> prepareSyncServerBuilder() {
return McpServer.sync(this.mcpServerTransportProvider);
}
+ @Override
+ protected McpClient.SyncSpec getMcpClientBuilder() {
+ return McpClient
+ .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
+ .sseEndpoint(CUSTOM_SSE_ENDPOINT)
+ .build())
+ .requestTimeout(Duration.ofHours(10));
+ }
+
@AfterEach
public void after() {
if (mcpServerTransportProvider != null) {
@@ -95,8 +107,77 @@ public void after() {
}
}
- @Override
- protected void prepareClients(int port, String mcpEndpoint) {
+ @Test
+ void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
+ var httpClient = HttpClient.newHttpClient();
+
+ // Establish an SSE session to obtain a valid session ID
+ prepareAsyncServerBuilder().build();
+ var sseRequest = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + CUSTOM_SSE_ENDPOINT))
+ .header("Accept", "text/event-stream")
+ .GET()
+ .build();
+ var sseResponseRef = new java.util.concurrent.atomic.AtomicReference>();
+ var sessionIdFuture = new java.util.concurrent.CompletableFuture();
+ httpClient.sendAsync(sseRequest, HttpResponse.BodyHandlers.ofInputStream()).thenAccept(response -> {
+ sseResponseRef.set(response);
+ try (var reader = new java.io.BufferedReader(
+ new java.io.InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.startsWith("data:") && line.contains("sessionId=")) {
+ String data = line.substring("data:".length()).strip();
+ String sessionId = data.substring(data.indexOf("sessionId=") + "sessionId=".length());
+ sessionIdFuture.complete(sessionId);
+ return;
+ }
+ }
+ sessionIdFuture.completeExceptionally(new RuntimeException("sessionId not found in SSE stream"));
+ response.body().close();
+ }
+ catch (Exception e) {
+ sessionIdFuture.completeExceptionally(e);
+ }
+ });
+ String sessionId = sessionIdFuture.get(5, java.util.concurrent.TimeUnit.SECONDS);
+
+ // Send POST request with an over-sized body
+ byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
+ HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() {
+ @Override
+ public long contentLength() {
+ // A publisher with unknown content length forces chunked transfer
+ // encoding, bypassing the Content-Length header check and exercising the
+ // body byte count
+ return -1;
+ }
+
+ @Override
+ public void subscribe(java.util.concurrent.Flow.Subscriber super ByteBuffer> subscriber) {
+ subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onNext(ByteBuffer.wrap(oversizedBody));
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+ };
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT + "?sessionId=" + sessionId))
+ .header("Content-Type", "application/json")
+ .header("Accept", "text/event-stream, application/json")
+ .POST(chunkedPublisher)
+ .build();
+
+ var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
}
static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
index e383d20ac..6f4ecb54a 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
@@ -4,21 +4,35 @@
package io.modelcontextprotocol.server;
-import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
-
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
-
+import java.util.function.Function;
+import java.util.function.Function;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
+import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
@@ -28,10 +42,14 @@
import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.ResourceReference;
+import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.ProtocolVersions;
+import jakarta.servlet.http.HttpServletResponse;
import net.javacrumbs.jsonunit.core.Option;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
@@ -40,8 +58,12 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -50,9 +72,12 @@
import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.APPLICATION_JSON;
import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.TEXT_EVENT_STREAM;
import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER;
+import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
import static org.awaitility.Awaitility.await;
@Timeout(15)
@@ -62,9 +87,16 @@ class HttpServletStatelessIntegrationTests {
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
+ private static final int MAX_REQUEST_SIZE = 2048;
+
private HttpServletStatelessServerTransport mcpStatelessServerTransport;
- ConcurrentHashMap clientBuilders = new ConcurrentHashMap<>();
+ private final McpClient.SyncSpec clientBuilder = McpClient
+ .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
+ .endpoint(CUSTOM_MESSAGE_ENDPOINT)
+ .build())
+ .initializationTimeout(Duration.ofHours(10))
+ .requestTimeout(Duration.ofHours(10));
private Tomcat tomcat;
@@ -72,6 +104,7 @@ class HttpServletStatelessIntegrationTests {
public void before() {
this.mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder()
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
+ .maxRequestSize(MAX_REQUEST_SIZE)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpStatelessServerTransport);
@@ -82,12 +115,6 @@ public void before() {
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
-
- clientBuilders
- .put("httpclient",
- McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
- .endpoint(CUSTOM_MESSAGE_ENDPOINT)
- .build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
}
@AfterEach
@@ -109,12 +136,8 @@ public void after() {
// ---------------------------------------
// Tools Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testToolCallSuccess(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testToolCallSuccess() {
var callResponse = CallToolResult.builder()
.content(List.of(McpSchema.TextContent.builder("CALL RESPONSE").build()))
.isError(false)
@@ -155,12 +178,8 @@ void testToolCallSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testInitialize(String clientType) {
-
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testInitialize() {
var mcpServer = McpServer.sync(mcpStatelessServerTransport).build();
try (var mcpClient = clientBuilder.build()) {
@@ -175,11 +194,8 @@ void testInitialize(String clientType) {
// ---------------------------------------
// Completion Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : Completion call")
- @ValueSource(strings = { "httpclient" })
- void testCompletionShouldReturnExpectedSuggestions(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testCompletionShouldReturnExpectedSuggestions() {
var expectedValues = List.of("python", "pytorch", "pyside");
var completionResponse = new CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
true // hasMore
@@ -230,14 +246,160 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) {
}
}
+ @Test
+ void testCompletionWithoutMatchingHandlerReturnsEmptyResult() {
+ BiFunction completionHandler = (transportContext,
+ request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("java"), 1, false));
+
+ var prompt = Prompt.builder("code_review")
+ .title("Code review")
+ .description("this is code review prompt")
+ .arguments(List
+ .of(PromptArgument.builder("language").title("Language").description("string").required(false).build()))
+ .build();
+
+ var otherPrompt = Prompt.builder("other_prompt")
+ .title("Other prompt")
+ .description("this prompt has completions")
+ .arguments(List
+ .of(PromptArgument.builder("topic").title("Topic").description("string").required(false).build()))
+ .build();
+
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .prompts(
+ new McpStatelessServerFeatures.SyncPromptSpecification(prompt,
+ (transportContext, getPromptRequest) -> null),
+ new McpStatelessServerFeatures.SyncPromptSpecification(otherPrompt,
+ (transportContext, getPromptRequest) -> null))
+ .completions(new McpStatelessServerFeatures.SyncCompletionSpecification(
+ PromptReference.builder("other_prompt").title("Other prompt").build(), completionHandler))
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(PromptReference.builder("code_review").title("Code review").build(),
+ new CompleteRequest.CompleteArgument("language", "ja"))
+ .build();
+
+ CompleteResult result = mcpClient.completeCompletion(request);
+
+ assertThat(result.completion().values()).isEmpty();
+ assertThat(result.completion().total()).isZero();
+ assertThat(result.completion().hasMore()).isFalse();
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+
+ @Test
+ void testResourceTemplateCompletionWithoutMatchingHandlerReturnsEmptyResult() {
+ BiFunction completionHandler = (transportContext,
+ request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("java"), 1, false));
+
+ var template = ResourceTemplate.builder("test://resource/{param}", "Test Resource")
+ .title("Test resource")
+ .description("A resource template for testing")
+ .mimeType("text/plain")
+ .build();
+
+ var otherTemplate = ResourceTemplate.builder("test://other/{param}", "Other Resource")
+ .title("Other resource")
+ .description("A resource template with completions")
+ .mimeType("text/plain")
+ .build();
+
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .resourceTemplates(
+ new McpStatelessServerFeatures.SyncResourceTemplateSpecification(template,
+ (transportContext, req) -> ReadResourceResult.builder(List.of()).build()),
+ new McpStatelessServerFeatures.SyncResourceTemplateSpecification(otherTemplate,
+ (transportContext, req) -> ReadResourceResult.builder(List.of()).build()))
+ .completions(new McpStatelessServerFeatures.SyncCompletionSpecification(
+ new ResourceReference("test://other/{param}"), completionHandler))
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new ResourceReference("test://resource/{param}"),
+ new CompleteRequest.CompleteArgument("param", "ja"))
+ .build();
+
+ CompleteResult result = mcpClient.completeCompletion(request);
+
+ assertThat(result.completion().values()).isEmpty();
+ assertThat(result.completion().total()).isZero();
+ assertThat(result.completion().hasMore()).isFalse();
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+
+ @Test
+ void testCompletionForNonExistentPromptReturnsInvalidParams() {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new PromptReference("nonexistent-prompt"), new CompleteRequest.CompleteArgument("arg", "val"))
+ .build();
+
+ assertThatThrownBy(() -> mcpClient.completeCompletion(request)).isInstanceOf(McpError.class)
+ .asInstanceOf(type(McpError.class))
+ .extracting(McpError::getJsonRpcError)
+ .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code)
+ .isEqualTo(ErrorCodes.INVALID_PARAMS);
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+
+ @Test
+ void testCompletionForNonExistentResourceReturnsResourceNotFound() {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new ResourceReference("test://nonexistent/{param}"),
+ new CompleteRequest.CompleteArgument("param", "val"))
+ .build();
+
+ assertThatThrownBy(() -> mcpClient.completeCompletion(request)).isInstanceOf(McpError.class)
+ .asInstanceOf(type(McpError.class))
+ .extracting(McpError::getJsonRpcError)
+ .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code)
+ .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND);
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+
// ---------------------------------------
// Tool Structured Output Schema Tests
// ---------------------------------------
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputValidationSuccess(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputValidationSuccess() {
// Create a tool with output schema
Map outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
@@ -302,11 +464,8 @@ void testStructuredOutputValidationSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputOfObjectArrayValidationSuccess() {
// Create a tool with output schema that returns an array of objects
Map outputSchema = Map
.of( // @formatter:off
@@ -363,11 +522,8 @@ void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputWithInHandlerError(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputWithInHandlerError() {
// Create a tool with output schema
Map outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
@@ -421,11 +577,8 @@ void testStructuredOutputWithInHandlerError(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputValidationFailure(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputValidationFailure() {
// Create a tool with output schema
Map outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
@@ -473,11 +626,8 @@ void testStructuredOutputValidationFailure(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputMissingStructuredContent(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputMissingStructuredContent() {
// Create a tool with output schema
Map outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
@@ -522,11 +672,8 @@ void testStructuredOutputMissingStructuredContent(String clientType) {
}
}
- @ParameterizedTest(name = "{0} : {displayName} ")
- @ValueSource(strings = { "httpclient" })
- void testStructuredOutputRuntimeToolAddition(String clientType) {
- var clientBuilder = clientBuilders.get(clientType);
-
+ @Test
+ void testStructuredOutputRuntimeToolAddition() {
// Start server without tools
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
@@ -644,6 +791,197 @@ void testThrownMcpErrorAndJsonRpcError() throws Exception {
mcpServer.close();
}
+ @Test
+ void testMissingHandlerReturnsMethodNotFoundError() {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().build())
+ .build();
+ var clientTransport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
+ .endpoint(CUSTOM_MESSAGE_ENDPOINT)
+ .build();
+
+ try (var mcpClient = McpClient.sync(clientTransport).build()) {
+ // Create a session using an MCP client
+ McpSchema.InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Override the response handler in the client to capture responses
+ AtomicReference response = new AtomicReference<>();
+ var handler = (Function, Mono>) (
+ message) -> message.doOnNext(r -> {
+ if (r instanceof McpSchema.JSONRPCResponse resp) {
+ response.set(resp);
+ }
+ });
+ StepVerifier.create(clientTransport.connect(handler)).verifyComplete();
+
+ // Send a request for a non-existent method through the transport, bypassing
+ // the client's capability checks
+ StepVerifier
+ .create(clientTransport.sendMessage(new McpSchema.JSONRPCRequest("foo/bar", "test-request-123")))
+ .verifyComplete();
+
+ // Wait until we've received the response
+ await().atMost(Duration.ofSeconds(1)).until(() -> response.get() != null);
+
+ assertThat(response.get().error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND);
+ assertThat(response.get().error().message()).isEqualTo("Method not found: foo/bar");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
+ @Test
+ void testInitializedNotificationDoesNotLogWarn() {
+ Logger handlerLogger = (Logger) LoggerFactory.getLogger(DefaultMcpStatelessServerHandler.class);
+ ListAppender logAppender = new ListAppender<>();
+ logAppender.start();
+ handlerLogger.addAppender(logAppender);
+
+ try {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ mcpClient.initialize(); // automatically sends notifications/initialized
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+ finally {
+ handlerLogger.detachAppender(logAppender);
+ logAppender.stop();
+ }
+
+ assertThat(logAppender.list).noneMatch(event -> event.getLevel() == Level.WARN);
+ }
+
+ @Test
+ void testRootsListChangedNotificationDoesNotLogWarn() {
+ Logger handlerLogger = (Logger) LoggerFactory.getLogger(DefaultMcpStatelessServerHandler.class);
+ ListAppender logAppender = new ListAppender<>();
+ logAppender.start();
+ handlerLogger.addAppender(logAppender);
+
+ try {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().build())
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ mcpClient.initialize();
+ mcpClient.rootsListChangedNotification();
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+ finally {
+ handlerLogger.detachAppender(logAppender);
+ logAppender.stop();
+ }
+
+ assertThat(logAppender.list).noneMatch(event -> event.getLevel() == Level.WARN);
+ }
+
+ // ---------------------------------------
+ // Bounded read
+ // ---------------------------------------
+ @Test
+ void testRejectsWhenContentLengthHeaderExceedsLimit() {
+ String inputSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" }
+ },
+ "required": ["message"]
+ }
+ """;
+
+ McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification
+ .builder()
+ .tool(Tool.builder("tool1", JSON_MAPPER, inputSchema).description("tool1 description").build())
+ .callHandler((transportContext, request) -> CallToolResult.builder()
+ .addContent(TextContent.builder(request.arguments().get("message").toString()).build())
+ .build())
+ .build();
+
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .capabilities(ServerCapabilities.builder().tools(false).build())
+ .tools(tool1)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ String oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1);
+
+ mcpClient.initialize();
+ assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
+
+ assertThatThrownBy(() -> mcpClient.callTool(
+ McpSchema.CallToolRequest.builder("tool1").arguments(Map.of("message", oversizedBody)).build()))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("413");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
+ @Test
+ void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport).build();
+
+ try {
+ var httpClient = HttpClient.newHttpClient();
+
+ // A publisher with unknown content length forces chunked transfer
+ // encoding, bypassing the Content-Length header check and exercising the
+ // body byte count
+ byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
+ HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() {
+ @Override
+ public long contentLength() {
+ return -1;
+ }
+
+ @Override
+ public void subscribe(java.util.concurrent.Flow.Subscriber super ByteBuffer> subscriber) {
+ subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onNext(ByteBuffer.wrap(oversizedBody));
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+ };
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT))
+ .header("Content-Type", "application/json")
+ .header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
+ .POST(chunkedPublisher)
+ .build();
+
+ var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
index 5b934e4e9..cfeb16b19 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
@@ -4,8 +4,16 @@
package io.modelcontextprotocol.server;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
import java.util.stream.Stream;
import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests;
@@ -16,14 +24,20 @@
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
+import org.awaitility.Awaitility;
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 org.junit.jupiter.params.provider.Arguments;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
@@ -49,6 +63,7 @@ public void before() {
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
.mcpEndpoint(MESSAGE_ENDPOINT)
.keepAliveInterval(Duration.ofSeconds(1))
+ .maxRequestSize(MAX_REQUEST_SIZE)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
@@ -59,12 +74,6 @@ public void before() {
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
-
- clientBuilders
- .put("httpclient",
- McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
- .endpoint(MESSAGE_ENDPOINT)
- .build()).requestTimeout(Duration.ofHours(10)));
}
@Override
@@ -77,6 +86,15 @@ protected SyncSpecification> prepareSyncServerBuilder() {
return McpServer.sync(this.mcpServerTransportProvider);
}
+ @Override
+ protected McpClient.SyncSpec getMcpClientBuilder() {
+ return McpClient
+ .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
+ .endpoint(MESSAGE_ENDPOINT)
+ .build())
+ .requestTimeout(Duration.ofHours(10));
+ }
+
@AfterEach
public void after() {
if (mcpServerTransportProvider != null) {
@@ -93,11 +111,88 @@ public void after() {
}
}
- @Override
- protected void prepareClients(int port, String mcpEndpoint) {
+ @Test
+ void testMissingHandlerReturnsMethodNotFoundError() {
+ var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
+ .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
+ .build();
+ var clientTransport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
+ .endpoint(MESSAGE_ENDPOINT)
+ .build();
+
+ try (var mcpClient = McpClient.sync(clientTransport).build()) {
+ // Create a session using an MCP client
+ McpSchema.InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Override the response handler in the client to capture responses
+ AtomicReference response = new AtomicReference<>();
+ var handler = (Function, Mono>) (
+ message) -> message.doOnNext(r -> {
+ if (r instanceof McpSchema.JSONRPCResponse resp) {
+ response.set(resp);
+ }
+ });
+ StepVerifier.create(clientTransport.connect(handler)).verifyComplete();
+
+ // Send an incorrect request through the transport
+ StepVerifier
+ .create(clientTransport.sendMessage(new McpSchema.JSONRPCRequest("foo/bar", "test-request-123")))
+ .verifyComplete();
+
+ // Wait until we've received the response
+ Awaitility.await().atMost(Duration.ofSeconds(1)).until(() -> response.get() != null);
+
+ assertThat(response.get().error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND);
+ assertThat(response.get().error().message()).isEqualTo("Method not found: foo/bar");
+ }
+ finally {
+ mcpServer.close();
+ }
+
}
static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
.create(Map.of("important", "value"));
+ @Test
+ void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
+ var httpClient = HttpClient.newHttpClient();
+ // A publisher with unknown content length forces chunked transfer encoding,
+ // bypassing the Content-Length header check and exercising the body byte
+ // count
+ byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
+ HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() {
+ @Override
+ public long contentLength() {
+ return -1;
+ }
+
+ @Override
+ public void subscribe(java.util.concurrent.Flow.Subscriber super ByteBuffer> subscriber) {
+ subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onNext(ByteBuffer.wrap(oversizedBody));
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+ };
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
+ .header("Content-Type", "application/json")
+ .header("Accept", "text/event-stream, application/json")
+ .POST(chunkedPublisher)
+ .build();
+
+ var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
+
}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java
index 710a55447..482085ec1 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java
@@ -21,6 +21,7 @@
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
@@ -28,15 +29,17 @@
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
+import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.Resource;
import io.modelcontextprotocol.spec.McpSchema.ResourceReference;
-import io.modelcontextprotocol.spec.McpSchema.PromptReference;
+import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
-import io.modelcontextprotocol.spec.McpError;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
/**
* Tests for completion functionality with context support.
@@ -179,6 +182,152 @@ void testCompletionBackwardCompatibility() {
mcpServer.close();
}
+ @Test
+ void testCompletionWithoutMatchingHandlerReturnsEmptyResult() {
+ BiFunction completionHandler = (exchange,
+ request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("java"), 1, false));
+
+ McpSchema.Prompt prompt = Prompt.builder("code_review")
+ .description("this is a code review prompt")
+ .arguments(List.of(PromptArgument.builder("language").description("string").required(false).build()))
+ .build();
+
+ McpSchema.Prompt otherPrompt = Prompt.builder("other_prompt")
+ .description("this prompt has completions")
+ .arguments(List.of(PromptArgument.builder("topic").description("string").required(false).build()))
+ .build();
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .prompts(
+ new McpServerFeatures.SyncPromptSpecification(prompt,
+ (mcpSyncServerExchange, getPromptRequest) -> null),
+ new McpServerFeatures.SyncPromptSpecification(otherPrompt,
+ (mcpSyncServerExchange, getPromptRequest) -> null))
+ .completions(new McpServerFeatures.SyncCompletionSpecification(new PromptReference("other_prompt"),
+ completionHandler))
+ .build();
+
+ try (var mcpClient = clientBuilder
+ .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build())
+ .build();) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new PromptReference("code_review"), new CompleteRequest.CompleteArgument("language", "ja"))
+ .build();
+
+ CompleteResult result = mcpClient.completeCompletion(request);
+
+ assertThat(result.completion().values()).isEmpty();
+ assertThat(result.completion().total()).isZero();
+ assertThat(result.completion().hasMore()).isFalse();
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testResourceTemplateCompletionWithoutMatchingHandlerReturnsEmptyResult() {
+ BiFunction completionHandler = (exchange,
+ request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("java"), 1, false));
+
+ ResourceTemplate template = ResourceTemplate.builder("test://resource/{param}", "Test Resource")
+ .description("A resource template for testing")
+ .mimeType("text/plain")
+ .build();
+
+ ResourceTemplate otherTemplate = ResourceTemplate.builder("test://other/{param}", "Other Resource")
+ .description("A resource template with completions")
+ .mimeType("text/plain")
+ .build();
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .resourceTemplates(
+ new McpServerFeatures.SyncResourceTemplateSpecification(template,
+ (exchange, req) -> ReadResourceResult.builder(List.of()).build()),
+ new McpServerFeatures.SyncResourceTemplateSpecification(otherTemplate,
+ (exchange, req) -> ReadResourceResult.builder(List.of()).build()))
+ .completions(new McpServerFeatures.SyncCompletionSpecification(
+ new ResourceReference("test://other/{param}"), completionHandler))
+ .build();
+
+ try (var mcpClient = clientBuilder
+ .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build())
+ .build();) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new ResourceReference("test://resource/{param}"),
+ new CompleteRequest.CompleteArgument("param", "ja"))
+ .build();
+
+ CompleteResult result = mcpClient.completeCompletion(request);
+
+ assertThat(result.completion().values()).isEmpty();
+ assertThat(result.completion().total()).isZero();
+ assertThat(result.completion().hasMore()).isFalse();
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testCompletionForNonExistentPromptReturnsInvalidParams() {
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .build();
+
+ try (var mcpClient = clientBuilder
+ .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build())
+ .build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new PromptReference("nonexistent-prompt"), new CompleteRequest.CompleteArgument("arg", "val"))
+ .build();
+
+ assertThatThrownBy(() -> mcpClient.completeCompletion(request)).isInstanceOf(McpError.class)
+ .asInstanceOf(type(McpError.class))
+ .extracting(McpError::getJsonRpcError)
+ .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code)
+ .isEqualTo(ErrorCodes.INVALID_PARAMS);
+ }
+
+ mcpServer.close();
+ }
+
+ @Test
+ void testCompletionForNonExistentResourceReturnsResourceNotFound() {
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .capabilities(ServerCapabilities.builder().completions().build())
+ .build();
+
+ try (var mcpClient = clientBuilder
+ .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build())
+ .build()) {
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = CompleteRequest
+ .builder(new ResourceReference("test://nonexistent/{param}"),
+ new CompleteRequest.CompleteArgument("param", "val"))
+ .build();
+
+ assertThatThrownBy(() -> mcpClient.completeCompletion(request)).isInstanceOf(McpError.class)
+ .asInstanceOf(type(McpError.class))
+ .extracting(McpError::getJsonRpcError)
+ .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code)
+ .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND);
+ }
+
+ mcpServer.close();
+ }
+
@Test
void testDependentCompletionScenario() {
BiFunction completionHandler = (exchange, request) -> {
@@ -365,4 +514,4 @@ void testPromptWithoutArgumentsCompletionForArgument() {
mcpServer.close();
}
-}
\ No newline at end of file
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java
index 6c2cc2bf4..ab1068954 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java
@@ -11,24 +11,25 @@
import java.io.InputStreamReader;
import java.io.PrintStream;
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.concurrent.atomic.AtomicReference;
import io.modelcontextprotocol.json.McpJsonDefaults;
-import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransport;
+import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -246,6 +247,49 @@ void shouldHandleInvalidJsonMessage() throws Exception {
.verifyComplete();
}
+ @Test
+ void shouldRejectInboundMessageExceedingMaxSize() throws Exception {
+ // A line larger than the configured limit that never terminates with a newline.
+ // BufferedReader#readLine would buffer the whole thing; the bounded reader must
+ // abort instead.
+ int maxSize = 1024;
+ String oversized = "a".repeat(maxSize + 10);
+ InputStream stream = new ByteArrayInputStream(oversized.getBytes(StandardCharsets.UTF_8));
+
+ transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, testOutPrintStream,
+ maxSize);
+
+ AtomicReference capturedMessage = new AtomicReference<>();
+ McpServerSession.Factory realSessionFactory = transport -> {
+ McpServerSession session = mock(McpServerSession.class);
+ when(session.handle(any())).thenAnswer(invocation -> {
+ capturedMessage.set(invocation.getArgument(0));
+ return Mono.empty();
+ });
+ when(session.closeGracefully()).thenReturn(Mono.empty());
+ return session;
+ };
+
+ transportProvider.setSessionFactory(realSessionFactory);
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(5))
+ .pollInterval(Duration.ofMillis(100))
+ .untilAsserted(
+ () -> assertThat(testErr.toString()).contains("Inbound message exceeds the maximum allowed size"));
+
+ // message is never processed
+ assertThat(capturedMessage.get()).isNull();
+ }
+
+ @Test
+ void shouldRejectNonPositiveMaxSize() {
+ assertThatThrownBy(
+ () -> new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, System.out, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("inputMaxSize must be positive");
+ }
+
@Test
void shouldHandleSessionClose() throws Exception {
// Set session factory
diff --git a/mcp/README.md b/mcp/README.md
index 7a9ff8516..06cc4e320 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -1,5 +1,5 @@
# Java MCP SDK
Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools.
-For comprehensive guides and API documentation, visit the [MCP Java SDK Reference Documentation](https://modelcontextprotocol.io/sdk/java/mcp-overview).
+For comprehensive guides and API documentation, visit the [MCP Java SDK Reference Documentation](https://java.sdk.modelcontextprotocol.io/latest/overview/).
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 44eb7b99b..50d95e095 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 2.0.0
+ 2.0.1
mcp
jar
@@ -25,13 +25,13 @@
io.modelcontextprotocol.sdk
mcp-json-jackson3
- 2.0.0
+ 2.0.1
io.modelcontextprotocol.sdk
mcp-core
- 2.0.0
+ 2.0.1
diff --git a/mkdocs.yml b/mkdocs.yml
index 9bed41532..de3487e14 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -48,7 +48,7 @@ nav:
- Contributing:
- Contributing Guide: contribute.md
- Documentation: development.md
- - API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/2.0.0
+ - API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/2.0.1
markdown_extensions:
- admonition
diff --git a/pom.xml b/pom.xml
index abe5c55e6..ca547309a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 2.0.0
+ 2.0.1
pom
https://github.com/modelcontextprotocol/java-sdk
@@ -68,9 +68,9 @@
2.0.16
1.5.15
- 2.20
- 2.20.1
- 3.0.3
+ 2.21
+ 2.21.1
+ 3.1.4
6.2.1
@@ -97,8 +97,8 @@
4.2.0
7.1.0
4.1.0
- 2.0.0
- 3.0.0
+ 2.0.4
+ 3.0.6