- * 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.
+ * There is no direct equivalent in {@link AsyncSpec}. To achieve the same result,
+ * append {@code contextWrite(McpTransportContext.KEY, context)} to any
+ * {@link McpAsyncClient} call.
* @param contextProvider A supplier to create a context
* @return This builder for method chaining
*/
@@ -581,21 +580,6 @@ 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 3e4b613..874da90 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,11 +88,6 @@ 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;
@@ -133,12 +128,6 @@ 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.
@@ -150,13 +139,11 @@ 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, int maxResponseSize) {
+ SseMessageEndpointValidator messageEndpointValidator) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
@@ -164,7 +151,6 @@ 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;
@@ -172,7 +158,6 @@ public class HttpClientSseClientTransport implements McpClientTransport {
this.requestBuilder = requestBuilder;
this.httpRequestCustomizer = httpRequestCustomizer;
this.messageEndpointValidator = messageEndpointValidator;
- this.maxResponseSize = maxResponseSize;
}
@Override
@@ -210,8 +195,6 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
- private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
-
/**
* Creates a new builder instance.
*/
@@ -343,26 +326,6 @@ 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
@@ -371,7 +334,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, maxResponseSize);
+ messageEndpointValidator);
}
}
@@ -390,15 +353,13 @@ 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,
- this.maxResponseSize))
- .exceptionallyCompose(e -> {
- sseSink.error(e);
- return CompletableFuture.failedFuture(e);
- }))
+ Disposable connection = Flux.create(sseSink -> this.httpClient
+ .sendAsync(requestBuilder.build(),
+ responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
+ .exceptionallyCompose(e -> {
+ sseSink.error(e);
+ return CompletableFuture.failedFuture(e);
+ }))
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
.flatMap(responseEvent -> {
if (isClosing) {
@@ -529,8 +490,7 @@ 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, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
+ return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
});
}
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 5517823..48462c0 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,11 +87,6 @@ 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
@@ -119,29 +114,6 @@ 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;
@@ -166,18 +138,11 @@ 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, int maxResponseSize) {
- Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
+ List supportedProtocolVersions) {
this.jsonMapper = jsonMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
@@ -193,7 +158,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
.sorted(Comparator.reverseOrder())
.findFirst()
.get();
- this.maxResponseSize = maxResponseSize;
}
@Override
@@ -242,8 +206,7 @@ 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,
- ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
+ return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
}).then();
}
@@ -360,7 +323,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
+ statusCode));
}
else if (statusCode >= 200 && statusCode < 300) {
- if (isMessageEvent(sseResponseEvent.sseEvent().event())) {
+ if (MESSAGE_EVENT_TYPE.equals(sseResponseEvent.sseEvent().event())) {
String data = sseResponseEvent.sseEvent().data();
// Per 2025-11-25 spec (SEP-1699), servers may
// send SSE events
@@ -484,16 +447,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, this.maxResponseSize);
+ return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
}
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, this.maxResponseSize);
+ return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
}
logger.debug("Received Bodyless response, using discarding subscriber");
- return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
+ return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
};
return responseBodyHandler;
@@ -695,10 +658,6 @@ 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));
@@ -763,13 +722,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private Duration connectTimeout = Duration.ofSeconds(10);
- private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2025_03_26,
- ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
+ 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 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
@@ -968,26 +925,6 @@ 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.
@@ -997,7 +934,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, maxResponseSize);
+ httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
}
}
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
deleted file mode 100644
index 8070340..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/MaxSizeExceededException.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * 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 b19904d..29dc23c 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,13 +5,8 @@
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;
@@ -35,21 +30,11 @@ 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) {
}
@@ -69,80 +54,19 @@ class ResponseSubscribers {
record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent {
}
- /**
- * 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 sseToBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ return HttpResponse.BodySubscribers
+ .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(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 aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ return HttpResponse.BodySubscribers
+ .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink)));
}
- /**
- * Creates a {@link BodySubscriber} that aggregates the whole response body into a
- * single event, bounding how much memory it may occupy. Both the size of an
- * individual line (as read off the wire before a terminator is seen) and the total
- * accumulated body are capped at {@code maxSize}; a peer exceeding either limit has
- * its response aborted instead of forcing the transport to buffer it in memory.
- * @param responseInfo the HTTP response information
- * @param sink the sink to emit the aggregated event to
- * @param maxSize the maximum number of bytes read for the response body
- */
- static BodySubscriber aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
- int maxSize) {
- BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
- .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink, maxSize)));
- return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
- }
-
- /**
- * 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
+ static BodySubscriber bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ return 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 {
@@ -188,30 +112,18 @@ 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, int maxSize) {
+ public SseLineSubscriber(ResponseInfo responseInfo, FluxSink sink) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.currentEventId = new AtomicReference<>();
this.currentEventType = new AtomicReference<>();
this.responseInfo = responseInfo;
- this.maxSize = maxSize;
}
@Override
@@ -243,18 +155,7 @@ class ResponseSubscribers {
if (line.startsWith("data:")) {
var matcher = EVENT_DATA_PATTERN.matcher(line);
if (matcher.find()) {
- 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");
+ this.eventBuilder.append(matcher.group(1).trim()).append("\n");
}
upstream().request(1);
}
@@ -324,26 +225,15 @@ 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, int maxSize) {
+ public AggregateSubscriber(ResponseInfo responseInfo, FluxSink sink) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.responseInfo = responseInfo;
- this.maxSize = maxSize;
}
@Override
@@ -362,15 +252,6 @@ 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");
}
@@ -443,177 +324,4 @@ 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 60a8d7c..e73e43e 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,8 +44,6 @@ 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
@@ -78,8 +76,6 @@ public class StdioClientTransport implements McpClientTransport {
private final Sinks.Many errorSink;
- private final int inputMaxSize;
-
private volatile boolean isClosing = false;
// visible for tests
@@ -91,21 +87,8 @@ 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();
@@ -114,8 +97,6 @@ public class StdioClientTransport implements McpClientTransport {
this.jsonMapper = jsonMapper;
- this.inputMaxSize = inputMaxSize;
-
this.errorSink = Sinks.many().unicast().onBackpressureBuffer();
// Start threads
@@ -258,13 +239,16 @@ public class StdioClientTransport implements McpClientTransport {
@Override
public Mono sendMessage(JSONRPCMessage message) {
- try {
- // busyLooping retries FAIL_NON_SERIALIZED under concurrent senders
- this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
+ 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.
return Mono.empty();
}
- catch (Sinks.EmissionException e) {
- return Mono.error(new RuntimeException("Failed to enqueue message", e));
+ else {
+ return Mono.error(new RuntimeException("Failed to enqueue message"));
}
}
@@ -276,7 +260,7 @@ public class StdioClientTransport implements McpClientTransport {
this.inboundScheduler.schedule(() -> {
try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
- while (!isClosing && (line = readLine(processReader, inputMaxSize)) != null) {
+ while (!isClosing && (line = processReader.readLine()) != null) {
try {
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, line);
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
@@ -294,11 +278,6 @@ 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);
@@ -311,37 +290,6 @@ 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 f2951d8..756b39c 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,26 +7,18 @@ 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 322aa3a..cde637b 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 = Map.copyOf(metadata);
+ this.metadata = 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 4691c13..46a2ccf 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
@@ -11,28 +11,13 @@ 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. Write the
- * context under this key to make it visible to the transport, for example
- * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}.
+ * Key for use in Reactor Context to transport the context to user land.
*/
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 6e89ab7..7eed339 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,11 +57,6 @@ 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 5cd5de7..def40d5 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.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 Mono.error(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
+ .message("Missing handler for request type: " + request.method())
+ .build());
}
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
deleted file mode 100644
index d6b1858..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * 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 fc1f8a4..83aa8f5 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
@@ -118,8 +118,6 @@ public class McpAsyncServer {
private final ConcurrentHashMap> resourceSubscriptions = new ConcurrentHashMap<>();
- private final McpAsyncListFilter toolFilter;
-
private List protocolVersions;
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory();
@@ -148,7 +146,6 @@ 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);
@@ -180,7 +177,6 @@ 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);
@@ -541,30 +537,10 @@ public class McpAsyncServer {
private McpRequestHandler toolsListRequestHandler() {
return (exchange, params) -> {
- // 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());
- };
- }
+ List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
- /**
- * 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());
+ return Mono.just(McpSchema.ListToolsResult.builder(tools).build());
+ };
}
private McpRequestHandler toolsCallRequestHandler() {
@@ -1114,7 +1090,9 @@ public class McpAsyncServer {
McpServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref());
if (specification == null) {
- return EMPTY_COMPLETION_RESULT;
+ return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS)
+ .message("AsyncCompletionSpecification not found: " + request.ref())
+ .build());
}
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 2113fdb..a2333ae 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
@@ -12,7 +12,6 @@ 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;
@@ -239,7 +238,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.toolFilters);
+ this.instructions);
var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -269,7 +268,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.toolFilters);
+ this.instructions);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -302,8 +301,6 @@ 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
@@ -443,43 +440,6 @@ 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
@@ -870,7 +830,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.toolFilters);
+ this.rootsChangeHandlers, this.instructions);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
@@ -905,7 +865,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.toolFilters);
+ this.rootsChangeHandlers, this.instructions);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
@@ -940,8 +900,6 @@ 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
@@ -1085,44 +1043,6 @@ 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
@@ -1522,8 +1442,6 @@ 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
@@ -1664,40 +1582,6 @@ 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
@@ -2024,8 +1908,7 @@ 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.toolFilters);
+ this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -2059,8 +1942,6 @@ 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
@@ -2201,42 +2082,6 @@ 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
@@ -2579,8 +2424,7 @@ 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.toolFilters);
+ this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
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 77ecbc8..cfa28e6 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
@@ -38,7 +38,6 @@ 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,
@@ -46,7 +45,7 @@ public class McpServerFeatures {
Map prompts,
Map completions,
List, Mono>> rootsChangeConsumers,
- String instructions, List> toolFilters) {
+ String instructions) {
/**
* Create an instance and validate the arguments.
@@ -59,7 +58,6 @@ 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,
@@ -67,7 +65,7 @@ public class McpServerFeatures {
Map prompts,
Map completions,
List, Mono>> rootsChangeConsumers,
- String instructions, List> toolFilters) {
+ String instructions) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -93,7 +91,6 @@ 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();
}
/**
@@ -141,11 +138,7 @@ public class McpServerFeatures {
}
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates,
- prompts, completions, rootChangeConsumers, syncSpec.instructions(),
- syncSpec.toolFilters()
- .stream()
- .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution))
- .toList());
+ prompts, completions, rootChangeConsumers, syncSpec.instructions());
}
}
@@ -168,8 +161,7 @@ public class McpServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- List>> rootsChangeConsumers, String instructions,
- List> toolFilters) {
+ List>> rootsChangeConsumers, String instructions) {
/**
* Create an instance and validate the arguments.
@@ -182,7 +174,6 @@ 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,
@@ -190,8 +181,8 @@ public class McpServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- List>> rootsChangeConsumers, String instructions,
- List> toolFilters) {
+ List>> rootsChangeConsumers,
+ String instructions) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -217,7 +208,6 @@ 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 95b694e..c610526 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
@@ -80,8 +80,6 @@ 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,
@@ -99,7 +97,6 @@ public class McpStatelessAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
- this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = new HashMap<>();
@@ -136,26 +133,10 @@ public class McpStatelessAsyncServer {
this.protocolVersions = new ArrayList<>(mcpTransport.protocolVersions());
- Map notificationHandlers = prepareNotificationHandlers();
- McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, notificationHandlers);
+ McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, Map.of());
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
// ---------------------------------------
@@ -420,32 +401,13 @@ public class McpStatelessAsyncServer {
private McpStatelessRequestHandler toolsListRequestHandler() {
return (ctx, params) -> {
- // 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)
+ List tools = this.tools.stream()
.map(McpStatelessServerFeatures.AsyncToolSpecification::tool)
- .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool)
- .onErrorResume(error -> opaqueListFilterError(tool, error)))
- .collectList()
- .map(tools -> McpSchema.ListToolsResult.builder(tools).build());
+ .toList();
+ return Mono.just(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,
@@ -853,7 +815,9 @@ public class McpStatelessAsyncServer {
McpStatelessServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref());
if (specification == null) {
- return EMPTY_COMPLETION_RESULT;
+ return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS)
+ .message("AsyncCompletionSpecification not found: " + request.ref())
+ .build());
}
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 8ffc2a6..0c1fbfb 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
@@ -37,7 +37,6 @@ 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,
@@ -45,7 +44,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions, List> toolFilters) {
+ String instructions) {
/**
* Create an instance and validate the arguments.
@@ -56,7 +55,6 @@ 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,
@@ -64,7 +62,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions, List> toolFilters) {
+ String instructions) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -86,7 +84,6 @@ 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();
}
/**
@@ -126,11 +123,7 @@ public class McpStatelessServerFeatures {
});
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates,
- prompts, completions, syncSpec.instructions(),
- syncSpec.toolFilters()
- .stream()
- .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution))
- .toList());
+ prompts, completions, syncSpec.instructions());
}
}
@@ -144,7 +137,6 @@ 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,
@@ -152,7 +144,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions, List> toolFilters) {
+ String instructions) {
/**
* Create an instance and validate the arguments.
@@ -163,7 +155,6 @@ 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,
@@ -171,7 +162,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions, List> toolFilters) {
+ String instructions) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -196,7 +187,6 @@ 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
deleted file mode 100644
index 01d4d07..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * 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 725a67a..e96403e 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,7 +7,6 @@ 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;
@@ -23,8 +22,7 @@ import io.modelcontextprotocol.util.Assert;
* @see ServerTransportSecurityValidator
* @see ServerTransportSecurityException
*/
-public final class DefaultServerTransportSecurityValidator
- implements ServerTransportSecurityValidator, ServerHttpHeaderValidator {
+public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator {
private static final String ORIGIN_HEADER = "Origin";
@@ -49,24 +47,27 @@ public final class DefaultServerTransportSecurityValidator
}
@Override
- @Deprecated
public void validateHeaders(Map> headers) throws ServerTransportSecurityException {
- 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()) {
- List hostValues = headerAccessor.getHeader(HOST_HEADER);
- if (hostValues == null || hostValues.isEmpty()) {
- throw new ServerTransportSecurityException(421, "Invalid Host header");
+ 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));
}
- validateHost(hostValues.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));
+ }
+ }
+ if (!allowedHosts.isEmpty() && missingHost) {
+ throw new ServerTransportSecurityException(421, "Invalid Host header");
}
}
@@ -138,37 +139,6 @@ public final class DefaultServerTransportSecurityValidator
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
deleted file mode 100644
index efed4a7..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * 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
deleted file mode 100644
index a06261c..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * 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 5901c38..3224694 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,10 +4,11 @@
package io.modelcontextprotocol.server.transport;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
@@ -22,31 +23,18 @@ final class HttpServletRequestUtils {
}
/**
- * Reads the request body, decoded using the request's character encoding (or UTF-8 if
- * not specified), while bounding the number of bytes read.
+ * Extracts all headers from the HTTP request into a map.
* @param request The HTTP servlet request
- * @param maxSize The maximum number of bytes to read from the request body
- * @return The decoded request body
- * @throws MaxSizeExceededException If the body exceeds {@code maxSize}
- * @throws IOException If an I/O error occurs while reading the request body
+ * @return A map of header names to their values
*/
- 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);
+ 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)));
}
- String charset = request.getCharacterEncoding() != null ? request.getCharacterEncoding()
- : StandardCharsets.UTF_8.name();
- return bodyBytes.toString(charset);
+ return headers;
}
}
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 a14271a..69d73f7 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,6 +4,7 @@
package io.modelcontextprotocol.server.transport;
+import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
@@ -75,11 +76,6 @@ 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
*/
@@ -115,11 +111,6 @@ 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
*/
@@ -161,7 +152,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
/**
* Security validator for validating HTTP requests.
*/
- private final ServerHttpHeaderValidator httpHeaderValidator;
+ private final ServerTransportSecurityValidator securityValidator;
/**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
@@ -174,29 +165,25 @@ 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 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 securityValidator The security validator for validating HTTP requests.
*/
private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor contextExtractor,
- ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
+ ServerTransportSecurityValidator securityValidator) {
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(httpHeaderValidator, "HTTP header validator must not be null");
- Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
+ Assert.notNull(securityValidator, "Security validator must not be null");
this.jsonMapper = jsonMapper;
this.baseUrl = baseUrl;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor;
- this.httpHeaderValidator = httpHeaderValidator;
- this.requestMaxSize = requestMaxSize;
+ this.securityValidator = securityValidator;
if (keepAliveInterval != null) {
@@ -293,7 +280,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
+ Map> headers = HttpServletRequestUtils.extractHeaders(request);
+ this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -358,11 +346,6 @@ 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);
@@ -370,7 +353,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
+ Map> headers = HttpServletRequestUtils.extractHeaders(request);
+ this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -408,10 +392,15 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
+ BufferedReader reader = request.getReader();
+ StringBuilder body = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ body.append(line);
+ }
final McpTransportContext transportContext = this.contextExtractor.extract(request);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
// Process the message through the session's handle method
// Block for Servlet compatibility
@@ -419,9 +408,6 @@ 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 {
@@ -617,9 +603,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private Duration keepAliveInterval;
- private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
-
- private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+ private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
/**
* Sets the JsonMapper implementation to use for serialization/deserialization. If
@@ -700,38 +684,10 @@ 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.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;
+ this.securityValidator = securityValidator;
return this;
}
@@ -747,7 +703,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint,
- sseEndpoint, keepAliveInterval, contextExtractor, httpHeaderValidator, requestMaxSize);
+ sseEndpoint, keepAliveInterval, contextExtractor, securityValidator);
}
}
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 9cd0d04..047aeeb 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,9 +4,11 @@
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;
@@ -37,11 +39,6 @@ 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";
@@ -67,39 +64,20 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
/**
* Security validator for validating HTTP requests.
*/
- private final ServerHttpHeaderValidator httpHeaderValidator;
+ private final ServerTransportSecurityValidator securityValidator;
- /**
- * Maximum size, in bytes, of a single request body accepted by this transport.
- */
- private final int requestMaxSize;
-
- /**
- * Constructs a new HttpServletStatelessServerTransport instance.
- * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of
- * messages.
- * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
- * messages.
- * @param contextExtractor The extractor for transport context from the request.
- * @param 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,
- ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
+ ServerTransportSecurityValidator securityValidator) {
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(httpHeaderValidator, "HTTP header validator must not be null");
- Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
+ Assert.notNull(securityValidator, "Security validator must not be null");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
- this.httpHeaderValidator = httpHeaderValidator;
- this.requestMaxSize = requestMaxSize;
+ this.securityValidator = securityValidator;
}
@Override
@@ -155,13 +133,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
return;
}
- if (request.getContentLengthLong() > this.requestMaxSize) {
- response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
- return;
- }
-
try {
- this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
+ Map> headers = HttpServletRequestUtils.extractHeaders(request);
+ this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -180,9 +154,14 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
}
try {
- String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
+ BufferedReader reader = request.getReader();
+ StringBuilder body = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ body.append(line);
+ }
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
@@ -230,9 +209,6 @@ 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,
@@ -298,9 +274,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private McpTransportContextExtractor contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
- private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
-
- private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+ private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
private Builder() {
// used by a static method
@@ -352,38 +326,10 @@ 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.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;
+ this.securityValidator = securityValidator;
return this;
}
@@ -397,7 +343,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,
- httpHeaderValidator, requestMaxSize);
+ securityValidator);
}
}
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 5f883cb..e6af4fd 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,46 +4,41 @@
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.Set;
+import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
-import io.modelcontextprotocol.common.McpTransportContext;
-import io.modelcontextprotocol.json.McpJsonDefaults;
-import io.modelcontextprotocol.json.McpJsonMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import io.modelcontextprotocol.json.TypeRef;
+
+import io.modelcontextprotocol.common.McpTransportContext;
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
@@ -60,7 +55,6 @@ import reactor.core.scheduler.Schedulers;
* @author Zachary German
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
- * @author Daniel Garnier-Moiroux
* @see McpStreamableServerTransportProvider
* @see HttpServlet
*/
@@ -68,11 +62,6 @@ import reactor.core.scheduler.Schedulers;
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);
/**
@@ -111,11 +100,6 @@ 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;
/**
@@ -123,14 +107,6 @@ 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;
/**
@@ -144,18 +120,10 @@ 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 ServerHttpHeaderValidator httpHeaderValidator;
+ private final ServerTransportSecurityValidator securityValidator;
/**
* Constructs a new HttpServletStreamableServerTransportProvider instance.
@@ -167,122 +135,34 @@ 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 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.
+ * @param securityValidator The security validator for validating HTTP requests.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor contextExtractor,
- Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize,
- Duration sessionSweepInterval) {
+ Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
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(httpHeaderValidator, "HTTP header validator must not be null");
- Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
+ Assert.notNull(securityValidator, "Security validator must not be null");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
- this.httpHeaderValidator = httpHeaderValidator;
- this.requestMaxSize = requestMaxSize;
+ this.securityValidator = securityValidator;
if (keepAliveInterval != null) {
- this.keepAliveScheduler = KeepAliveScheduler.builder(this::sessionsToPing)
+ this.keepAliveScheduler = KeepAliveScheduler
+ .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values()))
.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
@@ -357,9 +237,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
if (this.keepAliveScheduler != null) {
this.keepAliveScheduler.shutdown();
}
- if (this.sessionSweeper != null) {
- this.sessionSweeper.dispose();
- }
});
}
@@ -386,7 +263,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
+ Map> headers = HttpServletRequestUtils.extractHeaders(request);
+ this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -419,7 +297,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
- this.markSessionActive(sessionId);
logger.debug("Handling GET request for session: {}", sessionId);
@@ -437,21 +314,61 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
sessionId, asyncContext, response.getWriter());
- // 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;
+ // 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();
+ }
}
+ else {
+ // Establish new listening stream
+ McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
+ .listeningStream(sessionTransport);
- // 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);
+ 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();
+ }
- registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport);
+ @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
+ }
+ });
+ }
}
catch (Exception e) {
logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage());
@@ -480,13 +397,10 @@ 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 {
- this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
+ Map> headers = HttpServletRequestUtils.extractHeaders(request);
+ this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -506,9 +420,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
McpTransportContext transportContext = this.contextExtractor.extract(request);
try {
- String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
+ BufferedReader reader = request.getReader();
+ StringBuilder body = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ body.append(line);
+ }
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
// Handle initialization request
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
@@ -525,7 +444,6 @@ 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 {
@@ -577,8 +495,6 @@ 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))
@@ -604,16 +520,8 @@ 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 {
- responseStream.handle(jsonrpcRequest)
+ session.responseStream(jsonrpcRequest, sessionTransport)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
}
@@ -627,9 +535,6 @@ 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,
@@ -675,7 +580,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
+ Map> headers = HttpServletRequestUtils.extractHeaders(request);
+ this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -767,70 +673,6 @@ 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.
@@ -840,6 +682,7 @@ 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;
@@ -903,17 +746,9 @@ 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());
- 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);
+ HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
+ this.asyncContext.complete();
}
finally {
lock.unlock();
@@ -957,6 +792,8 @@ 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);
}
@@ -970,12 +807,6 @@ 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();
}
@@ -995,13 +826,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private McpTransportContextExtractor contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
- private Duration keepAliveInterval = Duration.ofMinutes(30);
+ private Duration keepAliveInterval;
- private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
-
- private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
-
- private Duration sessionSweepInterval = Duration.ofMinutes(30);
+ private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
/**
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
@@ -1054,7 +881,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. Defaults to 30 minutes.
+ * keep-alive will be scheduled.
* @return this builder instance
*/
public Builder keepAliveInterval(Duration keepAliveInterval) {
@@ -1067,54 +894,10 @@ 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.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;
+ this.securityValidator = securityValidator;
return this;
}
@@ -1128,7 +911,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, httpHeaderValidator, requestMaxSize, sessionSweepInterval);
+ contextExtractor, keepAliveInterval, securityValidator);
}
}
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
deleted file mode 100644
index ddca5db..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * 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
deleted file mode 100644
index 6f2a3dc..0000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * 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 023f7f3..ce80593 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,8 +6,6 @@ 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
@@ -15,12 +13,9 @@ import java.util.stream.Collectors;
* to the MCP specification.
*
* @author Daniel Garnier-Moiroux
- * @see ServerHttpHeaderValidator
+ * @see DefaultServerTransportSecurityValidator
* @see ServerTransportSecurityException
- * @deprecated Use {@link ServerHttpHeaderValidator} instead. This interface will be
- * removed in a future major version.
*/
-@Deprecated
@FunctionalInterface
public interface ServerTransportSecurityValidator {
@@ -30,20 +25,6 @@ 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 8370e41..045d7e3 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,8 +41,6 @@ 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;
@@ -51,8 +49,6 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
private final OutputStream outputStream;
- private final int inputMaxSize;
-
private McpServerSession session;
private final AtomicBoolean isClosing = new AtomicBoolean(false);
@@ -76,29 +72,13 @@ 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
@@ -175,12 +155,11 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
public Mono sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> {
- try {
- outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
+ if (outboundSink.tryEmitNext(message).isSuccess()) {
return Mono.empty();
}
- catch (Sinks.EmissionException e) {
- return Mono.error(new RuntimeException("Failed to enqueue message", e));
+ else {
+ return Mono.error(new RuntimeException("Failed to enqueue message"));
}
}));
}
@@ -232,7 +211,7 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
while (!isClosing.get()) {
try {
- String line = readLine(reader, inputMaxSize);
+ String line = reader.readLine();
if (line == null || isClosing.get()) {
break;
}
@@ -253,10 +232,6 @@ 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;
@@ -329,36 +304,6 @@ 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 5ac0863..5bb5c38 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
@@ -1,31 +1,29 @@
/*
- * Copyright 2024-2026 the original author or authors.
+ * Copyright 2024-2025 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 io.modelcontextprotocol.common.McpTransportContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import io.modelcontextprotocol.json.TypeRef;
+
+import io.modelcontextprotocol.common.McpTransportContext;
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;
@@ -38,26 +36,12 @@ 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);
- /**
- * 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 ConcurrentHashMap requestIdToStream = new ConcurrentHashMap<>();
private final String id;
@@ -195,91 +179,61 @@ public class McpStreamableServerSession implements McpLoggableSession {
}
/**
- * 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.
+ * Create a listening stream (the generic HTTP GET request without Last-Event-ID
+ * header).
* @param transport The dedicated SSE transport stream
* @return a stream representation
*/
public McpStreamableServerSessionStream listeningStream(McpStreamableServerTransport transport) {
McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream(transport);
- 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();
- }
+ this.listeningStreamRef.set(listeningStream);
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.defer(() -> this.responseStream(transport).handle(jsonrpcRequest));
+ 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());
+ });
}
/**
@@ -313,13 +267,22 @@ public class McpStreamableServerSession implements McpLoggableSession {
logger.debug("Received response: {}", response);
if (response.id() != null) {
- var pendingRequest = this.pendingRequests.remove(response.id());
- if (pendingRequest == null) {
+ var stream = this.requestIdToStream.get(response.id());
+ if (stream == null) {
return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR)
.message("Unexpected response for unknown id " + response.id())
.build());
}
- pendingRequest.sink().success(response);
+ // 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);
+ }
}
else {
logger.error("Discarded MCP request response without session id. "
@@ -337,49 +300,23 @@ 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(() -> {
- 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")));
+ McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
+ return listeningStream.closeGracefully();
+ // TODO: Also close all the open streams
}));
}
@Override
public void close() {
this.onClose.get().onErrorComplete().subscribe();
- 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");
+ McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
+ if (listeningStream != null) {
+ listeningStream.close();
+ }
+ // TODO: Also close all open streams
}
/**
@@ -421,23 +358,15 @@ 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 McpStreamableServerTransport connection;
+ private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>();
+
+ private final McpStreamableServerTransport transport;
private final String transportId;
@@ -445,11 +374,10 @@ public class McpStreamableServerSession implements McpLoggableSession {
/**
* Constructor accepting the dedicated transport representing the SSE stream.
- * @param connection request-specific SSE transport stream
+ * @param transport request-specific SSE transport stream
*/
- public McpStreamableServerSessionStream(McpStreamableServerTransport connection) {
- this.connection = connection;
- McpStreamableServerSession.this.openStreams.add(this);
+ public McpStreamableServerSessionStream(McpStreamableServerTransport transport) {
+ this.transport = transport;
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
@@ -471,20 +399,19 @@ 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 -> {
- // 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));
+ this.pendingResponses.put(requestId, sink);
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId,
requestParams);
String messageId = this.uuidGenerator.get();
// TODO: store message in history
- this.connection.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {
+ this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {
}, sink::error);
- }).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);
+ }).timeout(requestTimeout).doOnError(e -> {
+ this.pendingResponses.remove(requestId);
+ McpStreamableServerSession.this.requestIdToStream.remove(requestId);
}).handle((jsonRpcResponse, sink) -> {
if (jsonRpcResponse.error() != null) {
sink.error(new McpError(jsonRpcResponse.error()));
@@ -494,104 +421,42 @@ public class McpStreamableServerSession implements McpLoggableSession {
sink.complete();
}
else {
- sink.next(this.connection.unmarshalFrom(jsonRpcResponse.result(), typeRef));
+ sink.next(this.transport.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.connection.sendMessage(jsonrpcNotification, messageId);
+ return this.transport.sendMessage(jsonrpcNotification, messageId);
}
@Override
public Mono closeGracefully() {
return Mono.defer(() -> {
- McpStreamableServerSession.this.openStreams.remove(this);
- McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()),
- "Stream closed");
+ this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
+ this.pendingResponses.clear();
// If this was the generic stream, reset it
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
McpStreamableServerSession.this.missingMcpTransportSession);
- return this.connection.closeGracefully();
+ McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
+ return this.transport.closeGracefully();
});
}
@Override
public void close() {
- McpStreamableServerSession.this.openStreams.remove(this);
- McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()),
- "Stream closed");
+ this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
+ this.pendingResponses.clear();
// If this was the generic stream, reset it
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
McpStreamableServerSession.this.missingMcpTransportSession);
- 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();
+ McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
+ this.transport.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 7218be5..1fa6b30 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java
@@ -31,24 +31,6 @@ 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 0199262..6d53ed5 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
@@ -1,12 +1,11 @@
/**
- * Copyright 2025 - 2026 the original author or authors.
+ * Copyright 2025 - 2025 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;
@@ -29,7 +28,6 @@ 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 {
@@ -59,9 +57,6 @@ 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.
@@ -69,15 +64,13 @@ 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, Consumer onPingFailure) {
+ Supplier> mcpSessions) {
this.scheduler = scheduler;
this.initialDelay = initialDelay;
this.interval = interval;
this.mcpSessions = mcpSessions;
- this.onPingFailure = onPingFailure;
}
/**
@@ -99,14 +92,8 @@ public class KeepAliveScheduler {
.doOnNext(tick -> {
this.mcpSessions.get()
.flatMap(session -> session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF)
- // 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);
- })
+ .doOnError(e -> logger.warn("Failed to send keep-alive ping to session {}: {}", session,
+ e.getMessage()))
.onErrorComplete())
.subscribe();
})
@@ -161,15 +148,12 @@ public class KeepAliveScheduler {
private Scheduler scheduler = Schedulers.boundedElastic();
- private Duration initialDelay = Duration.ofSeconds(30);
+ private Duration initialDelay = Duration.ofSeconds(0);
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
@@ -220,28 +204,12 @@ 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, onPingFailure);
+ return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions);
}
}
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
deleted file mode 100644
index 5f35031..0000000
--- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java
+++ /dev/null
@@ -1,334 +0,0 @@
-/*
- * 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