- * There is no direct equivalent in {@link AsyncSpec}. To achieve the same result,
- * append {@code contextWrite(McpTransportContext.KEY, context)} to any
- * {@link McpAsyncClient} call.
+ * The supplier is invoked at subscription time, on the calling thread, and the
+ * resulting context is visible to the transport for every leg of the operation,
+ * including connections opened on other threads, such as the SSE stream started
+ * during initialization.
* @param contextProvider A supplier to create a context
* @return This builder for method chaining
*/
@@ -580,6 +581,21 @@ public interface McpClient {
*
Custom message sampling logic
*
+ *
+ *
+ * Unlike {@link SyncSpec}, this specification has no
+ * {@code transportContextProvider}. This is deliberate: in a reactive pipeline the
+ * caller owns the Reactor context, and whatever the transport needs, such as an
+ * {@link McpTransportContext}, can be written into it directly. Write it once where
+ * the reactive chain starts, rather than at every call site, and every
+ * {@link McpAsyncClient} call downstream inherits it, including the connections
+ * opened during initialization:
{@code
+ * chain.filter(exchange)
+ * .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context));
+ * } To bridge thread-locals into the chain, use Reactor's context propagation
+ * support.
+ *
+ * @see SyncSpec#transportContextProvider(Supplier)
*/
class AsyncSpec {
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 874da90..3e4b613 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 class HttpClientSseClientTransport implements McpClientTransport {
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
+ private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
+
/**
* Creates a new builder instance.
*/
@@ -326,6 +343,26 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return this;
}
+ /**
+ * Sets the maximum number of bytes read for a single inbound message, whether it
+ * arrives on the SSE stream or as the response to a posted message. A peer that
+ * sends a larger message (or never terminates one) has its stream aborted instead
+ * of forcing the transport to buffer it in memory. Defaults to 16MiB.
+ *
+ *
+ * The bound applies per message, not to the stream as a whole: a long-lived SSE
+ * stream may deliver any number of messages, each up to this size. SSE field
+ * framing is allowed a small amount of headroom on top of this size, so a message
+ * of exactly this many bytes is still accepted.
+ * @param maxResponseSize the maximum inbound message size, in bytes
+ * @return this builder
+ */
+ public Builder maxResponseSize(int maxResponseSize) {
+ Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
+ this.maxResponseSize = maxResponseSize;
+ return this;
+ }
+
/**
* Builds a new {@link HttpClientSseClientTransport} instance.
* @return a new transport instance
@@ -334,7 +371,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
- messageEndpointValidator);
+ messageEndpointValidator, maxResponseSize);
}
}
@@ -353,13 +390,15 @@ public class HttpClientSseClientTransport implements McpClientTransport {
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
}).flatMap(requestBuilder -> Mono.create(sink -> {
- Disposable connection = Flux.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 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
}).flatMap(customizedBuilder -> {
var request = customizedBuilder.build();
- return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
+ return Mono.fromFuture(
+ httpClient.sendAsync(request, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
});
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index 48462c0..5517823 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 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
.sorted(Comparator.reverseOrder())
.findFirst()
.get();
+ this.maxResponseSize = maxResponseSize;
}
@Override
@@ -206,7 +242,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
}).flatMap(requestBuilder -> {
var request = requestBuilder.build();
- return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
+ return Mono.fromFuture(() -> this.httpClient.sendAsync(request,
+ ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
}).then();
}
@@ -323,7 +360,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
+ 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 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
if (contentType.contains(TEXT_EVENT_STREAM)) {
// For SSE streams, use line subscriber that returns Void
logger.debug("Received SSE stream response, using line subscriber");
- return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
+ return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink, this.maxResponseSize);
}
else if (contentType.contains(APPLICATION_JSON)) {
// For JSON responses and others, use string subscriber
logger.debug("Received response, using string subscriber");
- return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
+ return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink, this.maxResponseSize);
}
logger.debug("Received Bodyless response, using discarding subscriber");
- return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
+ return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
};
return responseBodyHandler;
@@ -658,6 +695,10 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
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));
@@ -722,11 +763,13 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private Duration connectTimeout = Duration.ofSeconds(10);
- private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
- ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
+ private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2025_03_26,
+ ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
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 class HttpClientStreamableHttpTransport implements McpClientTransport {
return this;
}
+ /**
+ * Sets the maximum number of bytes read for a single inbound message, whether it
+ * arrives on an SSE stream or as a JSON response body. A peer that sends a larger
+ * message (or never terminates one) has its stream aborted instead of forcing the
+ * transport to buffer it in memory. Defaults to 16MiB.
+ *
+ *
+ * The bound applies per message, not to the stream as a whole: a long-lived SSE
+ * stream may deliver any number of messages, each up to this size. SSE field
+ * framing is allowed a small amount of headroom on top of this size, so a message
+ * of exactly this many bytes is still accepted.
+ * @param maxResponseSize the maximum inbound message size, in bytes
+ * @return this builder
+ */
+ public Builder maxResponseSize(int maxResponseSize) {
+ Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
+ this.maxResponseSize = maxResponseSize;
+ return this;
+ }
+
/**
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
* the current builder configuration.
@@ -934,7 +997,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
- httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
+ httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize);
}
}
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 0000000..8070340
--- /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 29dc23c..b19904d 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java
@@ -5,8 +5,13 @@
package io.modelcontextprotocol.client.transport;
import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.BodySubscriber;
import java.net.http.HttpResponse.ResponseInfo;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
@@ -30,11 +35,21 @@ import reactor.core.publisher.FluxSink;
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
+ * @author Daniel Garnier-Moiroux
*/
class ResponseSubscribers {
private static final Logger logger = LoggerFactory.getLogger(ResponseSubscribers.class);
+ /**
+ * Bytes of SSE field framing a single line may carry on top of the message payload:
+ * {@code "event: "} is the longest field prefix this parser recognises. Line
+ * terminators are not counted, as they reset the running line length. Without this
+ * allowance, an event carrying exactly the maximum message size would be rejected
+ * because of the bytes the SSE wire format adds around it.
+ */
+ private static final int SSE_FRAMING_OVERHEAD = "event: ".length();
+
record SseEvent(String id, String event, String data) {
}
@@ -54,19 +69,80 @@ class ResponseSubscribers {
record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent {
}
- static BodySubscriber sseToBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
- .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink)));
+ /**
+ * Creates a {@link BodySubscriber} that parses a Server-Sent Events stream, bounding
+ * how much memory a single inbound message may occupy. Both the size of an individual
+ * line (as read off the wire before a terminator is seen) and the accumulated size of
+ * a multi-line SSE event are capped at {@code maxSize}; a peer exceeding either limit
+ * has its stream aborted instead of forcing the transport to buffer it in memory. The
+ * line bound is allowed {@link #SSE_FRAMING_OVERHEAD} extra bytes so that the SSE
+ * framing around a payload does not count against the payload's own budget.
+ * @param responseInfo the HTTP response information
+ * @param sink the sink to emit parsed events to
+ * @param maxSize the maximum number of bytes read for a single inbound message
+ */
+ static BodySubscriber sseToBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
+ int maxSize) {
+ BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
+ .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink, maxSize)));
+ return new BoundedLineBodySubscriber(lineSubscriber, plusFramingOverhead(maxSize));
}
- static BodySubscriber aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
- .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink)));
+ /**
+ * Adds {@link #SSE_FRAMING_OVERHEAD} to {@code maxSize}, saturating at
+ * {@link Integer#MAX_VALUE} rather than overflowing into a negative bound that would
+ * reject everything.
+ */
+ private static int plusFramingOverhead(int maxSize) {
+ return maxSize > Integer.MAX_VALUE - SSE_FRAMING_OVERHEAD ? Integer.MAX_VALUE : maxSize + SSE_FRAMING_OVERHEAD;
}
- static BodySubscriber bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
+ /**
+ * Creates a {@link BodySubscriber} that aggregates the whole response body into a
+ * single event, bounding how much memory it may occupy. Both the size of an
+ * individual line (as read off the wire before a terminator is seen) and the total
+ * accumulated body are capped at {@code maxSize}; a peer exceeding either limit has
+ * its response aborted instead of forcing the transport to buffer it in memory.
+ * @param responseInfo the HTTP response information
+ * @param sink the sink to emit the aggregated event to
+ * @param maxSize the maximum number of bytes read for the response body
+ */
+ static BodySubscriber aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
+ int maxSize) {
+ BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
+ .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink, maxSize)));
+ return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
+ }
+
+ /**
+ * Creates a {@link BodySubscriber} that discards the response body, bounding how much
+ * memory reading it may occupy. The body is discarded as it arrives, but the
+ * underlying line subscriber still buffers each line before handing it over, so a
+ * peer sending a line longer than {@code maxSize} has its response aborted.
+ * @param responseInfo the HTTP response information
+ * @param sink the sink to emit the completion event to
+ * @param maxSize the maximum number of bytes read for a single line
+ */
+ static BodySubscriber bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
+ int maxSize) {
+ BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink)));
+ return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
+ }
+
+ /**
+ * Creates a {@link BodyHandler} that reads the response body into a string, bounding
+ * how much memory it may occupy. A peer sending more than {@code maxSize} bytes has
+ * its response aborted instead of forcing the transport to buffer it in memory.
+ *
+ *
+ * Decoding matches {@link HttpResponse.BodyHandlers#ofString()}, including its
+ * handling of the charset declared in the {@code Content-Type} header.
+ * @param maxSize the maximum number of bytes read for the response body
+ */
+ static BodyHandler boundedStringBodyHandler(int maxSize) {
+ BodyHandler delegate = HttpResponse.BodyHandlers.ofString();
+ return responseInfo -> new BoundedTotalBodySubscriber<>(delegate.apply(responseInfo), maxSize);
}
static class SseLineSubscriber extends BaseSubscriber {
@@ -112,18 +188,30 @@ class ResponseSubscribers {
*/
private ResponseInfo responseInfo;
+ /**
+ * The maximum number of bytes that may accumulate for a single SSE event. A peer
+ * that never terminates an event (e.g. an endless stream of {@code data:} lines)
+ * has its stream aborted instead of exhausting memory. The accumulated data is
+ * measured in characters, which for UTF-8 is never more than the number of bytes
+ * it was decoded from.
+ */
+ private final int maxSize;
+
/**
* Creates a new LineSubscriber that will emit parsed SSE events to the provided
* sink.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to
+ * @param maxSize the maximum number of bytes that may accumulate for a single SSE
+ * event
*/
- public SseLineSubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ public SseLineSubscriber(ResponseInfo responseInfo, FluxSink sink, int maxSize) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.currentEventId = new AtomicReference<>();
this.currentEventType = new AtomicReference<>();
this.responseInfo = responseInfo;
+ this.maxSize = maxSize;
}
@Override
@@ -155,7 +243,18 @@ class ResponseSubscribers {
if (line.startsWith("data:")) {
var matcher = EVENT_DATA_PATTERN.matcher(line);
if (matcher.find()) {
- this.eventBuilder.append(matcher.group(1).trim()).append("\n");
+ String data = matcher.group(1).trim();
+ // Measured before appending, so that an event carrying exactly
+ // maxSize of data is accepted: the trailing separator below is
+ // stripped again before the event is emitted.
+ if (this.eventBuilder.length() + data.length() > this.maxSize) {
+ upstream().cancel();
+ this.sink.error(
+ new McpTransportException("Inbound SSE event exceeds the maximum allowed size of "
+ + this.maxSize + " bytes"));
+ return;
+ }
+ this.eventBuilder.append(data).append("\n");
}
upstream().request(1);
}
@@ -225,15 +324,26 @@ class ResponseSubscribers {
volatile boolean hasRequestedDemand = false;
+ /**
+ * The maximum number of bytes that may accumulate for the aggregated response
+ * body. A peer that sends a larger body has its response aborted instead of
+ * exhausting memory. The accumulated body is measured in characters, which for
+ * UTF-8 is never more than the number of bytes it was decoded from.
+ */
+ private final int maxSize;
+
/**
* Creates a new JsonLineSubscriber that will emit parsed JSON-RPC messages.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to
+ * @param maxSize the maximum number of bytes that may accumulate for the
+ * aggregated response body
*/
- public AggregateSubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ public AggregateSubscriber(ResponseInfo responseInfo, FluxSink sink, int maxSize) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.responseInfo = responseInfo;
+ this.maxSize = maxSize;
}
@Override
@@ -252,6 +362,15 @@ class ResponseSubscribers {
@Override
protected void hookOnNext(String line) {
+ // Measured before appending, so that a body of exactly maxSize is accepted.
+ // The separator this adds back for each line stands in for the terminator the
+ // peer sent, which the line subscriber has already stripped.
+ if (this.eventBuilder.length() + line.length() > this.maxSize) {
+ upstream().cancel();
+ this.sink.error(new McpTransportException(
+ "Inbound response body exceeds the maximum allowed size of " + this.maxSize + " bytes"));
+ return;
+ }
this.eventBuilder.append(line).append("\n");
}
@@ -324,4 +443,177 @@ class ResponseSubscribers {
}
+ /**
+ * Base for {@link BodySubscriber} wrappers that transparently forward the response
+ * body to a delegate, but abort it once the peer exceeds a size bound.
+ *
+ *
+ * Aborting cancels the upstream subscription, which closes the connection, and
+ * signals a {@link McpTransportException} to the delegate so the failure surfaces
+ * both through the body's {@link CompletionStage} and through any sink the delegate
+ * feeds.
+ */
+ abstract static class BoundedBodySubscriber implements BodySubscriber {
+
+ private final BodySubscriber delegate;
+
+ protected final int maxSize;
+
+ /**
+ * What the bound applies to, e.g. {@code "Inbound line"}, used to build the
+ * failure message.
+ */
+ private final String boundedEntity;
+
+ private Flow.Subscription subscription;
+
+ private volatile boolean done = false;
+
+ BoundedBodySubscriber(BodySubscriber delegate, int maxSize, String boundedEntity) {
+ this.delegate = delegate;
+ this.maxSize = maxSize;
+ this.boundedEntity = boundedEntity;
+ }
+
+ @Override
+ public CompletionStage getBody() {
+ return this.delegate.getBody();
+ }
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ this.subscription = subscription;
+ this.delegate.onSubscribe(subscription);
+ }
+
+ @Override
+ public void onNext(List buffers) {
+ if (this.done) {
+ return;
+ }
+ for (ByteBuffer buffer : buffers) {
+ if (!checkSize(buffer)) {
+ this.done = true;
+ this.subscription.cancel();
+ this.delegate.onError(new McpTransportException(
+ this.boundedEntity + " exceeds the maximum allowed size of " + this.maxSize + " bytes"));
+ return;
+ }
+ }
+ this.delegate.onNext(buffers);
+ }
+
+ /**
+ * Accounts for the bytes in {@code buffer}, which must be inspected with absolute
+ * reads only so the delegate still sees the original position.
+ * @param buffer the buffer about to be handed to the delegate
+ * @return {@code true} to accept the buffer, or {@code false} to abort the
+ * response because the bound has been exceeded
+ */
+ protected abstract boolean checkSize(ByteBuffer buffer);
+
+ @Override
+ public void onError(Throwable throwable) {
+ if (this.done) {
+ return;
+ }
+ this.done = true;
+ this.delegate.onError(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ if (this.done) {
+ return;
+ }
+ this.done = true;
+ this.delegate.onComplete();
+ }
+
+ }
+
+ /**
+ * A {@link BoundedBodySubscriber} that aborts the response once a single line (a run
+ * of bytes with no CR/LF terminator) exceeds {@code maxSize} bytes.
+ *
+ *
+ * {@link HttpResponse.BodySubscribers#fromLineSubscriber} buffers characters until it
+ * encounters a line terminator, so a peer that never terminates a line (or sends an
+ * enormous one) would force the transport to buffer it in memory. This wrapper counts
+ * bytes as they arrive off the wire and cancels the subscription before that buffer
+ * can grow without bound.
+ */
+ static final class BoundedLineBodySubscriber extends BoundedBodySubscriber {
+
+ private long bytesSinceLineTerminator = 0;
+
+ BoundedLineBodySubscriber(BodySubscriber delegate, int maxSize) {
+ super(delegate, maxSize, "Inbound line");
+ }
+
+ @Override
+ protected boolean checkSize(ByteBuffer buffer) {
+ int position = buffer.position();
+ int limit = buffer.limit();
+ if (position == limit) {
+ return true;
+ }
+ if (this.bytesSinceLineTerminator + (limit - position) <= this.maxSize) {
+ // No line ending in this buffer can exceed the limit, because there are
+ // not enough bytes since the last terminator for one to. Only the
+ // trailing (still unterminated) run matters, so scan back to the last
+ // terminator instead of walking every byte.
+ this.bytesSinceLineTerminator = lengthOfTrailingRun(buffer, position, limit);
+ return true;
+ }
+ // The limit is within reach, so account for every line exactly.
+ for (int i = position; i < limit; i++) {
+ byte b = buffer.get(i);
+ if (b == '\n' || b == '\r') {
+ this.bytesSinceLineTerminator = 0;
+ }
+ else if (++this.bytesSinceLineTerminator > this.maxSize) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Returns the number of bytes after the last line terminator in the buffer, or
+ * the whole span added to the running count when the buffer holds no terminator.
+ */
+ private long lengthOfTrailingRun(ByteBuffer buffer, int position, int limit) {
+ for (int i = limit - 1; i >= position; i--) {
+ byte b = buffer.get(i);
+ if (b == '\n' || b == '\r') {
+ return limit - 1 - i;
+ }
+ }
+ return this.bytesSinceLineTerminator + (limit - position);
+ }
+
+ }
+
+ /**
+ * A {@link BoundedBodySubscriber} that aborts the response once the body as a whole
+ * exceeds {@code maxSize} bytes. Suitable for delegates that aggregate the entire
+ * body in memory, such as {@link HttpResponse.BodyHandlers#ofString()}.
+ */
+ static final class BoundedTotalBodySubscriber extends BoundedBodySubscriber {
+
+ private long totalBytes = 0;
+
+ BoundedTotalBodySubscriber(BodySubscriber delegate, int maxSize) {
+ super(delegate, maxSize, "Inbound response body");
+ }
+
+ @Override
+ protected boolean checkSize(ByteBuffer buffer) {
+ this.totalBytes += buffer.remaining();
+ return this.totalBytes <= this.maxSize;
+ }
+
+ }
+
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
index e73e43e..60a8d7c 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 class StdioClientTransport implements McpClientTransport {
this.jsonMapper = jsonMapper;
+ this.inputMaxSize = inputMaxSize;
+
this.errorSink = Sinks.many().unicast().onBackpressureBuffer();
// Start threads
@@ -239,16 +258,13 @@ public class StdioClientTransport implements McpClientTransport {
@Override
public Mono sendMessage(JSONRPCMessage message) {
- if (this.outboundSink.tryEmitNext(message).isSuccess()) {
- // TODO: essentially we could reschedule ourselves in some time and make
- // another attempt with the already read data but pause reading until
- // success
- // In this approach we delegate the retry and the backpressure onto the
- // caller. This might be enough for most cases.
+ try {
+ // busyLooping retries FAIL_NON_SERIALIZED under concurrent senders
+ this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
return Mono.empty();
}
- else {
- return Mono.error(new RuntimeException("Failed to enqueue message"));
+ catch (Sinks.EmissionException e) {
+ return Mono.error(new RuntimeException("Failed to enqueue message", e));
}
}
@@ -260,7 +276,7 @@ public class StdioClientTransport implements McpClientTransport {
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 +294,11 @@ public class StdioClientTransport implements McpClientTransport {
}
}
}
+ 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 +311,37 @@ public class StdioClientTransport implements McpClientTransport {
});
}
+ /**
+ * 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/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java
index 756b39c..f2951d8 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java
@@ -7,18 +7,26 @@ package io.modelcontextprotocol.client.transport.customizer;
import java.net.URI;
import java.net.http.HttpRequest;
+import io.modelcontextprotocol.client.McpClient.SyncSpec;
+import io.modelcontextprotocol.common.McpTransportContext;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.annotation.Nullable;
-import io.modelcontextprotocol.common.McpTransportContext;
-
/**
* Customize {@link HttpRequest.Builder} before executing the request, in either SSE or
* Streamable HTTP transport.
*
* When used in a non-blocking context, implementations MUST be non-blocking.
+ *
+ * The {@link McpTransportContext} handed to {@code customize} is read from the Reactor
+ * context, under {@link McpTransportContext#KEY}, and is
+ * {@link McpTransportContext#EMPTY} when the caller wrote nothing there. Write it once
+ * where the reactive chain starts, with
+ * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}, rather than at
+ * every call site. With a synchronous client, configure
+ * {@link SyncSpec#transportContextProvider} instead.
*
* @author Daniel Garnier-Moiroux
*/
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 cde637b..322aa3a 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/common/McpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
index 46a2ccf..4691c13 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
@@ -11,13 +11,28 @@ import java.util.Map;
* Context associated with the transport layer. It allows to add transport-level metadata
* for use further down the line. Specifically, it can be beneficial to extract HTTP
* request metadata for use in MCP feature implementations.
+ *
+ * The context travels in the Reactor context, under {@link #KEY}. On the server side, the
+ * transports populate it from the incoming request. On the client side, writing it is the
+ * caller's responsibility:
+ *
+ * with a synchronous client, configure
+ * {@code McpClient.SyncSpec#transportContextProvider(Supplier)}, which is invoked on the
+ * calling thread before every operation;
+ * with an asynchronous client, write it into the Reactor context once, where the
+ * reactive chain starts, using
+ * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}. Every client
+ * call downstream inherits it, so there is no need to repeat it at each call site.
+ *
*
* @author Dariusz Jędrzejczyk
*/
public interface McpTransportContext {
/**
- * Key for use in Reactor Context to transport the context to user land.
+ * Key for use in Reactor Context to transport the context to user land. Write the
+ * context under this key to make it visible to the transport, for example
+ * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}.
*/
String KEY = "MCP_TRANSPORT_CONTEXT";
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java
index 7eed339..6e89ab7 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java
@@ -57,6 +57,11 @@ public interface JsonSchemaValidator {
/**
* Validates the structured content against the provided JSON schema.
+ *
+ * If {@code structuredContent} is a {@link String}, it is treated as a serialized
+ * JSON document and parsed before validation; quote embedded strings accordingly (for
+ * example {@code "\"red\""} for the JSON string value {@code red}). Any other type is
+ * converted to its JSON representation directly.
* @param schema The JSON schema to validate against.
* @param structuredContent The structured content to validate.
* @return A ValidationResponse indicating whether the validation was successful or
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 def40d5..5cd5de7 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 @@ class DefaultMcpStatelessServerHandler implements McpStatelessServerHandler {
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/McpAsyncListFilter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java
new file mode 100644
index 0000000..d6b1858
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import java.util.List;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.util.Assert;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+/**
+ * Decide per request whether a primitive is advertised in the corresponding listing, such
+ * as {@code tools/list}.
+ *
+ * A primitive hidden by this filter is omitted from listings ONLY. It remains reachable
+ * through its own endpoint: a hidden tool called by name still executes. Permissions MUST
+ * be enforced in the primitive's handler.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @see McpSyncListFilter
+ * @see McpTransportContextExtractor
+ */
+@FunctionalInterface
+public interface McpAsyncListFilter {
+
+ /**
+ * Whether the given primitive is visible to the caller of the current request.
+ * @param transportContext transport context containing, for example, HTTP headers or
+ * a resolved principal. Should never be {@code null}, but may
+ * {@link McpTransportContext#EMPTY} for transports that carry no per-request
+ * metadata, such as STDIO.
+ * @param primitive the primitive that is a candidate for inclusion in the listing,
+ * such as {@link Tool}.
+ * @return a publisher emitting {@code true} to include the primitive in the listing,
+ * {@code false} to omit it. Completing empty omits the primitive; erroring fails the
+ * listing request.
+ */
+ Mono isVisible(McpTransportContext transportContext, T primitive);
+
+ /**
+ * Convert a potentially blocking, synchronous filter into an asynchronous one,
+ * offloading it to prevent accidental blocking of a non-blocking transport.
+ * @param filter the synchronous filter. MUST NOT be null.
+ * @param immediateExecution When true, do not offload work asynchronously. Do NOT set
+ * to true when the filter performs blocking I/O.
+ */
+ static McpAsyncListFilter fromSync(McpSyncListFilter filter, boolean immediateExecution) {
+ Assert.notNull(filter, "filter must not be null");
+ return (transportContext, primitive) -> {
+ var visible = Mono.fromCallable(() -> filter.isVisible(transportContext, primitive));
+ return immediateExecution ? visible : visible.subscribeOn(Schedulers.boundedElastic());
+ };
+ }
+
+ /**
+ * Combine multiple filters in a single AND-filter. An empty or {@code null} list
+ * makes everything visible, keeping listing on a single code path when nothing is
+ * configured.
+ * @param filters the filters to combine. May be {@code null} or empty, but MUST NOT
+ * contain {@code null} elements.
+ */
+ static McpAsyncListFilter and(List> filters) {
+ if (filters == null || filters.isEmpty()) {
+ return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
+ }
+ Assert.noNullElements(filters, "filters must not contain null elements");
+ if (filters.size() == 1) {
+ return filters.get(0);
+ }
+ List> snapshot = List.copyOf(filters);
+ return (transportContext, primitive) -> Flux.fromIterable(snapshot)
+ .concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE))
+ .all(Boolean.TRUE::equals);
+ }
+
+}
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 83aa8f5..fc1f8a4 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
@@ -118,6 +118,8 @@ public class McpAsyncServer {
private final ConcurrentHashMap> resourceSubscriptions = new ConcurrentHashMap<>();
+ private final McpAsyncListFilter toolFilter;
+
private List protocolVersions;
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory();
@@ -146,6 +148,7 @@ public class McpAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
+ this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = prepareRequestHandlers();
Map notificationHandlers = prepareNotificationHandlers(features);
@@ -177,6 +180,7 @@ public class McpAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
+ this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = prepareRequestHandlers();
Map notificationHandlers = prepareNotificationHandlers(features);
@@ -537,12 +541,32 @@ public class McpAsyncServer {
private McpRequestHandler toolsListRequestHandler() {
return (exchange, params) -> {
- List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
-
- return Mono.just(McpSchema.ListToolsResult.builder(tools).build());
+ // TODO: Implement pagination. Cursors must be computed over the filtered
+ // view, otherwise page offsets leak the number of hidden tools.
+ return Flux.fromIterable(this.tools)
+ .map(McpServerFeatures.AsyncToolSpecification::tool)
+ .filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool)
+ .onErrorResume(error -> opaqueListFilterError(tool, error)))
+ .collectList()
+ .map(tools -> McpSchema.ListToolsResult.builder(tools).build());
};
}
+ /**
+ * Report a list filter failure to the client as an opaque {@code -32603} error, so
+ * that filter internals such as identity provider hostnames or the reason a principal
+ * was rejected never leave the server. The actual cause is logged instead. An
+ * {@link McpError} is deliberate on the filter's part and passes through untouched.
+ */
+ private static Mono opaqueListFilterError(Tool tool, Throwable error) {
+ if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
+ logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error);
+ return Mono.error(mcpError);
+ }
+ logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error);
+ return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build());
+ }
+
private McpRequestHandler toolsCallRequestHandler() {
return (exchange, params) -> {
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,
@@ -1090,9 +1114,7 @@ public class McpAsyncServer {
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/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
index a2333ae..2113fdb 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
@@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
+import java.util.function.Consumer;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
@@ -238,7 +239,7 @@ public interface McpServer {
public McpAsyncServer build() {
var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
- this.instructions);
+ this.instructions, this.toolFilters);
var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -268,7 +269,7 @@ public interface McpServer {
public McpAsyncServer build() {
var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
- this.instructions);
+ this.instructions, this.toolFilters);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -301,6 +302,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -440,6 +443,43 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler. Tools are NOT hidden from
+ * {@code notifications/tools/list_changed}, as it is a per-client context rather
+ * than per-request. Consider disabling list changed notifications entirely when
+ * using filters.
+ *
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpAsyncListFilter
+ */
+ public AsyncSpecification addToolFilter(McpAsyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpAsyncListFilter)
+ */
+ public AsyncSpecification toolFilters(
+ Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -830,7 +870,7 @@ public interface McpServer {
public McpSyncServer build() {
McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities,
this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions,
- this.rootsChangeHandlers, this.instructions);
+ this.rootsChangeHandlers, this.instructions, this.toolFilters);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
@@ -865,7 +905,7 @@ public interface McpServer {
public McpSyncServer build() {
McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities,
this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions,
- this.rootsChangeHandlers, this.instructions);
+ this.rootsChangeHandlers, this.instructions, this.toolFilters);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
@@ -900,6 +940,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -1043,6 +1085,44 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler. Tools are NOT hidden from
+ * {@code notifications/tools/list_changed}, as it is a per-client context rather
+ * than per-request. Consider disabling list changed notifications entirely when
+ * using filters.
+ *
+ * The filter is offloaded to a bounded elastic scheduler unless
+ * {@link #immediateExecution(boolean)} is set.
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpSyncListFilter
+ */
+ public SyncSpecification addToolFilter(McpSyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpSyncListFilter)
+ */
+ public SyncSpecification toolFilters(Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -1442,6 +1522,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -1582,6 +1664,40 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler.
+ *
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpAsyncListFilter
+ */
+ public StatelessAsyncSpecification addToolFilter(McpAsyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpAsyncListFilter)
+ */
+ public StatelessAsyncSpecification toolFilters(
+ Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -1908,7 +2024,8 @@ public interface McpServer {
public McpStatelessAsyncServer build() {
var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
- this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
+ this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions,
+ this.toolFilters);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -1942,6 +2059,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -2082,6 +2201,42 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler.
+ *
+ * The filter is offloaded to a bounded elastic scheduler unless
+ * {@link #immediateExecution(boolean)} is set.
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpSyncListFilter
+ */
+ public StatelessSyncSpecification addToolFilter(McpSyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpSyncListFilter)
+ */
+ public StatelessSyncSpecification toolFilters(
+ Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -2424,7 +2579,8 @@ public interface McpServer {
public McpStatelessSyncServer build() {
var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools,
- this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
+ this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions,
+ this.toolFilters);
var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
index cfa28e6..77ecbc8 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
@@ -38,6 +38,7 @@ public class McpServerFeatures {
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools, Map resources,
@@ -45,7 +46,7 @@ public class McpServerFeatures {
Map prompts,
Map completions,
List, Mono>> rootsChangeConsumers,
- String instructions) {
+ String instructions, List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -58,6 +59,7 @@ public class McpServerFeatures {
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools, Map resources,
@@ -65,7 +67,7 @@ public class McpServerFeatures {
Map prompts,
Map completions,
List, Mono>> rootsChangeConsumers,
- String instructions) {
+ String instructions, List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -91,6 +93,7 @@ public class McpServerFeatures {
this.completions = (completions != null) ? completions : Map.of();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
/**
@@ -138,7 +141,11 @@ public class McpServerFeatures {
}
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates,
- prompts, completions, rootChangeConsumers, syncSpec.instructions());
+ prompts, completions, rootChangeConsumers, syncSpec.instructions(),
+ syncSpec.toolFilters()
+ .stream()
+ .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution))
+ .toList());
}
}
@@ -161,7 +168,8 @@ public class McpServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- List>> rootsChangeConsumers, String instructions) {
+ List>> rootsChangeConsumers, String instructions,
+ List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -174,6 +182,7 @@ public class McpServerFeatures {
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -181,8 +190,8 @@ public class McpServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- List>> rootsChangeConsumers,
- String instructions) {
+ List>> rootsChangeConsumers, String instructions,
+ List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -208,6 +217,7 @@ public class McpServerFeatures {
this.completions = (completions != null) ? completions : new HashMap<>();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
}
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 c610526..95b694e 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
@@ -80,6 +80,8 @@ public class McpStatelessAsyncServer {
private final boolean validateToolInputs;
+ private final McpAsyncListFilter toolFilter;
+
McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper,
McpStatelessServerFeatures.Async features, Duration requestTimeout,
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
@@ -97,6 +99,7 @@ public class McpStatelessAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
+ this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = new HashMap<>();
@@ -133,10 +136,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
// ---------------------------------------
@@ -401,13 +420,32 @@ public class McpStatelessAsyncServer {
private McpStatelessRequestHandler toolsListRequestHandler() {
return (ctx, params) -> {
- List tools = this.tools.stream()
+ // TODO: Implement pagination. Cursors must be computed over the filtered
+ // view, otherwise page offsets leak the number of hidden tools.
+ return Flux.fromIterable(this.tools)
.map(McpStatelessServerFeatures.AsyncToolSpecification::tool)
- .toList();
- return Mono.just(McpSchema.ListToolsResult.builder(tools).build());
+ .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool)
+ .onErrorResume(error -> opaqueListFilterError(tool, error)))
+ .collectList()
+ .map(tools -> McpSchema.ListToolsResult.builder(tools).build());
};
}
+ /**
+ * Report a list filter failure to the client as an opaque {@code -32603} error, so
+ * that filter internals such as identity provider hostnames or the reason a principal
+ * was rejected never leave the server. The actual cause is logged instead. An
+ * {@link McpError} is deliberate on the filter's part and passes through untouched.
+ */
+ private static Mono opaqueListFilterError(Tool tool, Throwable error) {
+ if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
+ logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error);
+ return Mono.error(mcpError);
+ }
+ logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error);
+ return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build());
+ }
+
private McpStatelessRequestHandler toolsCallRequestHandler() {
return (ctx, params) -> {
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,
@@ -815,9 +853,7 @@ public class McpStatelessAsyncServer {
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/McpStatelessServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
index 0c1fbfb..8ffc2a6 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
@@ -37,6 +37,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -44,7 +45,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -55,6 +56,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -62,7 +64,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -84,6 +86,7 @@ public class McpStatelessServerFeatures {
this.prompts = (prompts != null) ? prompts : Map.of();
this.completions = (completions != null) ? completions : Map.of();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
/**
@@ -123,7 +126,11 @@ public class McpStatelessServerFeatures {
});
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates,
- prompts, completions, syncSpec.instructions());
+ prompts, completions, syncSpec.instructions(),
+ syncSpec.toolFilters()
+ .stream()
+ .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution))
+ .toList());
}
}
@@ -137,6 +144,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -144,7 +152,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -155,6 +163,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -162,7 +171,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -187,6 +196,7 @@ public class McpStatelessServerFeatures {
this.prompts = (prompts != null) ? prompts : new HashMap<>();
this.completions = (completions != null) ? completions : new HashMap<>();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java
new file mode 100644
index 0000000..01d4d07
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.spec.McpSchema;
+
+/**
+ * Decide per request whether a primitive is advertised in the corresponding listing, such
+ * as {@code tools/list}.
+ *
+ * A primitive hidden by this filter is omitted from listings ONLY. It remains reachable
+ * through its own endpoint: a hidden tool called by name still executes. Permissions MUST
+ * be enforced in the primitive's handler.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @see McpAsyncListFilter
+ * @see McpTransportContextExtractor
+ */
+@FunctionalInterface
+public interface McpSyncListFilter {
+
+ /**
+ * Whether the given primitive is visible to the caller of the current request.
+ * @param transportContext transport context containing, for example, HTTP headers or
+ * a resolved principal. Should never be {@code null}, but may
+ * {@link McpTransportContext#EMPTY} for transports that carry no per-request
+ * metadata, such as STDIO.
+ * @param primitive the primitive that is a candidate for inclusion in the listing,
+ * such as {@link McpSchema.Tool}.
+ * @return {@code true} to include the primitive in the listing, {@code false} to omit
+ * it. Throwing an exception fails the listing request.
+ */
+ boolean isVisible(McpTransportContext transportContext, T primitive);
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java
index e96403e..725a67a 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java
@@ -7,6 +7,7 @@ package io.modelcontextprotocol.server.transport;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import io.modelcontextprotocol.util.Assert;
@@ -22,7 +23,8 @@ import io.modelcontextprotocol.util.Assert;
* @see ServerTransportSecurityValidator
* @see ServerTransportSecurityException
*/
-public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator {
+public final class DefaultServerTransportSecurityValidator
+ implements ServerTransportSecurityValidator, ServerHttpHeaderValidator {
private static final String ORIGIN_HEADER = "Origin";
@@ -47,27 +49,24 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
}
@Override
+ @Deprecated
public void validateHeaders(Map> headers) throws ServerTransportSecurityException {
- boolean missingHost = true;
- for (Map.Entry> entry : headers.entrySet()) {
- if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) {
- List values = entry.getValue();
- if (values == null || values.isEmpty()) {
- throw new ServerTransportSecurityException(403, "Invalid Origin header");
- }
- validateOrigin(values.get(0));
- }
- else if (HOST_HEADER.equalsIgnoreCase(entry.getKey())) {
- missingHost = false;
- List values = entry.getValue();
- if (values == null || values.isEmpty()) {
- throw new ServerTransportSecurityException(421, "Invalid Host header");
- }
- validateHost(values.get(0));
- }
+ validate(new MapHeaderAccessor(headers));
+ }
+
+ @Override
+ public void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException {
+ List originValues = headerAccessor.getHeader(ORIGIN_HEADER);
+ if (originValues != null && !originValues.isEmpty()) {
+ validateOrigin(originValues.get(0));
}
- if (!allowedHosts.isEmpty() && missingHost) {
- throw new ServerTransportSecurityException(421, "Invalid Host header");
+
+ if (!allowedHosts.isEmpty()) {
+ List hostValues = headerAccessor.getHeader(HOST_HEADER);
+ if (hostValues == null || hostValues.isEmpty()) {
+ throw new ServerTransportSecurityException(421, "Invalid Host header");
+ }
+ validateHost(hostValues.get(0));
}
}
@@ -139,6 +138,37 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
return new Builder();
}
+ /**
+ * {@link HeaderAccessor} view over a {@code Map>}, used to
+ * bridge the deprecated {@link #validateHeaders(Map)} to
+ * {@link #validate(HeaderAccessor)}.
+ */
+ private static final class MapHeaderAccessor implements HeaderAccessor {
+
+ private final Map> headers;
+
+ private MapHeaderAccessor(Map> headers) {
+ this.headers = headers;
+ }
+
+ @Override
+ public List getHeader(String name) {
+ return headers.entrySet()
+ .stream()
+ .filter(entry -> entry.getKey().equalsIgnoreCase(name))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(List.of());
+ }
+
+ @Override
+ public List getHeaderNames() {
+ return List.copyOf(headers.keySet());
+ }
+
+ }
+
/**
* Builder for creating instances of {@link DefaultServerTransportSecurityValidator}.
*/
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java
new file mode 100644
index 0000000..efed4a7
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.util.List;
+
+/**
+ * Abstraction for accessing HTTP headers from an incoming request. Implementations should
+ * provide case-insensitive header name lookups (e.g., when backed by
+ * {@code HttpServletRequest}).
+ *
+ * @author Neeraj Bhatt
+ * @since 2.1.0
+ * @see ServerHttpHeaderValidator
+ */
+public interface HeaderAccessor {
+
+ /**
+ * Returns the values of the specified header, or an empty list if the header is not
+ * present.
+ * @param name the header name (case-insensitive)
+ * @return the list of header values, never {@code null}
+ */
+ List getHeader(String name);
+
+ /**
+ * Returns all header names present in the request.
+ * @return the list of header names, never {@code null}
+ */
+ List getHeaderNames();
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java
new file mode 100644
index 0000000..a06261c
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.util.Collections;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * {@link HeaderAccessor} implementation backed by an {@link HttpServletRequest}. Header
+ * name lookups are case-insensitive as per the Servlet specification.
+ *
+ *
+ * For internal use only.
+ *
+ * @author Neeraj Bhatt
+ * @since 2.1.0
+ * @see HeaderAccessor
+ */
+final class HttpServletHeaderAccessor implements HeaderAccessor {
+
+ private final HttpServletRequest request;
+
+ HttpServletHeaderAccessor(HttpServletRequest request) {
+ this.request = request;
+ }
+
+ @Override
+ public List getHeader(String name) {
+ return Collections.list(this.request.getHeaders(name));
+ }
+
+ @Override
+ public List getHeaderNames() {
+ return Collections.list(this.request.getHeaderNames());
+ }
+
+}
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 3224694..5901c38 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,11 +4,10 @@
package io.modelcontextprotocol.server.transport;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import jakarta.servlet.http.HttpServletRequest;
@@ -23,18 +22,31 @@ final class HttpServletRequestUtils {
}
/**
- * Extracts all headers from the HTTP request into a map.
+ * 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
- * @return A map of header names to their values
+ * @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 Map> extractHeaders(HttpServletRequest request) {
- Map> headers = new HashMap<>();
- Enumeration names = request.getHeaderNames();
- while (names.hasMoreElements()) {
- String name = names.nextElement();
- headers.put(name, Collections.list(request.getHeaders(name)));
+ 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);
}
- return headers;
+ 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 69d73f7..a14271a 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 @@ import reactor.core.publisher.Mono;
@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
*/
@@ -152,7 +161,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
/**
* Security validator for validating HTTP requests.
*/
- private final ServerTransportSecurityValidator securityValidator;
+ private final ServerHttpHeaderValidator httpHeaderValidator;
/**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
@@ -165,25 +174,29 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param keepAliveInterval The interval for keep-alive pings, or null to disable
* keep-alive functionality
* @param contextExtractor The extractor for transport context from the request.
- * @param securityValidator The security validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header 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) {
+ ServerHttpHeaderValidator httpHeaderValidator, 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.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.baseUrl = baseUrl;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor;
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = httpHeaderValidator;
+ this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
@@ -280,8 +293,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -346,6 +358,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
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);
@@ -353,8 +370,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -392,15 +408,10 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
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 +419,9 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
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 {
@@ -603,7 +617,9 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private Duration keepAliveInterval;
- private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
+
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
/**
* Sets the JsonMapper implementation to use for serialization/deserialization. If
@@ -684,10 +700,38 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param securityValidator The security validator to use. Must not be null.
* @return This builder instance
* @throws IllegalArgumentException if securityValidator is null
+ * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
+ * instead.
*/
+ @Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP header validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator to use. Must not be null.
+ * @return This builder instance
+ * @throws IllegalArgumentException if httpHeaderValidator is null
+ */
+ public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ this.httpHeaderValidator = httpHeaderValidator;
+ 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;
}
@@ -703,7 +747,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint,
- sseEndpoint, keepAliveInterval, contextExtractor, securityValidator);
+ sseEndpoint, keepAliveInterval, contextExtractor, httpHeaderValidator, 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 047aeeb..9cd0d04 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,11 +4,9 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
-import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -39,6 +37,11 @@ import reactor.core.publisher.Mono;
@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";
@@ -64,20 +67,39 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
/**
* Security validator for validating HTTP requests.
*/
- private final ServerTransportSecurityValidator securityValidator;
+ private final ServerHttpHeaderValidator httpHeaderValidator;
+ /**
+ * 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 httpHeaderValidator The HTTP header 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) {
+ ServerHttpHeaderValidator httpHeaderValidator, 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.notNull(httpHeaderValidator, "HTTP header 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.httpHeaderValidator = httpHeaderValidator;
+ this.requestMaxSize = requestMaxSize;
}
@Override
@@ -133,9 +155,13 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
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);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -154,14 +180,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
}
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 +230,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
.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,
@@ -274,7 +298,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private McpTransportContextExtractor contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
- private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
+
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
private Builder() {
// used by a static method
@@ -326,10 +352,38 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
+ * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
+ * instead.
*/
+ @Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP header validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator to use. Must not be null.
+ * @return this builder instance
+ * @throws IllegalArgumentException if httpHeaderValidator is null
+ */
+ public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ this.httpHeaderValidator = httpHeaderValidator;
+ 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;
}
@@ -343,7 +397,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
- securityValidator);
+ httpHeaderValidator, 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 e6af4fd..5f883cb 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,41 +4,46 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
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;
import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSession;
import io.modelcontextprotocol.spec.McpStreamableServerSession;
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
+import io.modelcontextprotocol.spec.McpTransportException;
+import io.modelcontextprotocol.spec.ProtocolVersions;
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.AsyncEvent;
+import jakarta.servlet.AsyncListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
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.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
/**
* Server-side implementation of the Model Context Protocol (MCP) streamable transport
@@ -55,6 +60,7 @@ import reactor.core.publisher.Mono;
* @author Zachary German
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
+ * @author Daniel Garnier-Moiroux
* @see McpStreamableServerTransportProvider
* @see HttpServlet
*/
@@ -62,6 +68,11 @@ import reactor.core.publisher.Mono;
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 +111,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;
/**
@@ -107,6 +123,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
*/
private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+ /**
+ * IDs of the sessions which received a request since the last sweep. The set is
+ * swapped for an empty one on every sweep, so it only ever holds the activity of the
+ * current interval. Only populated when a sweeper runs, as nothing would ever swap it
+ * otherwise.
+ */
+ private final AtomicReference> activeSessions = new AtomicReference<>(ConcurrentHashMap.newKeySet());
+
private McpTransportContextExtractor contextExtractor;
/**
@@ -120,10 +144,18 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
*/
private KeepAliveScheduler keepAliveScheduler;
+ /**
+ * Periodic eviction of the sessions no client came back to. {@code null} if no
+ * sessionSweepInterval is set, in which case sessions are never reclaimed.
+ */
+ private final Disposable sessionSweeper;
+
+ private static Duration SESSION_SWEEP_TIMEOUT = Duration.ofSeconds(30);
+
/**
* Security validator for validating HTTP requests.
*/
- private final ServerTransportSecurityValidator securityValidator;
+ private final ServerHttpHeaderValidator httpHeaderValidator;
/**
* Constructs a new HttpServletStreamableServerTransportProvider instance.
@@ -135,34 +167,122 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param contextExtractor The extractor for transport context from the request.
* @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 httpHeaderValidator The HTTP header validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
+ * @param sessionSweepInterval The interval at which idle sessions are evicted. If
+ * null, no sweeping will be scheduled.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor contextExtractor,
- Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
+ Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize,
+ Duration sessionSweepInterval) {
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.notNull(httpHeaderValidator, "HTTP header 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.httpHeaderValidator = httpHeaderValidator;
+ this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
- this.keepAliveScheduler = KeepAliveScheduler
- .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values()))
+ this.keepAliveScheduler = KeepAliveScheduler.builder(this::sessionsToPing)
.initialDelay(keepAliveInterval)
.interval(keepAliveInterval)
+ .onPingFailure(session -> {
+ // The stream the ping was written to is dead. The session survives:
+ // the client may reconnect to it, and the idle timeout reclaims it if
+ // it never does.
+ if (session instanceof McpStreamableServerSession streamableSession) {
+ streamableSession.releaseListeningStream();
+ }
+ })
.build();
this.keepAliveScheduler.start();
}
+ this.sessionSweeper = sessionSweepInterval == null ? null : startSessionSweeper(sessionSweepInterval);
+ }
+
+ /**
+ * Schedules the periodic eviction of idle sessions.
+ * @param sessionSweepInterval the interval between two sweeps
+ * @return the handle on the scheduled sweeps, to be disposed when the transport shuts
+ * down
+ */
+ private Disposable startSessionSweeper(Duration sessionSweepInterval) {
+ return Flux
+ .interval(sessionSweepInterval, sessionSweepInterval,
+ Schedulers.newSingle("streamable-http-server-transport-session-sweeper"))
+ .concatMap(
+ tick -> sweepSessions().doOnError(e -> logger.error("Session sweep failed", e)).onErrorComplete())
+ .subscribe(next -> {
+ }, error -> logger.error("Session sweeper error", error));
+ }
+
+ /**
+ * Evicts the sessions no client is using anymore. A session is kept if it holds an
+ * open stream, which a client can legitimately sit on without ever writing to it, or
+ * if it received a request during the interval which just elapsed. Anything else is a
+ * session whose client went away without deleting it: the protocol lets a client
+ * reconnect to a session, so nothing else ever reclaims it.
+ * @return a Mono completing once every evicted session has been closed
+ */
+ private Mono sweepSessions() {
+ if (this.isClosing) {
+ return Mono.empty();
+ }
+ Set active = this.activeSessions.getAndSet(ConcurrentHashMap.newKeySet());
+ return Flux.fromIterable(this.sessions.values()).filter(session -> {
+ if (session.hasOpenStream() || active.contains(session.getId())) {
+ return false;
+ }
+ return this.sessions.remove(session.getId(), session);
+ }).flatMap(session -> {
+ logger.debug("Evicting idle session {}", session.getId());
+ return session.closeGracefully()
+ .timeout(SESSION_SWEEP_TIMEOUT)
+ .doOnError(e -> logger.warn("Failed to close idle session {}: {}", session.getId(), e.getMessage()))
+ .onErrorComplete();
+ }).then();
+ }
+
+ /**
+ * Records that the given session is being used, so that the next sweep does not
+ * mistake it for a session whose client is gone.
+ * @param sessionId the session the current request belongs to
+ */
+ private void markSessionActive(String sessionId) {
+ if (this.sessionSweeper == null) {
+ // No sweep ever swaps the set of active sessions, so recording activity would
+ // only accumulate the ID of every session which ever issued a request
+ return;
+ }
+ this.activeSessions.get().add(sessionId);
+ }
+
+ /**
+ * Returns the sessions a keep-alive ping can be sent to, that is the sessions having
+ * a listening stream. A session without one, e.g. a client which only ever issues
+ * POST requests, has nothing to write a ping to: pinging it would fail on every
+ * interval without ever telling us anything about the client being alive.
+ * @return the sessions to ping
+ */
+ private Flux sessionsToPing() {
+ if (this.isClosing) {
+ return Flux.empty();
+ }
+ return Flux.fromIterable(this.sessions.values())
+ .filter(McpStreamableServerSession::hasListeningStream)
+ .cast(McpSession.class);
}
@Override
@@ -237,6 +357,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
if (this.keepAliveScheduler != null) {
this.keepAliveScheduler.shutdown();
}
+ if (this.sessionSweeper != null) {
+ this.sessionSweeper.dispose();
+ }
});
}
@@ -263,8 +386,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -297,6 +419,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
+ this.markSessionActive(sessionId);
logger.debug("Handling GET request for session: {}", sessionId);
@@ -314,61 +437,21 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
sessionId, asyncContext, response.getWriter());
- // Check if this is a replay request
- if (request.getHeader(HttpHeaders.LAST_EVENT_ID) != null) {
- String lastId = request.getHeader(HttpHeaders.LAST_EVENT_ID);
-
- try {
- session.replay(lastId)
- .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
- .toIterable()
- .forEach(message -> {
- try {
- sessionTransport.sendMessage(message)
- .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
- .block();
- }
- catch (Exception e) {
- logger.error("Failed to replay message: {}", e.getMessage());
- asyncContext.complete();
- }
- });
- }
- catch (Exception e) {
- logger.error("Failed to replay messages: {}", e.getMessage());
- asyncContext.complete();
- }
+ // Replay the messages the client missed while its stream was broken
+ String lastEventId = request.getHeader(HttpHeaders.LAST_EVENT_ID);
+ if (lastEventId != null
+ && !this.tryReplayMissedMessages(session, lastEventId, sessionTransport, transportContext)) {
+ // The replay failed and already closed the transport
+ return;
}
- else {
- // Establish new listening stream
- McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
- .listeningStream(sessionTransport);
- asyncContext.addListener(new jakarta.servlet.AsyncListener() {
- @Override
- public void onComplete(jakarta.servlet.AsyncEvent event) throws IOException {
- logger.debug("SSE connection completed for session: {}", sessionId);
- listeningStream.close();
- }
+ // Establish the listening stream. Resumed streams are registered too, so
+ // that the session keeps delivering messages to the reconnected client and
+ // the async context is completed once the client goes away.
+ McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
+ .listeningStream(sessionTransport);
- @Override
- public void onTimeout(jakarta.servlet.AsyncEvent event) throws IOException {
- logger.debug("SSE connection timed out for session: {}", sessionId);
- listeningStream.close();
- }
-
- @Override
- public void onError(jakarta.servlet.AsyncEvent event) throws IOException {
- logger.debug("SSE connection error for session: {}", sessionId);
- listeningStream.close();
- }
-
- @Override
- public void onStartAsync(jakarta.servlet.AsyncEvent event) throws IOException {
- // No action needed
- }
- });
- }
+ registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport);
}
catch (Exception e) {
logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage());
@@ -397,10 +480,13 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
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);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -420,14 +506,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
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
@@ -444,6 +525,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
});
McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory
.startSession(initializeRequest);
+ this.markSessionActive(init.session().getId());
this.sessions.put(init.session().getId(), init.session());
try {
@@ -495,6 +577,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return;
}
+ this.markSessionActive(sessionId);
+
if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) {
session.accept(jsonrpcResponse)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
@@ -520,8 +604,16 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
sessionId, asyncContext, response.getWriter());
+ // The listener is given the stream rather than its transport, so that the
+ // end of the connection detaches the stream from the session instead of
+ // only dropping the socket: a stream outliving its connection keeps the
+ // session looking busy and spares it from the sweeper
+ McpStreamableServerSession.McpStreamableServerSessionStream responseStream = session
+ .responseStream(sessionTransport);
+ registerAsyncLifecycle(asyncContext, sessionId, responseStream::releaseTransport);
+
try {
- session.responseStream(jsonrpcRequest, sessionTransport)
+ responseStream.handle(jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
}
@@ -535,6 +627,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
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,
@@ -580,8 +675,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -673,6 +767,70 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
super.destroy();
}
+ /**
+ * Replays the messages the client missed while its SSE stream was broken.
+ * @param session the session the client is resuming
+ * @param lastEventId the ID of the last event received by the client
+ * @param sessionTransport the transport of the resumed SSE stream
+ * @param transportContext the context extracted from the request
+ * @return {@code true} if the replay completed, {@code false} if it failed, in which
+ * case the transport has been closed
+ */
+ private static boolean tryReplayMissedMessages(McpStreamableServerSession session, String lastEventId,
+ McpStreamableServerTransport sessionTransport, McpTransportContext transportContext) {
+ try {
+ for (McpSchema.JSONRPCMessage message : session.replay(lastEventId)
+ .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
+ .toIterable()) {
+ sessionTransport.sendMessage(message)
+ .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
+ .block();
+ }
+ return true;
+ }
+ catch (Exception e) {
+ logger.error("Failed to replay messages for session {}: {}", session.getId(), e.getMessage());
+ sessionTransport.close();
+ return false;
+ }
+ }
+
+ /**
+ * Registers a listener releasing the SSE stream carried by the given asynchronous
+ * request once the container is done with it, whether the client went away, the
+ * request timed out or it errored. Without this, the connection is left open, holding
+ * on to a socket and a container thread, until the process restarts.
+ * @param asyncContext the asynchronous context of the SSE request
+ * @param sessionId the session the stream belongs to
+ * @param onConnectionEnd the action releasing the stream
+ */
+ private static void registerAsyncLifecycle(AsyncContext asyncContext, String sessionId, Runnable onConnectionEnd) {
+ asyncContext.addListener(new AsyncListener() {
+ @Override
+ public void onComplete(AsyncEvent event) throws IOException {
+ logger.debug("SSE connection completed for session: {}", sessionId);
+ onConnectionEnd.run();
+ }
+
+ @Override
+ public void onTimeout(AsyncEvent event) throws IOException {
+ logger.debug("SSE connection timed out for session: {}", sessionId);
+ onConnectionEnd.run();
+ }
+
+ @Override
+ public void onError(AsyncEvent event) throws IOException {
+ logger.debug("SSE connection error for session: {}", sessionId);
+ onConnectionEnd.run();
+ }
+
+ @Override
+ public void onStartAsync(AsyncEvent event) throws IOException {
+ // No action needed
+ }
+ });
+ }
+
/**
* Implementation of McpStreamableServerTransport for HttpServlet SSE sessions. This
* class handles the transport-level communication for a specific client session.
@@ -682,7 +840,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* underlying PrintWriter to prevent race conditions when multiple threads attempt to
* send messages concurrently.
*/
-
private class HttpServletStreamableMcpSessionTransport implements McpStreamableServerTransport {
private final String sessionId;
@@ -746,9 +903,17 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId);
}
catch (Exception e) {
+ // The connection is gone, the session is not: the client may come
+ // back for it, and the sweeper reclaims it if it never does
logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage());
- HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
- this.asyncContext.complete();
+ this.close();
+ // Surfaced to the caller rather than swallowed: whoever is writing to
+ // this stream has to learn that it no longer leads anywhere, or it
+ // keeps producing messages for a client which is gone. A request
+ // being streamed a response would never finish, holding on to the
+ // container thread which has to be given back before the end of the
+ // connection can be acted upon.
+ throw new McpTransportException("Failed to send message to session " + this.sessionId, e);
}
finally {
lock.unlock();
@@ -792,8 +957,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
this.closed = true;
-
- // HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
this.asyncContext.complete();
logger.debug("Successfully completed async context for session {}", sessionId);
}
@@ -807,6 +970,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
+ @Override
+ public List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18,
+ ProtocolVersions.MCP_2025_11_25);
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -826,9 +995,13 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private McpTransportContextExtractor contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
- private Duration keepAliveInterval;
+ private Duration keepAliveInterval = Duration.ofMinutes(30);
- private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
+
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
+ private Duration sessionSweepInterval = Duration.ofMinutes(30);
/**
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
@@ -881,7 +1054,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* Sets the keep-alive interval for the transport. If set, a keep-alive scheduler
* will be activated to periodically ping active sessions.
* @param keepAliveInterval The interval for keep-alive pings. If null, no
- * keep-alive will be scheduled.
+ * keep-alive will be scheduled. Defaults to 30 minutes.
* @return this builder instance
*/
public Builder keepAliveInterval(Duration keepAliveInterval) {
@@ -894,10 +1067,54 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
+ * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
+ * instead.
*/
+ @Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP header validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator to use. Must not be null.
+ * @return this builder instance
+ * @throws IllegalArgumentException if httpHeaderValidator is null
+ */
+ public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ this.httpHeaderValidator = httpHeaderValidator;
+ 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;
+ }
+
+ /**
+ * Sets the interval at which idle sessions are evicted. A session is idle once it
+ * holds no open stream and has received no request for a full interval, which is
+ * how a session whose client went away without deleting it looks. Nothing else
+ * reclaims those sessions, as the protocol lets a client reconnect to a session
+ * it has been disconnected from.
+ * @param sessionSweepInterval The interval between two sweeps. If null, no
+ * sweeping will be scheduled and sessions are kept until they are deleted or the
+ * server shuts down. Defaults to 30 minutes.
+ * @return this builder instance
+ */
+ public Builder sessionSweepInterval(Duration sessionSweepInterval) {
+ this.sessionSweepInterval = sessionSweepInterval;
return this;
}
@@ -911,7 +1128,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete,
- contextExtractor, keepAliveInterval, securityValidator);
+ contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize, sessionSweepInterval);
}
}
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 0000000..ddca5db
--- /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/ServerHttpHeaderValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java
new file mode 100644
index 0000000..6f2a3dc
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+/**
+ * Validates HTTP request headers in server transports.
+ *
+ * @author Neeraj Bhatt
+ * @see HeaderAccessor
+ * @see ServerTransportSecurityException
+ */
+@FunctionalInterface
+public interface ServerHttpHeaderValidator {
+
+ /**
+ * A no-op validator that accepts all requests without validation.
+ */
+ ServerHttpHeaderValidator NOOP = headerAccessor -> {
+ };
+
+ /**
+ * Validates the HTTP headers from an incoming request.
+ * @param headerAccessor provides access to request headers
+ * @throws ServerTransportSecurityException if validation fails
+ */
+ void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException;
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java
index ce80593..023f7f3 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java
@@ -6,6 +6,8 @@ package io.modelcontextprotocol.server.transport;
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
/**
* Interface for validating HTTP requests in server transports. Implementations can
@@ -13,9 +15,12 @@ import java.util.Map;
* to the MCP specification.
*
* @author Daniel Garnier-Moiroux
- * @see DefaultServerTransportSecurityValidator
+ * @see ServerHttpHeaderValidator
* @see ServerTransportSecurityException
+ * @deprecated Use {@link ServerHttpHeaderValidator} instead. This interface will be
+ * removed in a future major version.
*/
+@Deprecated
@FunctionalInterface
public interface ServerTransportSecurityValidator {
@@ -25,6 +30,20 @@ public interface ServerTransportSecurityValidator {
ServerTransportSecurityValidator NOOP = headers -> {
};
+ /**
+ * Adapts a legacy map-based validator to a header accessor validator.
+ * @param validator the legacy validator to adapt
+ * @return a header accessor validator delegating to {@code validator}
+ */
+ static ServerHttpHeaderValidator toHttpHeaderValidator(ServerTransportSecurityValidator validator) {
+ return accessor -> {
+ var collectedHeaders = accessor.getHeaderNames()
+ .stream()
+ .collect(Collectors.toUnmodifiableMap(Function.identity(), accessor::getHeader));
+ validator.validateHeaders(collectedHeaders);
+ };
+ }
+
/**
* Validates the HTTP headers from an incoming request.
* @param headers A map of header names to their values (multi-valued headers
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 045d7e3..8370e41 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
@@ -10,13 +10,13 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import io.modelcontextprotocol.json.TypeRef;
-import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.spec.McpServerSession;
@@ -41,6 +41,8 @@ import reactor.core.scheduler.Schedulers;
*/
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 class StdioServerTransportProvider implements McpServerTransportProvider
* @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
@@ -155,11 +175,12 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
public Mono sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> {
- if (outboundSink.tryEmitNext(message).isSuccess()) {
+ try {
+ outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
return Mono.empty();
}
- else {
- return Mono.error(new RuntimeException("Failed to enqueue message"));
+ catch (Sinks.EmissionException e) {
+ return Mono.error(new RuntimeException("Failed to enqueue message", e));
}
}));
}
@@ -211,7 +232,7 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
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 +253,10 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
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 +329,36 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
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 5bb5c38..5ac0863 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
@@ -1,29 +1,31 @@
/*
- * Copyright 2024-2025 the original author or authors.
+ * Copyright 2024-2026 the original author or authors.
*/
package io.modelcontextprotocol.spec;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Predicate;
import java.util.function.Supplier;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import io.modelcontextprotocol.json.TypeRef;
-
import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.util.Assert;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
@@ -36,12 +38,26 @@ import reactor.core.publisher.MonoSink;
*
* @author Dariusz Jędrzejczyk
* @author Yanming Zhou
+ * @author Daniel Garnier-Moiroux
*/
public class McpStreamableServerSession implements McpLoggableSession {
private static final Logger logger = LoggerFactory.getLogger(McpStreamableServerSession.class);
- private final ConcurrentHashMap requestIdToStream = new ConcurrentHashMap<>();
+ /**
+ * Every server-initiated request still awaiting its response, keyed by request ID.
+ * Tracked per session rather than per stream, because the client answers over a
+ * separate HTTP POST which outlives the stream the request was sent on: a stream
+ * going away must not lose the requests it carried, but the end of the session must
+ * resolve all of them.
+ */
+ private final ConcurrentHashMap pendingRequests = new ConcurrentHashMap<>();
+
+ /**
+ * Every stream with a connection currently attached, whether the listening stream or
+ * a POST response stream, so that they can all be released when the session ends.
+ */
+ private final Set openStreams = ConcurrentHashMap.newKeySet();
private final String id;
@@ -179,61 +195,91 @@ public class McpStreamableServerSession implements McpLoggableSession {
}
/**
- * Create a listening stream (the generic HTTP GET request without Last-Event-ID
- * header).
+ * Whether the session currently has a listening stream, that is a stream the server
+ * can send its own requests and notifications to. Sessions have none until the client
+ * issues the GET request establishing one, and clients are not required to ever issue
+ * it.
+ * @return {@code true} if the session has a listening stream
+ */
+ public boolean hasListeningStream() {
+ return this.listeningStreamRef.get() instanceof McpStreamableServerSessionStream;
+ }
+
+ /**
+ * Whether the session currently has at least one stream with a connection attached,
+ * whether the listening stream or a POST response stream. A client holding such a
+ * connection open is still there, however long it stays silent on it.
+ * @return {@code true} if the session has an open stream
+ */
+ public boolean hasOpenStream() {
+ return !this.openStreams.isEmpty();
+ }
+
+ /**
+ * Create a listening stream (the generic HTTP GET request, with or without a
+ * Last-Event-ID header). A session addresses a single listening stream at a time, so
+ * the stream being replaced, if any, is closed: no message would ever be sent to it
+ * again, and leaving it open would leak the underlying connection.
* @param transport The dedicated SSE transport stream
* @return a stream representation
*/
public McpStreamableServerSessionStream listeningStream(McpStreamableServerTransport transport) {
McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream(transport);
- this.listeningStreamRef.set(listeningStream);
+ McpLoggableSession replaced = this.listeningStreamRef.getAndSet(listeningStream);
+ if (replaced instanceof McpStreamableServerSessionStream replacedStream) {
+ logger.debug("Releasing the connection of the listening stream replaced in session {}", this.id);
+ replacedStream.releaseTransport();
+ }
return listeningStream;
}
+ /**
+ * Releases the connection of the listening stream, if one is attached, leaving the
+ * session without one until the client establishes a new stream. Used when the
+ * connection turns out to be dead, typically because a keep-alive ping went
+ * unanswered, so that the socket behind it is not held on to for nothing.
+ */
+ public void releaseListeningStream() {
+ if (this.listeningStreamRef.get() instanceof McpStreamableServerSessionStream stream) {
+ stream.releaseTransport();
+ }
+ }
+
// TODO: keep track of history by keeping a map from eventId to stream and then
// iterate over the events using the lastEventId
public Flux replay(Object lastEventId) {
return Flux.empty();
}
+ /**
+ * Create a response stream (the SSE stream of a single HTTP POST request, finalized
+ * with the response to the request it carries). The caller owns the returned stream
+ * and is responsible for releasing it once the connection behind it ends, the same
+ * way it does for a {@link #listeningStream(McpStreamableServerTransport)}: a stream
+ * outliving its connection keeps the session looking busy, see
+ * {@link #hasOpenStream()}.
+ * @param transport the SSE transport stream to send messages to
+ * @return a stream representation, on which
+ * {@link McpStreamableServerSessionStream#handle(McpSchema.JSONRPCRequest)} runs the
+ * request
+ */
+ public McpStreamableServerSessionStream responseStream(McpStreamableServerTransport transport) {
+ return new McpStreamableServerSessionStream(transport);
+ }
+
/**
* Provide the SSE stream of MCP messages finalized with a Response.
* @param jsonrpcRequest the MCP request triggering the stream creation
* @param transport the SSE transport stream to send messages to
* @return Mono which completes once the processing is done
+ * @deprecated the stream created for the request is not exposed, which leaves the
+ * caller unable to release it when the connection carrying it ends. Use
+ * {@link #responseStream(McpStreamableServerTransport)} and
+ * {@link McpStreamableServerSessionStream#handle(McpSchema.JSONRPCRequest)} instead.
*/
+ @Deprecated
public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStreamableServerTransport transport) {
- return Mono.deferContextual(ctx -> {
- McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
-
- McpStreamableServerSessionStream stream = new McpStreamableServerSessionStream(transport);
- McpRequestHandler> requestHandler = McpStreamableServerSession.this.requestHandlers
- .get(jsonrpcRequest.method());
- // TODO: delegate to stream, which upon successful response should close
- // remove itself from the registry and also close the underlying transport
- // (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 requestHandler
- .handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(),
- transportContext, this.jsonSchemaValidator), jsonrpcRequest.params())
- .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result))
- .onErrorResume(e -> {
- McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError
- && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError()
- : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
- e.getMessage(), McpError.aggregateExceptionMessages(e));
-
- var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError);
- return Mono.just(errorResponse);
- })
- .flatMap(transport::sendMessage)
- .then(transport.closeGracefully());
- });
+ return Mono.defer(() -> this.responseStream(transport).handle(jsonrpcRequest));
}
/**
@@ -267,22 +313,13 @@ public class McpStreamableServerSession implements McpLoggableSession {
logger.debug("Received response: {}", response);
if (response.id() != null) {
- var stream = this.requestIdToStream.get(response.id());
- if (stream == null) {
+ var pendingRequest = this.pendingRequests.remove(response.id());
+ if (pendingRequest == null) {
return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR)
.message("Unexpected response for unknown id " + response.id())
.build());
}
- // TODO: encapsulate this inside the stream itself
- var sink = stream.pendingResponses.remove(response.id());
- if (sink == null) {
- return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR)
- .message("Unexpected response for unknown id " + response.id())
- .build());
- }
- else {
- sink.success(response);
- }
+ pendingRequest.sink().success(response);
}
else {
logger.error("Discarded MCP request response without session id. "
@@ -300,23 +337,49 @@ public class McpStreamableServerSession implements McpLoggableSession {
return new MethodNotFoundError(method, "Method not found: " + method, null);
}
+ /**
+ * Fails the matching pending requests with the given reason. Each request is removed
+ * before its sink is failed, so that the cleanup the failure triggers on the
+ * requesting side finds nothing left to remove.
+ * @param match selects the requests to fail
+ * @param reason the message of the error the sinks are failed with
+ */
+ private void failPendingRequests(Predicate match, String reason) {
+ List requestIds = new ArrayList<>();
+ this.pendingRequests.forEach((requestId, pendingRequest) -> {
+ if (match.test(pendingRequest)) {
+ requestIds.add(requestId);
+ }
+ });
+ for (String requestId : requestIds) {
+ PendingRequest pendingRequest = this.pendingRequests.remove(requestId);
+ if (pendingRequest != null) {
+ pendingRequest.sink().error(new RuntimeException(reason));
+ }
+ }
+ }
+
@Override
public Mono closeGracefully() {
return this.onClose.get().onErrorComplete().then(Mono.defer(() -> {
- McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
- return listeningStream.closeGracefully();
- // TODO: Also close all the open streams
+ this.listeningStreamRef.set(this.missingMcpTransportSession);
+ return Flux.fromIterable(List.copyOf(this.openStreams))
+ .flatMap(McpStreamableServerSessionStream::closeGracefully)
+ .then()
+ // Also the requests of the streams already released: the session ending
+ // is the point at which no client can answer them anymore
+ .then(Mono.fromRunnable(() -> this.failPendingRequests(request -> true, "Session closed")));
}));
}
@Override
public void close() {
this.onClose.get().onErrorComplete().subscribe();
- McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
- if (listeningStream != null) {
- listeningStream.close();
- }
- // TODO: Also close all open streams
+ this.listeningStreamRef.set(this.missingMcpTransportSession);
+ List.copyOf(this.openStreams).forEach(McpStreamableServerSessionStream::close);
+ // Also the requests of the streams already released: the session ending is the
+ // point at which no client can answer them anymore
+ this.failPendingRequests(request -> true, "Session closed");
}
/**
@@ -358,15 +421,23 @@ public class McpStreamableServerSession implements McpLoggableSession {
Mono initResult) {
}
+ /**
+ * A server-initiated request awaiting its response, along with the stream it was sent
+ * on.
+ *
+ * @param stream the stream the request was sent on
+ * @param sink the sink to resolve once the response arrives
+ */
+ private record PendingRequest(McpStreamableServerSessionStream stream, MonoSink sink) {
+ }
+
/**
* An individual SSE stream within a Streamable HTTP context. Can be either the
* listening GET SSE stream or a request-specific POST SSE stream.
*/
public final class McpStreamableServerSessionStream implements McpLoggableSession {
- private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>();
-
- private final McpStreamableServerTransport transport;
+ private final McpStreamableServerTransport connection;
private final String transportId;
@@ -374,10 +445,11 @@ public class McpStreamableServerSession implements McpLoggableSession {
/**
* Constructor accepting the dedicated transport representing the SSE stream.
- * @param transport request-specific SSE transport stream
+ * @param connection request-specific SSE transport stream
*/
- public McpStreamableServerSessionStream(McpStreamableServerTransport transport) {
- this.transport = transport;
+ public McpStreamableServerSessionStream(McpStreamableServerTransport connection) {
+ this.connection = connection;
+ McpStreamableServerSession.this.openStreams.add(this);
this.transportId = UUID.randomUUID().toString();
// This ID design allows for a constant-time extraction of the history by
// precisely identifying the SSE stream using the first component
@@ -399,19 +471,20 @@ public class McpStreamableServerSession implements McpLoggableSession {
public Mono sendRequest(String method, Object requestParams, TypeRef typeRef) {
String requestId = McpStreamableServerSession.this.generateRequestId();
- McpStreamableServerSession.this.requestIdToStream.put(requestId, this);
-
return Mono.create(sink -> {
- this.pendingResponses.put(requestId, sink);
+ // Registered on subscription rather than on assembly, so that a request
+ // which is never sent does not leave the session tracking it forever
+ McpStreamableServerSession.this.pendingRequests.put(requestId, new PendingRequest(this, sink));
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId,
requestParams);
String messageId = this.uuidGenerator.get();
// TODO: store message in history
- this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {
+ this.connection.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {
}, sink::error);
- }).timeout(requestTimeout).doOnError(e -> {
- this.pendingResponses.remove(requestId);
- McpStreamableServerSession.this.requestIdToStream.remove(requestId);
+ }).timeout(requestTimeout).doFinally(signal -> {
+ // Also on completion and cancellation: a resolved request keeps no state,
+ // and a deadline imposed by the caller cancels rather than errors
+ McpStreamableServerSession.this.pendingRequests.remove(requestId);
}).handle((jsonRpcResponse, sink) -> {
if (jsonRpcResponse.error() != null) {
sink.error(new McpError(jsonRpcResponse.error()));
@@ -421,42 +494,104 @@ public class McpStreamableServerSession implements McpLoggableSession {
sink.complete();
}
else {
- sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef));
+ sink.next(this.connection.unmarshalFrom(jsonRpcResponse.result(), typeRef));
}
}
});
}
+ /**
+ * Runs the request this stream was created for, sending its messages to the
+ * client and finalizing the stream with the response.
+ *
+ * The stream is detached from the session once the request is done with it,
+ * whatever the outcome: a response, an error, or the caller giving up.
+ * @param jsonrpcRequest the MCP request this stream carries the response of
+ * @return Mono which completes once the processing is done
+ */
+ public Mono handle(McpSchema.JSONRPCRequest jsonrpcRequest) {
+ // The stream is released whichever way the request ends: with a response, on
+ // an error, or by the caller giving up on it
+ return Mono.usingWhen(Mono.just(this), stream -> Mono.deferContextual(ctx -> {
+ McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY,
+ McpTransportContext.EMPTY);
+
+ McpRequestHandler> requestHandler = McpStreamableServerSession.this.requestHandlers
+ .get(jsonrpcRequest.method());
+ if (requestHandler == null) {
+ MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method());
+ return this.connection.sendMessage(McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(),
+ new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
+ error.message(), error.data())));
+ }
+ return requestHandler
+ .handle(new McpAsyncServerExchange(McpStreamableServerSession.this.id, this,
+ clientCapabilities.get(), clientInfo.get(), transportContext,
+ McpStreamableServerSession.this.jsonSchemaValidator), jsonrpcRequest.params())
+ .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result))
+ .onErrorResume(e -> {
+ McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError
+ && mcpError.getJsonRpcError() != null)
+ ? mcpError.getJsonRpcError()
+ : new McpSchema.JSONRPCResponse.JSONRPCError(
+ McpSchema.ErrorCodes.INTERNAL_ERROR, e.getMessage(),
+ McpError.aggregateExceptionMessages(e));
+
+ var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError);
+ return Mono.just(errorResponse);
+ })
+ .flatMap(this.connection::sendMessage);
+ }), McpStreamableServerSessionStream::closeGracefully);
+ }
+
@Override
public Mono sendNotification(String method, Object params) {
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params);
String messageId = this.uuidGenerator.get();
// TODO: store message in history
- return this.transport.sendMessage(jsonrpcNotification, messageId);
+ return this.connection.sendMessage(jsonrpcNotification, messageId);
}
@Override
public Mono closeGracefully() {
return Mono.defer(() -> {
- this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
- this.pendingResponses.clear();
+ McpStreamableServerSession.this.openStreams.remove(this);
+ McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()),
+ "Stream closed");
// If this was the generic stream, reset it
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
McpStreamableServerSession.this.missingMcpTransportSession);
- McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
- return this.transport.closeGracefully();
+ return this.connection.closeGracefully();
});
}
@Override
public void close() {
- this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
- this.pendingResponses.clear();
+ McpStreamableServerSession.this.openStreams.remove(this);
+ McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()),
+ "Stream closed");
// If this was the generic stream, reset it
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
McpStreamableServerSession.this.missingMcpTransportSession);
- McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
- this.transport.close();
+ this.connection.close();
+ }
+
+ /**
+ * Releases the connection carrying this stream, detaching the stream from the
+ * session, but keeps its pending server-initiated requests resolvable: the client
+ * answers those with a separate HTTP POST request, which outlives the SSE stream
+ * the request was sent on.
+ *
+ * This is the counterpart of {@link #close()} for the end of a connection rather
+ * than the end of the session: an SSE stream going away, whether replaced,
+ * disconnected or timed out, does not invalidate the requests sent on it.
+ */
+ public void releaseTransport() {
+ McpStreamableServerSession.this.openStreams.remove(this);
+ // If this was the generic stream, reset it
+ McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
+ McpStreamableServerSession.this.missingMcpTransportSession);
+ this.connection.close();
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java
index 1fa6b30..7218be5 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java
@@ -31,6 +31,24 @@ public final class Assert {
}
}
+ /**
+ * Assert that the collection contains no {@code null} elements. A {@code null} or
+ * empty collection passes.
+ * @param collection the collection to check
+ * @param message the exception message to use if the assertion fails
+ * @throws IllegalArgumentException if the collection contains a {@code null} element
+ */
+ public static void noNullElements(@Nullable Collection> collection, String message) {
+ if (collection == null) {
+ return;
+ }
+ for (Object element : collection) {
+ if (element == null) {
+ throw new IllegalArgumentException(message);
+ }
+ }
+ }
+
/**
* Assert that an object is not {@code null}.
*
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
index 6d53ed5..0199262 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
@@ -1,11 +1,12 @@
/**
- * Copyright 2025 - 2025 the original author or authors.
+ * Copyright 2025 - 2026 the original author or authors.
*/
package io.modelcontextprotocol.util;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
import java.util.function.Supplier;
import org.slf4j.Logger;
@@ -28,6 +29,7 @@ import reactor.core.scheduler.Schedulers;
* The pings are sent to all active mcp sessions at regular intervals.
*
* @author Christian Tzolov
+ * @author Daniel Garnier-Moiroux
*/
public class KeepAliveScheduler {
@@ -57,6 +59,9 @@ public class KeepAliveScheduler {
/** Supplier for reactive McpSession instances */
private final Supplier> mcpSessions;
+ /** Invoked with the session whose keep-alive ping went unanswered */
+ private final Consumer onPingFailure;
+
/**
* Creates a KeepAliveScheduler with a custom scheduler, initial delay, interval and a
* supplier for McpSession instances.
@@ -64,13 +69,15 @@ public class KeepAliveScheduler {
* @param initialDelay Initial delay before the first keepAlive call
* @param interval Interval between subsequent keepAlive calls
* @param mcpSessions Supplier for McpSession instances
+ * @param onPingFailure Callback invoked with the session whose ping went unanswered
*/
KeepAliveScheduler(Scheduler scheduler, Duration initialDelay, Duration interval,
- Supplier> mcpSessions) {
+ Supplier> mcpSessions, Consumer onPingFailure) {
this.scheduler = scheduler;
this.initialDelay = initialDelay;
this.interval = interval;
this.mcpSessions = mcpSessions;
+ this.onPingFailure = onPingFailure;
}
/**
@@ -92,8 +99,14 @@ public class KeepAliveScheduler {
.doOnNext(tick -> {
this.mcpSessions.get()
.flatMap(session -> session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF)
- .doOnError(e -> logger.warn("Failed to send keep-alive ping to session {}: {}", session,
- e.getMessage()))
+ // A ping has to be answered before the next one is due. The
+ // request timeout of the session is unrelated to keeping the
+ // connection alive, and is measured in hours by default.
+ .timeout(this.interval)
+ .doOnError(e -> {
+ logger.warn("Keep-alive ping to session {} failed: {}", session, e.getMessage());
+ this.onPingFailure.accept(session);
+ })
.onErrorComplete())
.subscribe();
})
@@ -148,12 +161,15 @@ public class KeepAliveScheduler {
private Scheduler scheduler = Schedulers.boundedElastic();
- private Duration initialDelay = Duration.ofSeconds(0);
+ private Duration initialDelay = Duration.ofSeconds(30);
private Duration interval = Duration.ofSeconds(30);
private Supplier> mcpSessions;
+ private Consumer onPingFailure = session -> {
+ };
+
/**
* Creates a new Builder instance with a supplier for McpSession instances.
* @param mcpSessions The supplier for McpSession instances
@@ -204,12 +220,28 @@ public class KeepAliveScheduler {
return this;
}
+ /**
+ * Sets the callback invoked when a session does not answer a keep-alive ping
+ * within the keep-alive interval. An unanswered ping means the connection the
+ * ping was written to is dead, which the operating system does not necessarily
+ * report: writing to a connection whose peer is gone keeps succeeding until it
+ * resets. It does not mean the session itself is over, as the client is free to
+ * reconnect to it.
+ * @param onPingFailure The callback receiving the unresponsive session
+ * @return This builder instance for method chaining
+ */
+ public Builder onPingFailure(Consumer onPingFailure) {
+ Assert.notNull(onPingFailure, "onPingFailure must not be null");
+ this.onPingFailure = onPingFailure;
+ return this;
+ }
+
/**
* Builds and returns a new KeepAliveScheduler instance.
* @return A new KeepAliveScheduler configured with the builder's settings
*/
public KeepAliveScheduler build() {
- return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions);
+ return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions, onPingFailure);
}
}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java
new file mode 100644
index 0000000..5f35031
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.net.http.HttpResponse.BodySubscriber;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Flow;
+
+import io.modelcontextprotocol.client.transport.ResponseSubscribers.BoundedLineBodySubscriber;
+import io.modelcontextprotocol.client.transport.ResponseSubscribers.BoundedTotalBodySubscriber;
+import io.modelcontextprotocol.spec.McpTransportException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests the size accounting in {@link ResponseSubscribers.BoundedBodySubscriber} and its
+ * two implementations. These bound how much of a response the transport will buffer, so
+ * the accounting is exercised directly rather than only through a live HTTP exchange:
+ * buffer boundaries, line terminators split across buffers, and the exact limit are all
+ * places where an off-by-one either lets a peer past the bound or rejects a legitimate
+ * message.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class BoundedBodySubscriberTests {
+
+ private static final int MAX_SIZE = 16;
+
+ private final RecordingBodySubscriber delegate = new RecordingBodySubscriber();
+
+ private final RecordingSubscription subscription = new RecordingSubscription();
+
+ // --- BoundedLineBodySubscriber: per-line accounting -----------------------
+
+ @Test
+ void lineSubscriberAcceptsEmptyBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer(""))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberAcceptsLineOfExactlyMaxSize() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberRejectsLineOneByteOverMaxSize() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE + 1)))).isFalse();
+ }
+
+ @Test
+ void lineSubscriberAccumulatesAcrossBuffers() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(10)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("a".repeat(6)))).isTrue();
+ // 17th byte of the same unterminated line.
+ assertThat(subscriber.checkSize(buffer("a"))).isFalse();
+ }
+
+ @Test
+ void lineSubscriberAcceptsUnboundedTotalOfTerminatedLines() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ // Far more than MAX_SIZE in total, but no single line comes close to it.
+ for (int i = 0; i < 100; i++) {
+ assertThat(subscriber.checkSize(buffer("aaaa\n"))).isTrue();
+ }
+ }
+
+ @Test
+ void lineSubscriberResetsOnTerminatorAtEndOfBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(10) + "\n"))).isTrue();
+ // A fresh line, so the previous 10 bytes must not count towards it.
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberResetsOnTerminatorAtStartOfBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("\n" + "a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberHandlesCrLfSplitAcrossBuffers() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(12) + "\r"))).isTrue();
+ assertThat(subscriber.checkSize(buffer("\n" + "a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberAcceptsBufferLargerThanMaxSizeHoldingOnlyShortLines() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ // Forces the exact per-byte accounting path: the buffer alone is well over the
+ // limit, yet every line in it is legitimate.
+ assertThat(subscriber.checkSize(buffer("aaaa\n".repeat(20)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberRejectsRunSpanningManyBuffers() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ boolean accepted = true;
+ for (int i = 0; i < 10 && accepted; i++) {
+ accepted = subscriber.checkSize(buffer("aa"));
+ }
+
+ assertThat(accepted).isFalse();
+ }
+
+ @Test
+ void lineSubscriberOnlyCountsFromTheBufferPosition() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2));
+ partiallyConsumed.position(MAX_SIZE * 2 - 4);
+
+ assertThat(subscriber.checkSize(partiallyConsumed)).isTrue();
+ }
+
+ @Test
+ void lineSubscriberDoesNotConsumeTheBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ ByteBuffer buffer = buffer("aaaa\nbbbb");
+ buffer.position(2);
+
+ subscriber.checkSize(buffer);
+
+ assertThat(buffer.position()).isEqualTo(2);
+ assertThat(buffer.limit()).isEqualTo(9);
+ }
+
+ // --- BoundedTotalBodySubscriber: whole-body accounting --------------------
+
+ @Test
+ void totalSubscriberAcceptsBodyOfExactlyMaxSize() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
+ }
+
+ @Test
+ void totalSubscriberRejectsBodyOneByteOverMaxSize() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("a".repeat(9)))).isFalse();
+ }
+
+ @Test
+ void totalSubscriberIsNotResetByLineTerminators() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ // Unlike the per-line bound, terminated lines still count towards the total.
+ assertThat(subscriber.checkSize(buffer("aaaa\n".repeat(3)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("aaaa\n"))).isFalse();
+ }
+
+ @Test
+ void totalSubscriberOnlyCountsFromTheBufferPosition() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+ ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2));
+ partiallyConsumed.position(MAX_SIZE);
+
+ assertThat(subscriber.checkSize(partiallyConsumed)).isTrue();
+ }
+
+ @Test
+ void totalSubscriberDoesNotConsumeTheBuffer() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+ ByteBuffer buffer = buffer("aaaa");
+
+ subscriber.checkSize(buffer);
+
+ assertThat(buffer.position()).isZero();
+ assertThat(buffer.remaining()).isEqualTo(4);
+ }
+
+ // --- onNext: what a failed check does ------------------------------------
+
+ @Test
+ void forwardsBuffersWhileWithinBounds() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ List buffers = List.of(buffer("aaaa\n"), buffer("bbbb\n"));
+
+ subscriber.onNext(buffers);
+
+ assertThat(this.delegate.received).containsExactly(buffers);
+ assertThat(this.delegate.error).isNull();
+ assertThat(this.subscription.cancellations).isZero();
+ }
+
+ @Test
+ void abortsTheResponseWhenTheLineBoundIsExceeded() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
+
+ assertThat(this.subscription.cancellations).isEqualTo(1);
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isInstanceOf(McpTransportException.class)
+ .hasMessage("Inbound line exceeds the maximum allowed size of " + MAX_SIZE + " bytes");
+ }
+
+ @Test
+ void abortsTheResponseWhenTheTotalBoundIsExceeded() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
+
+ assertThat(this.subscription.cancellations).isEqualTo(1);
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isInstanceOf(McpTransportException.class)
+ .hasMessage("Inbound response body exceeds the maximum allowed size of " + MAX_SIZE + " bytes");
+ }
+
+ @Test
+ void withholdsTheWholeListWhenALaterBufferExceedsTheBound() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ subscriber.onNext(List.of(buffer("a".repeat(8)), buffer("a".repeat(9))));
+
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isInstanceOf(McpTransportException.class);
+ }
+
+ @Test
+ void ignoresFurtherSignalsOnceAborted() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
+ Throwable firstError = this.delegate.error;
+
+ // The HTTP client may still signal after the subscription is cancelled.
+ subscriber.onNext(List.of(buffer("aaaa")));
+ subscriber.onError(new RuntimeException("late failure"));
+ subscriber.onComplete();
+
+ assertThat(this.subscription.cancellations).isEqualTo(1);
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isSameAs(firstError);
+ assertThat(this.delegate.completed).isFalse();
+ }
+
+ // --- fixtures ------------------------------------------------------------
+
+ private BoundedLineBodySubscriber lineSubscriber() {
+ BoundedLineBodySubscriber subscriber = new BoundedLineBodySubscriber(this.delegate, MAX_SIZE);
+ subscriber.onSubscribe(this.subscription);
+ return subscriber;
+ }
+
+ private BoundedTotalBodySubscriber totalSubscriber() {
+ BoundedTotalBodySubscriber