diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml
index 1a61d33..1f20cab 100644
--- a/.github/workflows/publish-snapshot.yml
+++ b/.github/workflows/publish-snapshot.yml
@@ -2,7 +2,7 @@ name: Publish Snapshot
on:
push:
- branches: [ "main" ]
+ branches: [ "1.1.x" ]
jobs:
build:
diff --git a/ROADMAP.md b/ROADMAP.md
index b5b7dc4..bc2a336 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,7 +27,7 @@ Once we catch up on the most recent MCP specification revision we aim to fully s
### v1.x Development
-The Java SDK is currently in active development as v1.x, following a recent stable 1.0.0 release. The SDK provides:
+The Java SDK is currently in active development as v1.x, following a recent stable 1.1.0 release. The SDK provides:
- MCP protocol implementation
- Synchronous and asynchronous programming models
diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md
index 8edc7ad..cb38abe 100644
--- a/conformance-tests/VALIDATION_RESULTS.md
+++ b/conformance-tests/VALIDATION_RESULTS.md
@@ -87,7 +87,7 @@ cd conformance-tests/client-jdk-http-client
# Run all scenarios
for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do
npx @modelcontextprotocol/conformance client \
- --command "java -jar target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario $scenario
done
```
@@ -104,7 +104,7 @@ cd conformance-tests/client-spring-http-client
# Run auth suite
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--suite auth
```
diff --git a/conformance-tests/client-jdk-http-client/README.md b/conformance-tests/client-jdk-http-client/README.md
index ba5f4fe..787820e 100644
--- a/conformance-tests/client-jdk-http-client/README.md
+++ b/conformance-tests/client-jdk-http-client/README.md
@@ -54,7 +54,7 @@ cd conformance-tests/client-jdk-http-client
This creates an executable JAR at:
```
-target/client-jdk-http-client-1.1.0-SNAPSHOT.jar
+target/client-jdk-http-client-1.1.1-SNAPSHOT.jar
```
## Running Tests
@@ -65,19 +65,19 @@ Run a single scenario:
```bash
npx @modelcontextprotocol/conformance client \
- --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario initialize
npx @modelcontextprotocol/conformance client \
- --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario tools_call
npx @modelcontextprotocol/conformance client \
- --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario elicitation-sep1034-client-defaults
npx @modelcontextprotocol/conformance client \
- --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario sse-retry
```
@@ -85,7 +85,7 @@ Run with verbose output:
```bash
npx @modelcontextprotocol/conformance client \
- --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar" \
--scenario initialize \
--verbose
```
@@ -96,7 +96,7 @@ You can also run the client manually if you have a test server:
```bash
export MCP_CONFORMANCE_SCENARIO=initialize
-java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp
+java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.1-SNAPSHOT.jar http://localhost:3000/mcp
```
## Test Results
diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml
index 54618f1..cdd8ef1 100644
--- a/conformance-tests/client-jdk-http-client/pom.xml
+++ b/conformance-tests/client-jdk-http-client/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkconformance-tests
- 1.1.0-SNAPSHOT
+ 1.1.4client-jdk-http-clientjar
@@ -28,7 +28,7 @@
io.modelcontextprotocol.sdkmcp
- 1.1.0-SNAPSHOT
+ 1.1.4
diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md
index afbf647..0abca0e 100644
--- a/conformance-tests/client-spring-http-client/README.md
+++ b/conformance-tests/client-spring-http-client/README.md
@@ -67,7 +67,7 @@ cd conformance-tests/client-spring-http-client
This creates an executable JAR at:
```
-target/client-spring-http-client-1.1.0-SNAPSHOT.jar
+target/client-spring-http-client-1.1.1-SNAPSHOT.jar
```
## Running Tests
@@ -79,7 +79,7 @@ Run the full auth suite:
```bash
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--suite auth
```
@@ -88,7 +88,7 @@ Run a single scenario:
```bash
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--scenario auth/metadata-default
```
@@ -97,7 +97,7 @@ Run with verbose output:
```bash
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar" \
--scenario auth/metadata-default \
--verbose
```
@@ -108,7 +108,7 @@ You can also run the client manually if you have a test server:
```bash
export MCP_CONFORMANCE_SCENARIO=auth/metadata-default
-java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp
+java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.1-SNAPSHOT.jar http://localhost:3000/mcp
```
## Known Issues
diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml
index 90ed576..a0bb870 100644
--- a/conformance-tests/client-spring-http-client/pom.xml
+++ b/conformance-tests/client-spring-http-client/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkconformance-tests
- 1.1.0-SNAPSHOT
+ 1.1.4client-spring-http-clientjar
diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml
index 7329fe8..af4c2e3 100644
--- a/conformance-tests/pom.xml
+++ b/conformance-tests/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4conformance-testspom
diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml
index 289599a..fa7d5c6 100644
--- a/conformance-tests/server-servlet/pom.xml
+++ b/conformance-tests/server-servlet/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkconformance-tests
- 1.1.0-SNAPSHOT
+ 1.1.4server-servletjar
@@ -28,7 +28,7 @@
io.modelcontextprotocol.sdkmcp
- 1.1.0-SNAPSHOT
+ 1.1.4
diff --git a/docs/quickstart.md b/docs/quickstart.md
index e7e76bc..1cbc08b 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -123,7 +123,7 @@ Add the BOM to your project:
io.modelcontextprotocol.sdkmcp-bom
- 1.0.0
+ 1.1.0pomimport
@@ -135,7 +135,7 @@ Add the BOM to your project:
```groovy
dependencies {
- implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.0.0")
+ implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.1.0")
//...
}
```
diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml
index aa6cc79..ce8cfe7 100644
--- a/mcp-bom/pom.xml
+++ b/mcp-bom/pom.xml
@@ -7,7 +7,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4mcp-bom
diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml
index 3f7fa0b..cdc5b44 100644
--- a/mcp-core/pom.xml
+++ b/mcp-core/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4mcp-corejar
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
index 93fcc33..434c07a 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
@@ -303,7 +303,7 @@ public class McpAsyncClient {
return Mono.empty();
}
- return this.listToolsInternal(init, McpSchema.FIRST_PAGE).doOnNext(listToolsResult -> {
+ return this.listToolsInternal(init, McpSchema.FIRST_PAGE, null).doOnNext(listToolsResult -> {
listToolsResult.tools()
.forEach(tool -> logger.debug("Tool {} schema: {}", tool.name(), tool.outputSchema()));
if (enableCallToolSchemaCaching && listToolsResult.tools() != null) {
@@ -645,16 +645,27 @@ public class McpAsyncClient {
* @return A Mono that emits the list of tools result
*/
public Mono listTools(String cursor) {
- return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor));
+ return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, null));
}
- private Mono listToolsInternal(Initialization init, String cursor) {
+ /**
+ * Retrieves a paginated list of tools with optional metadata.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that emits the list of tools result
+ */
+ public Mono listTools(String cursor, Map meta) {
+ return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta));
+ }
+
+ private Mono listToolsInternal(Initialization init, String cursor,
+ Map meta) {
if (init.initializeResult().capabilities().tools() == null) {
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
}
return init.mcpSession()
- .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
+ .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_TOOLS_RESULT_TYPE_REF)
.doOnNext(result -> {
// Validate tool names (warn only)
@@ -725,12 +736,30 @@ public class McpAsyncClient {
* @see #readResource(McpSchema.Resource)
*/
public Mono listResources(String cursor) {
+ return this.listResourcesInternal(cursor, null);
+ }
+
+ /**
+ * Retrieves a paginated list of resources provided by the server. Resources represent
+ * any kind of UTF-8 encoded data that an MCP server makes available to clients, such
+ * as database records, API responses, log files, and more.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that completes with the list of resources result.
+ * @see McpSchema.ListResourcesResult
+ * @see #readResource(McpSchema.Resource)
+ */
+ public Mono listResources(String cursor, Map meta) {
+ return this.listResourcesInternal(cursor, meta);
+ }
+
+ private Mono listResourcesInternal(String cursor, Map meta) {
return this.initializer.withInitialization("listing resources", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
- .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
+ .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_RESOURCES_RESULT_TYPE_REF);
});
}
@@ -795,12 +824,30 @@ public class McpAsyncClient {
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono listResourceTemplates(String cursor) {
+ return this.listResourceTemplatesInternal(cursor, null);
+ }
+
+ /**
+ * Retrieves a paginated list of resource templates provided by the server. Resource
+ * templates allow servers to expose parameterized resources using URI templates,
+ * enabling dynamic resource access based on variable parameters.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that completes with the list of resource templates result.
+ * @see McpSchema.ListResourceTemplatesResult
+ */
+ public Mono listResourceTemplates(String cursor, Map meta) {
+ return this.listResourceTemplatesInternal(cursor, meta);
+ }
+
+ private Mono listResourceTemplatesInternal(String cursor,
+ Map meta) {
return this.initializer.withInitialization("listing resource templates", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
- .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor),
+ .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
});
}
@@ -895,8 +942,26 @@ public class McpAsyncClient {
* @see #getPrompt(GetPromptRequest)
*/
public Mono listPrompts(String cursor) {
- return this.initializer.withInitialization("listing prompts", init -> init.mcpSession()
- .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF));
+ return this.listPromptsInternal(cursor, null);
+ }
+
+ /**
+ * Retrieves a paginated list of prompts with optional metadata.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that completes with the list of prompts result.
+ * @see McpSchema.ListPromptsResult
+ * @see #getPrompt(GetPromptRequest)
+ */
+ public Mono listPrompts(String cursor, Map meta) {
+ return this.listPromptsInternal(cursor, meta);
+ }
+
+ private Mono listPromptsInternal(String cursor, Map meta) {
+ return this.initializer.withInitialization("listing prompts",
+ init -> init.mcpSession()
+ .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta),
+ LIST_PROMPTS_RESULT_TYPE_REF));
}
/**
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
index 7fdaa89..7e08f83 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
@@ -5,6 +5,7 @@
package io.modelcontextprotocol.client;
import java.time.Duration;
+import java.util.Map;
import java.util.function.Supplier;
import org.slf4j.Logger;
@@ -259,6 +260,18 @@ public class McpSyncClient implements AutoCloseable {
}
+ /**
+ * Retrieves a paginated list of tools provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of tools result containing: - tools: List of available tools, each
+ * with a name, description, and input schema - nextCursor: Optional cursor for
+ * pagination if more tools are available
+ */
+ public McpSchema.ListToolsResult listTools(String cursor, Map meta) {
+ return withProvidedContext(this.delegate.listTools(cursor, meta)).block();
+ }
+
// --------------------------
// Resources
// --------------------------
@@ -282,6 +295,17 @@ public class McpSyncClient implements AutoCloseable {
}
+ /**
+ * Retrieves a paginated list of resources with optional metadata.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of resources result
+ */
+ public McpSchema.ListResourcesResult listResources(String cursor, Map meta) {
+ return withProvidedContext(this.delegate.listResources(cursor, meta)).block();
+
+ }
+
/**
* Send a resources/read request.
* @param resource the resource to read
@@ -324,6 +348,20 @@ public class McpSyncClient implements AutoCloseable {
}
+ /**
+ * Resource templates allow servers to expose parameterized resources using URI
+ * templates. Arguments may be auto-completed through the completion API.
+ *
+ * Retrieves a paginated list of resource templates provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of resource templates result.
+ */
+ public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor, Map meta) {
+ return withProvidedContext(this.delegate.listResourceTemplates(cursor, meta)).block();
+
+ }
+
/**
* Subscriptions. The protocol supports optional subscriptions to resource changes.
* Clients can subscribe to specific resources and receive notifications when they
@@ -370,6 +408,17 @@ public class McpSyncClient implements AutoCloseable {
}
+ /**
+ * Retrieves a paginated list of prompts provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of prompts result.
+ */
+ public ListPromptsResult listPrompts(String cursor, Map meta) {
+ return withProvidedContext(this.delegate.listPrompts(cursor, meta)).block();
+
+ }
+
public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) {
return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block();
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java
new file mode 100644
index 0000000..4be5875
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+package io.modelcontextprotocol.client.transport;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import io.modelcontextprotocol.util.Assert;
+
+/**
+ * Default {@link SseMessageEndpointValidator} that validates the {@code message} endpoint
+ * advertised by an SSE server. Message endpoints must either have the same origin as the
+ * SSE uri, or be a relative uri.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public final class DefaultSseMessageEndpointValidator implements SseMessageEndpointValidator {
+
+ @Override
+ public void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException {
+ Assert.hasText(messageEndpoint, "messageEndpoint must not be empty");
+
+ URI endpointUri;
+ try {
+ endpointUri = new URI(messageEndpoint);
+ }
+ catch (URISyntaxException ex) {
+ throw new InvalidSseMessageEndpointException("messageEndpoint is not a valid URI: " + ex.getMessage(),
+ messageEndpoint);
+ }
+
+ if (endpointUri.isAbsolute() || endpointUri.getRawAuthority() != null) {
+ String scheme = endpointUri.getScheme();
+ String host = endpointUri.getHost();
+ int port = endpointUri.getPort();
+
+ boolean sameScheme = scheme != null && scheme.equalsIgnoreCase(sseUri.getScheme());
+ boolean sameHost = host != null && host.equalsIgnoreCase(sseUri.getHost());
+ boolean samePort = port == sseUri.getPort();
+
+ if (!sameScheme || !sameHost || !samePort) {
+ throw new InvalidSseMessageEndpointException(
+ "messageEndpoint must be a relative path or a same-origin URI", messageEndpoint);
+ }
+ }
+
+ // Exclude path-traversal
+ String decodedPath = endpointUri.getPath();
+ if (decodedPath != null) {
+ for (String segment : decodedPath.split("/", -1)) {
+ if (".".equals(segment) || "..".equals(segment)) {
+ throw new InvalidSseMessageEndpointException(
+ "messageEndpoint must not contain path-traversal segments", messageEndpoint);
+ }
+ }
+ }
+
+ }
+
+}
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 be4e4cf..d4b0bbb 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java
@@ -16,8 +16,6 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
@@ -33,6 +31,8 @@ import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -82,6 +82,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
/** Default SSE endpoint path */
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
+ /**
+ * Default maximum number of bytes read for a single inbound message.
+ */
+ private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
+
/** Base URI for the MCP server */
private final URI baseUri;
@@ -117,6 +122,17 @@ public class HttpClientSseClientTransport implements McpClientTransport {
*/
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
+ /**
+ * Validator for the message endpoint;
+ */
+ 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.
@@ -127,22 +143,30 @@ public class HttpClientSseClientTransport implements McpClientTransport {
* @param jsonMapper the object mapper for JSON serialization/deserialization
* @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) {
+ String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
+ SseMessageEndpointValidator messageEndpointValidator, int maxResponseSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
Assert.notNull(httpClient, "httpClient must not be null");
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;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
this.httpRequestCustomizer = httpRequestCustomizer;
+ this.messageEndpointValidator = messageEndpointValidator;
+ this.maxResponseSize = maxResponseSize;
}
@Override
@@ -178,6 +202,10 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private Duration connectTimeout = Duration.ofSeconds(10);
+ private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
+
+ private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
+
/**
* Creates a new builder instance.
*/
@@ -241,10 +269,17 @@ public class HttpClientSseClientTransport implements McpClientTransport {
}
/**
- * Customizes the HTTP client builder.
+ * Applies the given consumer to the shared {@link HttpRequest.Builder} once,
+ * at build time. Any headers set here are frozen into the template and
+ * cannot be updated after the transport is built.
* @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder
+ * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
+ * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
+ * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
+ * dynamic per-request customization.
*/
+ @Deprecated
public Builder customizeRequest(final Consumer requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder);
@@ -308,6 +343,38 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return this;
}
+ /**
+ * Sets the validator that ensure the message endpoint returned over the SSE
+ * connection is valid.
+ * @param messageEndpointValidator the validator
+ * @return this builder
+ */
+ public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpointValidator) {
+ Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
+ this.messageEndpointValidator = messageEndpointValidator;
+ 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
@@ -315,7 +382,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
public HttpClientSseClientTransport build() {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
- jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer);
+ jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
+ messageEndpointValidator, maxResponseSize);
}
}
@@ -334,13 +402,15 @@ public class HttpClientSseClientTransport implements McpClientTransport {
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
}).flatMap(requestBuilder -> Mono.create(sink -> {
- Disposable connection = Flux.create(sseSink -> this.httpClient
- .sendAsync(requestBuilder.build(),
- responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
- .exceptionallyCompose(e -> {
- sseSink.error(e);
- return CompletableFuture.failedFuture(e);
- }))
+ Disposable connection = Flux.create(
+ sseSink -> this.httpClient
+ .sendAsync(requestBuilder.build(),
+ responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink,
+ this.maxResponseSize))
+ .exceptionallyCompose(e -> {
+ sseSink.error(e);
+ return CompletableFuture.failedFuture(e);
+ }))
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
.flatMap(responseEvent -> {
if (isClosing) {
@@ -353,6 +423,14 @@ public class HttpClientSseClientTransport implements McpClientTransport {
try {
if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
String messageEndpointUri = responseEvent.sseEvent().data();
+ try {
+ messageEndpointValidator.validate(uri, messageEndpointUri);
+ }
+ catch (InvalidSseMessageEndpointException e) {
+ sink.error(e);
+ this.messageEndpointSink.tryEmitError(e);
+ return Flux.error(e);
+ }
if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) {
sink.success();
return Flux.empty(); // No further processing needed
@@ -456,14 +534,15 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return Mono.deferContextual(ctx -> {
var builder = this.requestBuilder.copy()
.uri(requestUri)
- .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .header(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(body));
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
}).flatMap(customizedBuilder -> {
var request = customizedBuilder.build();
- return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
+ return Mono.fromFuture(
+ httpClient.sendAsync(request, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
});
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index 57a27a3..7f1d00f 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
@@ -8,7 +8,6 @@ import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.time.Duration;
import java.util.Collections;
@@ -85,6 +84,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final String DEFAULT_ENDPOINT = "/mcp";
+ /**
+ * Default maximum number of bytes read for a single inbound message.
+ */
+ private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
+
/**
* HTTP client for sending messages to the server. Uses HTTP POST over the message
* endpoint
@@ -102,6 +106,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final String APPLICATION_JSON = "application/json";
+ private static final String APPLICATION_JSON_UTF8 = "application/json; charset=utf-8";
+
private static final String TEXT_EVENT_STREAM = "text/event-stream";
public static int NOT_FOUND = 404;
@@ -134,10 +140,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private final String latestSupportedProtocolVersion;
+ /**
+ * Maximum number of bytes read for a single inbound message, whether it arrives on an
+ * SSE stream or as a JSON response body.
+ */
+ private final int maxResponseSize;
+
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
- McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List supportedProtocolVersions) {
+ McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List supportedProtocolVersions,
+ int maxResponseSize) {
+ Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
this.jsonMapper = jsonMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
@@ -153,6 +167,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
.sorted(Comparator.reverseOrder())
.findFirst()
.get();
+ this.maxResponseSize = maxResponseSize;
}
@Override
@@ -209,7 +224,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
}).flatMap(requestBuilder -> {
var request = requestBuilder.build();
- return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
+ return Mono.fromFuture(() -> this.httpClient.sendAsync(request,
+ ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
}).then();
}
@@ -298,6 +314,10 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
"Authorization error connecting to SSE stream",
responseEvent.responseInfo()));
}
+ else if (statusCode == METHOD_NOT_ALLOWED) {
+ logger.debug("The server does not support SSE streams, using request-response mode.");
+ return Flux.empty();
+ }
if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) {
return Flux.error(new McpTransportException(
@@ -344,10 +364,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.empty();
}
}
- else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
- logger.debug("The server does not support SSE streams, using request-response mode.");
- return Flux.empty();
- }
else if (statusCode == NOT_FOUND) {
if (transportSession != null && transportSession.sessionId().isPresent()) {
@@ -432,16 +448,16 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
if (contentType.contains(TEXT_EVENT_STREAM)) {
// For SSE streams, use line subscriber that returns Void
logger.debug("Received SSE stream response, using line subscriber");
- return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
+ return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink, this.maxResponseSize);
}
else if (contentType.contains(APPLICATION_JSON)) {
// For JSON responses and others, use string subscriber
logger.debug("Received response, using string subscriber");
- return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
+ return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink, this.maxResponseSize);
}
logger.debug("Received Bodyless response, using discarding subscriber");
- return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
+ return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
};
return responseBodyHandler;
@@ -477,7 +493,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
var builder = requestBuilder.uri(uri)
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
- .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8)
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
.header(HttpHeaders.PROTOCOL_VERSION,
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
@@ -629,6 +645,10 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.error(new McpTransportException(
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
}
+ else if (statusCode >= 400 && statusCode < 500) {
+ return Flux.error(
+ new McpTransportException("Invalid request. Status code: " + statusCode));
+ }
return Flux.error(
new RuntimeException("Failed to send message: " + responseEvent));
@@ -695,6 +715,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
+ private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
+
/**
* Creates a new builder with the specified base URI.
* @param baseUri the base URI of the MCP server
@@ -738,10 +760,17 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
}
/**
- * Customizes the HTTP client builder.
+ * Applies the given consumer to the shared {@link HttpRequest.Builder} once,
+ * at build time. Any headers set here are frozen into the template and
+ * cannot be updated after the transport is built.
* @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder
+ * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
+ * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
+ * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
+ * dynamic per-request customization.
*/
+ @Deprecated
public Builder customizeRequest(final Consumer requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder);
@@ -878,6 +907,26 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return this;
}
+ /**
+ * Sets the maximum number of bytes read for a single inbound message, whether it
+ * arrives on an SSE stream or as a JSON response body. A peer that sends a larger
+ * message (or never terminates one) has its stream aborted instead of forcing the
+ * transport to buffer it in memory. Defaults to 16MiB.
+ *
+ *
+ * The bound applies per message, not to the stream as a whole: a long-lived SSE
+ * stream may deliver any number of messages, each up to this size. SSE field
+ * framing is allowed a small amount of headroom on top of this size, so a message
+ * of exactly this many bytes is still accepted.
+ * @param maxResponseSize the maximum inbound message size, in bytes
+ * @return this builder
+ */
+ public Builder maxResponseSize(int maxResponseSize) {
+ Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
+ this.maxResponseSize = maxResponseSize;
+ return this;
+ }
+
/**
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
* the current builder configuration.
@@ -887,7 +936,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
- httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
+ httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java
new file mode 100644
index 0000000..6acdfae
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+/**
+ * Exception thrown when the {@code message} endpoint returned from the SSE connection is
+ * not valid.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class InvalidSseMessageEndpointException extends Exception {
+
+ private final String messageEndpoint;
+
+ public InvalidSseMessageEndpointException(String message, String messageEndpoint) {
+ super(message);
+ this.messageEndpoint = messageEndpoint;
+ }
+
+ public String getMessageEndpoint() {
+ return messageEndpoint;
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java
index 29dc23c..b19904d 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ResponseSubscribers.java
@@ -5,8 +5,13 @@
package io.modelcontextprotocol.client.transport;
import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.BodySubscriber;
import java.net.http.HttpResponse.ResponseInfo;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
@@ -30,11 +35,21 @@ import reactor.core.publisher.FluxSink;
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
+ * @author Daniel Garnier-Moiroux
*/
class ResponseSubscribers {
private static final Logger logger = LoggerFactory.getLogger(ResponseSubscribers.class);
+ /**
+ * Bytes of SSE field framing a single line may carry on top of the message payload:
+ * {@code "event: "} is the longest field prefix this parser recognises. Line
+ * terminators are not counted, as they reset the running line length. Without this
+ * allowance, an event carrying exactly the maximum message size would be rejected
+ * because of the bytes the SSE wire format adds around it.
+ */
+ private static final int SSE_FRAMING_OVERHEAD = "event: ".length();
+
record SseEvent(String id, String event, String data) {
}
@@ -54,19 +69,80 @@ class ResponseSubscribers {
record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent {
}
- static BodySubscriber sseToBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
- .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink)));
+ /**
+ * Creates a {@link BodySubscriber} that parses a Server-Sent Events stream, bounding
+ * how much memory a single inbound message may occupy. Both the size of an individual
+ * line (as read off the wire before a terminator is seen) and the accumulated size of
+ * a multi-line SSE event are capped at {@code maxSize}; a peer exceeding either limit
+ * has its stream aborted instead of forcing the transport to buffer it in memory. The
+ * line bound is allowed {@link #SSE_FRAMING_OVERHEAD} extra bytes so that the SSE
+ * framing around a payload does not count against the payload's own budget.
+ * @param responseInfo the HTTP response information
+ * @param sink the sink to emit parsed events to
+ * @param maxSize the maximum number of bytes read for a single inbound message
+ */
+ static BodySubscriber sseToBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
+ int maxSize) {
+ BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
+ .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink, maxSize)));
+ return new BoundedLineBodySubscriber(lineSubscriber, plusFramingOverhead(maxSize));
}
- static BodySubscriber aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
- .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink)));
+ /**
+ * Adds {@link #SSE_FRAMING_OVERHEAD} to {@code maxSize}, saturating at
+ * {@link Integer#MAX_VALUE} rather than overflowing into a negative bound that would
+ * reject everything.
+ */
+ private static int plusFramingOverhead(int maxSize) {
+ return maxSize > Integer.MAX_VALUE - SSE_FRAMING_OVERHEAD ? Integer.MAX_VALUE : maxSize + SSE_FRAMING_OVERHEAD;
}
- static BodySubscriber bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink) {
- return HttpResponse.BodySubscribers
+ /**
+ * Creates a {@link BodySubscriber} that aggregates the whole response body into a
+ * single event, bounding how much memory it may occupy. Both the size of an
+ * individual line (as read off the wire before a terminator is seen) and the total
+ * accumulated body are capped at {@code maxSize}; a peer exceeding either limit has
+ * its response aborted instead of forcing the transport to buffer it in memory.
+ * @param responseInfo the HTTP response information
+ * @param sink the sink to emit the aggregated event to
+ * @param maxSize the maximum number of bytes read for the response body
+ */
+ static BodySubscriber aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
+ int maxSize) {
+ BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
+ .fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink, maxSize)));
+ return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
+ }
+
+ /**
+ * Creates a {@link BodySubscriber} that discards the response body, bounding how much
+ * memory reading it may occupy. The body is discarded as it arrives, but the
+ * underlying line subscriber still buffers each line before handing it over, so a
+ * peer sending a line longer than {@code maxSize} has its response aborted.
+ * @param responseInfo the HTTP response information
+ * @param sink the sink to emit the completion event to
+ * @param maxSize the maximum number of bytes read for a single line
+ */
+ static BodySubscriber bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink sink,
+ int maxSize) {
+ BodySubscriber lineSubscriber = HttpResponse.BodySubscribers
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink)));
+ return new BoundedLineBodySubscriber(lineSubscriber, maxSize);
+ }
+
+ /**
+ * Creates a {@link BodyHandler} that reads the response body into a string, bounding
+ * how much memory it may occupy. A peer sending more than {@code maxSize} bytes has
+ * its response aborted instead of forcing the transport to buffer it in memory.
+ *
+ *
+ * Decoding matches {@link HttpResponse.BodyHandlers#ofString()}, including its
+ * handling of the charset declared in the {@code Content-Type} header.
+ * @param maxSize the maximum number of bytes read for the response body
+ */
+ static BodyHandler boundedStringBodyHandler(int maxSize) {
+ BodyHandler delegate = HttpResponse.BodyHandlers.ofString();
+ return responseInfo -> new BoundedTotalBodySubscriber<>(delegate.apply(responseInfo), maxSize);
}
static class SseLineSubscriber extends BaseSubscriber {
@@ -112,18 +188,30 @@ class ResponseSubscribers {
*/
private ResponseInfo responseInfo;
+ /**
+ * The maximum number of bytes that may accumulate for a single SSE event. A peer
+ * that never terminates an event (e.g. an endless stream of {@code data:} lines)
+ * has its stream aborted instead of exhausting memory. The accumulated data is
+ * measured in characters, which for UTF-8 is never more than the number of bytes
+ * it was decoded from.
+ */
+ private final int maxSize;
+
/**
* Creates a new LineSubscriber that will emit parsed SSE events to the provided
* sink.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to
+ * @param maxSize the maximum number of bytes that may accumulate for a single SSE
+ * event
*/
- public SseLineSubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ public SseLineSubscriber(ResponseInfo responseInfo, FluxSink sink, int maxSize) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.currentEventId = new AtomicReference<>();
this.currentEventType = new AtomicReference<>();
this.responseInfo = responseInfo;
+ this.maxSize = maxSize;
}
@Override
@@ -155,7 +243,18 @@ class ResponseSubscribers {
if (line.startsWith("data:")) {
var matcher = EVENT_DATA_PATTERN.matcher(line);
if (matcher.find()) {
- this.eventBuilder.append(matcher.group(1).trim()).append("\n");
+ String data = matcher.group(1).trim();
+ // Measured before appending, so that an event carrying exactly
+ // maxSize of data is accepted: the trailing separator below is
+ // stripped again before the event is emitted.
+ if (this.eventBuilder.length() + data.length() > this.maxSize) {
+ upstream().cancel();
+ this.sink.error(
+ new McpTransportException("Inbound SSE event exceeds the maximum allowed size of "
+ + this.maxSize + " bytes"));
+ return;
+ }
+ this.eventBuilder.append(data).append("\n");
}
upstream().request(1);
}
@@ -225,15 +324,26 @@ class ResponseSubscribers {
volatile boolean hasRequestedDemand = false;
+ /**
+ * The maximum number of bytes that may accumulate for the aggregated response
+ * body. A peer that sends a larger body has its response aborted instead of
+ * exhausting memory. The accumulated body is measured in characters, which for
+ * UTF-8 is never more than the number of bytes it was decoded from.
+ */
+ private final int maxSize;
+
/**
* Creates a new JsonLineSubscriber that will emit parsed JSON-RPC messages.
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
* to
+ * @param maxSize the maximum number of bytes that may accumulate for the
+ * aggregated response body
*/
- public AggregateSubscriber(ResponseInfo responseInfo, FluxSink sink) {
+ public AggregateSubscriber(ResponseInfo responseInfo, FluxSink sink, int maxSize) {
this.sink = sink;
this.eventBuilder = new StringBuilder();
this.responseInfo = responseInfo;
+ this.maxSize = maxSize;
}
@Override
@@ -252,6 +362,15 @@ class ResponseSubscribers {
@Override
protected void hookOnNext(String line) {
+ // Measured before appending, so that a body of exactly maxSize is accepted.
+ // The separator this adds back for each line stands in for the terminator the
+ // peer sent, which the line subscriber has already stripped.
+ if (this.eventBuilder.length() + line.length() > this.maxSize) {
+ upstream().cancel();
+ this.sink.error(new McpTransportException(
+ "Inbound response body exceeds the maximum allowed size of " + this.maxSize + " bytes"));
+ return;
+ }
this.eventBuilder.append(line).append("\n");
}
@@ -324,4 +443,177 @@ class ResponseSubscribers {
}
+ /**
+ * Base for {@link BodySubscriber} wrappers that transparently forward the response
+ * body to a delegate, but abort it once the peer exceeds a size bound.
+ *
+ *
+ * Aborting cancels the upstream subscription, which closes the connection, and
+ * signals a {@link McpTransportException} to the delegate so the failure surfaces
+ * both through the body's {@link CompletionStage} and through any sink the delegate
+ * feeds.
+ */
+ abstract static class BoundedBodySubscriber implements BodySubscriber {
+
+ private final BodySubscriber delegate;
+
+ protected final int maxSize;
+
+ /**
+ * What the bound applies to, e.g. {@code "Inbound line"}, used to build the
+ * failure message.
+ */
+ private final String boundedEntity;
+
+ private Flow.Subscription subscription;
+
+ private volatile boolean done = false;
+
+ BoundedBodySubscriber(BodySubscriber delegate, int maxSize, String boundedEntity) {
+ this.delegate = delegate;
+ this.maxSize = maxSize;
+ this.boundedEntity = boundedEntity;
+ }
+
+ @Override
+ public CompletionStage getBody() {
+ return this.delegate.getBody();
+ }
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ this.subscription = subscription;
+ this.delegate.onSubscribe(subscription);
+ }
+
+ @Override
+ public void onNext(List buffers) {
+ if (this.done) {
+ return;
+ }
+ for (ByteBuffer buffer : buffers) {
+ if (!checkSize(buffer)) {
+ this.done = true;
+ this.subscription.cancel();
+ this.delegate.onError(new McpTransportException(
+ this.boundedEntity + " exceeds the maximum allowed size of " + this.maxSize + " bytes"));
+ return;
+ }
+ }
+ this.delegate.onNext(buffers);
+ }
+
+ /**
+ * Accounts for the bytes in {@code buffer}, which must be inspected with absolute
+ * reads only so the delegate still sees the original position.
+ * @param buffer the buffer about to be handed to the delegate
+ * @return {@code true} to accept the buffer, or {@code false} to abort the
+ * response because the bound has been exceeded
+ */
+ protected abstract boolean checkSize(ByteBuffer buffer);
+
+ @Override
+ public void onError(Throwable throwable) {
+ if (this.done) {
+ return;
+ }
+ this.done = true;
+ this.delegate.onError(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ if (this.done) {
+ return;
+ }
+ this.done = true;
+ this.delegate.onComplete();
+ }
+
+ }
+
+ /**
+ * A {@link BoundedBodySubscriber} that aborts the response once a single line (a run
+ * of bytes with no CR/LF terminator) exceeds {@code maxSize} bytes.
+ *
+ *
+ * {@link HttpResponse.BodySubscribers#fromLineSubscriber} buffers characters until it
+ * encounters a line terminator, so a peer that never terminates a line (or sends an
+ * enormous one) would force the transport to buffer it in memory. This wrapper counts
+ * bytes as they arrive off the wire and cancels the subscription before that buffer
+ * can grow without bound.
+ */
+ static final class BoundedLineBodySubscriber extends BoundedBodySubscriber {
+
+ private long bytesSinceLineTerminator = 0;
+
+ BoundedLineBodySubscriber(BodySubscriber delegate, int maxSize) {
+ super(delegate, maxSize, "Inbound line");
+ }
+
+ @Override
+ protected boolean checkSize(ByteBuffer buffer) {
+ int position = buffer.position();
+ int limit = buffer.limit();
+ if (position == limit) {
+ return true;
+ }
+ if (this.bytesSinceLineTerminator + (limit - position) <= this.maxSize) {
+ // No line ending in this buffer can exceed the limit, because there are
+ // not enough bytes since the last terminator for one to. Only the
+ // trailing (still unterminated) run matters, so scan back to the last
+ // terminator instead of walking every byte.
+ this.bytesSinceLineTerminator = lengthOfTrailingRun(buffer, position, limit);
+ return true;
+ }
+ // The limit is within reach, so account for every line exactly.
+ for (int i = position; i < limit; i++) {
+ byte b = buffer.get(i);
+ if (b == '\n' || b == '\r') {
+ this.bytesSinceLineTerminator = 0;
+ }
+ else if (++this.bytesSinceLineTerminator > this.maxSize) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Returns the number of bytes after the last line terminator in the buffer, or
+ * the whole span added to the running count when the buffer holds no terminator.
+ */
+ private long lengthOfTrailingRun(ByteBuffer buffer, int position, int limit) {
+ for (int i = limit - 1; i >= position; i--) {
+ byte b = buffer.get(i);
+ if (b == '\n' || b == '\r') {
+ return limit - 1 - i;
+ }
+ }
+ return this.bytesSinceLineTerminator + (limit - position);
+ }
+
+ }
+
+ /**
+ * A {@link BoundedBodySubscriber} that aborts the response once the body as a whole
+ * exceeds {@code maxSize} bytes. Suitable for delegates that aggregate the entire
+ * body in memory, such as {@link HttpResponse.BodyHandlers#ofString()}.
+ */
+ static final class BoundedTotalBodySubscriber extends BoundedBodySubscriber {
+
+ private long totalBytes = 0;
+
+ BoundedTotalBodySubscriber(BodySubscriber delegate, int maxSize) {
+ super(delegate, maxSize, "Inbound response body");
+ }
+
+ @Override
+ protected boolean checkSize(ByteBuffer buffer) {
+ this.totalBytes += buffer.remaining();
+ return this.totalBytes <= this.maxSize;
+ }
+
+ }
+
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java
new file mode 100644
index 0000000..322e646
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.net.URI;
+
+/**
+ * Validate the that message endpoint in the SSE transport is valid. Throws
+ * {@link InvalidSseMessageEndpointException} when then endpoint is not valid.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+@FunctionalInterface
+public interface SseMessageEndpointValidator {
+
+ /**
+ * Validate the message endpoint coming from an SSE connection. Throws if not valid.
+ * @param sseUri the URI used to establish the SSE connection
+ * @param messageEndpoint the message endpoint from the SSE connection
+ * @throws InvalidSseMessageEndpointException error thrown if the message endpoint is
+ * not valid.
+ */
+ void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException;
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
index 3224694..721bdf1 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
@@ -4,6 +4,10 @@
package io.modelcontextprotocol.server.transport;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
@@ -37,4 +41,32 @@ final class HttpServletRequestUtils {
return headers;
}
+ /**
+ * Reads the request body, decoded using the request's character encoding (or UTF-8 if
+ * not specified), while bounding the number of bytes read.
+ * @param request The HTTP servlet request
+ * @param maxSize The maximum number of bytes to read from the request body
+ * @return The decoded request body
+ * @throws MaxSizeExceededException If the body exceeds {@code maxSize}
+ * @throws IOException If an I/O error occurs while reading the request body
+ */
+ static String readBody(HttpServletRequest request, int maxSize) throws MaxSizeExceededException, IOException {
+ InputStream inputStream = request.getInputStream();
+ ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int totalBytes = 0;
+ int readBytes;
+ while ((readBytes = inputStream.read(buf, 0, buf.length)) != -1) {
+ totalBytes += readBytes;
+ if (totalBytes > maxSize) {
+ throw new MaxSizeExceededException(
+ "Request body exceeds the maximum allowed size of " + maxSize + " bytes");
+ }
+ bodyBytes.write(buf, 0, readBytes);
+ }
+ String charset = request.getCharacterEncoding() != null ? request.getCharacterEncoding()
+ : StandardCharsets.UTF_8.name();
+ return bodyBytes.toString(charset);
+ }
+
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
index d3648a0..3c9802b 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
@@ -4,7 +4,6 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
@@ -70,6 +69,11 @@ import reactor.core.publisher.Mono;
@WebServlet(asyncSupported = true)
public class HttpServletSseServerTransportProvider extends HttpServlet implements McpServerTransportProvider {
+ /**
+ * Default maximum size of a single request body: 16 MiB (16 * 1024 * 1024 bytes).
+ */
+ private static final int DEFAULT_REQUEST_MAX_SIZE = 16 * 1024 * 1024;
+
/**
* Logger for this class
*/
@@ -105,6 +109,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
*/
private final McpJsonMapper jsonMapper;
+ /**
+ * Maximum size, in bytes, of a single request body accepted by this transport.
+ */
+ private final int requestMaxSize;
+
/**
* Base URL for the server transport
*/
@@ -160,17 +169,20 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* keep-alive functionality
* @param contextExtractor The extractor for transport context from the request.
* @param securityValidator The security validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
*/
private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor contextExtractor,
- ServerTransportSecurityValidator securityValidator) {
+ ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(messageEndpoint, "messageEndpoint must not be null");
Assert.notNull(sseEndpoint, "sseEndpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.baseUrl = baseUrl;
@@ -178,6 +190,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
+ this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
@@ -286,7 +299,6 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
- response.setHeader("Access-Control-Allow-Origin", "*");
String sessionId = UUID.randomUUID().toString();
AsyncContext asyncContext = request.startAsync();
@@ -341,6 +353,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
return;
}
+ if (request.getContentLengthLong() > this.requestMaxSize) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ return;
+ }
+
String requestURI = request.getRequestURI();
if (!requestURI.endsWith(messageEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
@@ -387,15 +404,10 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- BufferedReader reader = request.getReader();
- StringBuilder body = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- body.append(line);
- }
+ String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
final McpTransportContext transportContext = this.contextExtractor.extract(request);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
// Process the message through the session's handle method
// Block for Servlet compatibility
@@ -403,6 +415,9 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
response.setStatus(HttpServletResponse.SC_OK);
}
+ catch (MaxSizeExceededException e) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
catch (Exception e) {
logger.error("Error processing message: {}", e.getMessage());
try {
@@ -600,6 +615,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
/**
* Sets the JsonMapper implementation to use for serialization/deserialization. If
* not specified, a JacksonJsonMapper will be created from the configured
@@ -686,6 +703,19 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
return this;
}
+ /**
+ * Sets the maximum size, in bytes, of a single request body accepted by this
+ * transport. Requests whose body exceeds this size are rejected with a 413
+ * (Payload Too Large) response. Defaults to 16 MiB if not set.
+ * @param requestMaxSize The maximum request body size, in bytes. Must be
+ * positive.
+ * @return This builder instance
+ */
+ public Builder maxRequestSize(int requestMaxSize) {
+ this.requestMaxSize = requestMaxSize;
+ return this;
+ }
+
/**
* Builds a new instance of HttpServletSseServerTransportProvider with the
* configured settings.
@@ -698,7 +728,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint,
- sseEndpoint, keepAliveInterval, contextExtractor, securityValidator);
+ sseEndpoint, keepAliveInterval, contextExtractor, securityValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
index 047aeeb..54f0ac0 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
@@ -4,7 +4,6 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
@@ -39,6 +38,11 @@ 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";
@@ -66,18 +70,37 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
*/
private final ServerTransportSecurityValidator securityValidator;
+ /**
+ * Maximum size, in bytes, of a single request body accepted by this transport.
+ */
+ private final int requestMaxSize;
+
+ /**
+ * Constructs a new HttpServletStatelessServerTransport instance.
+ * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of
+ * messages.
+ * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
+ * messages.
+ * @param contextExtractor The extractor for transport context from the request.
+ * @param securityValidator The security validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
+ * @throws IllegalArgumentException if any parameter is null
+ */
private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor contextExtractor,
- ServerTransportSecurityValidator securityValidator) {
+ ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
+ this.requestMaxSize = requestMaxSize;
}
@Override
@@ -133,6 +156,11 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
return;
}
+ if (request.getContentLengthLong() > this.requestMaxSize) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ return;
+ }
+
try {
Map> headers = HttpServletRequestUtils.extractHeaders(request);
this.securityValidator.validateHeaders(headers);
@@ -154,14 +182,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
}
try {
- BufferedReader reader = request.getReader();
- StringBuilder body = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- body.append(line);
- }
+ String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
@@ -209,6 +232,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
.build());
}
}
+ catch (MaxSizeExceededException e) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
@@ -276,6 +302,8 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
private Builder() {
// used by a static method
}
@@ -333,6 +361,19 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
return this;
}
+ /**
+ * Sets the maximum size, in bytes, of a single request body accepted by this
+ * transport. Requests whose body exceeds this size are rejected with a 413
+ * (Payload Too Large) response. Defaults to 16 MiB if not set.
+ * @param requestMaxSize The maximum request body size, in bytes. Must be
+ * positive.
+ * @return this builder instance
+ */
+ public Builder maxRequestSize(int requestMaxSize) {
+ this.requestMaxSize = requestMaxSize;
+ return this;
+ }
+
/**
* Builds a new instance of {@link HttpServletStatelessServerTransport} with the
* configured settings.
@@ -343,7 +384,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
- securityValidator);
+ securityValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
index 95edb63..5d78c5c 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
@@ -4,7 +4,6 @@
package io.modelcontextprotocol.server.transport;
-import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Duration;
@@ -14,12 +13,10 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import io.modelcontextprotocol.json.TypeRef;
-
import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpError;
@@ -29,8 +26,6 @@ import io.modelcontextprotocol.spec.McpStreamableServerTransport;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
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.ServletException;
@@ -38,6 +33,8 @@ 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.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -63,6 +60,11 @@ import reactor.core.publisher.Mono;
public class HttpServletStreamableServerTransportProvider extends HttpServlet
implements McpStreamableServerTransportProvider {
+ /**
+ * Default maximum size of a single request body: 16 MiB (16 * 1024 * 1024 bytes).
+ */
+ private static final int DEFAULT_REQUEST_MAX_SIZE = 16 * 1024 * 1024;
+
private static final Logger logger = LoggerFactory.getLogger(HttpServletStreamableServerTransportProvider.class);
/**
@@ -101,6 +103,11 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private final McpJsonMapper jsonMapper;
+ /**
+ * Maximum size, in bytes, of a single request body accepted by this transport.
+ */
+ private final int requestMaxSize;
+
private McpStreamableServerSession.Factory sessionFactory;
/**
@@ -137,21 +144,25 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive
* will be scheduled.
* @param securityValidator The security validator for validating HTTP requests.
+ * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
+ * positive.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor contextExtractor,
- Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
+ Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
this.securityValidator = securityValidator;
+ this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
@@ -315,7 +326,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
- response.setHeader("Access-Control-Allow-Origin", "*");
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);
@@ -406,6 +416,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 {
Map> headers = HttpServletRequestUtils.extractHeaders(request);
@@ -429,14 +443,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
McpTransportContext transportContext = this.contextExtractor.extract(request);
try {
- BufferedReader reader = request.getReader();
- StringBuilder body = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- body.append(line);
- }
+ String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
// Handle initialization request
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
@@ -522,7 +531,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
- response.setHeader("Access-Control-Allow-Origin", "*");
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);
@@ -545,6 +553,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Unknown message type").build());
}
}
+ catch (MaxSizeExceededException e) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
@@ -840,6 +851,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+
/**
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
* messages.
@@ -911,6 +924,19 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return this;
}
+ /**
+ * Sets the maximum size, in bytes, of a single request body accepted by this
+ * transport. Requests whose body exceeds this size are rejected with a 413
+ * (Payload Too Large) response. Defaults to 16 MiB if not set.
+ * @param requestMaxSize The maximum request body size, in bytes. Must be
+ * positive.
+ * @return this builder instance
+ */
+ public Builder maxRequestSize(int requestMaxSize) {
+ this.requestMaxSize = requestMaxSize;
+ return this;
+ }
+
/**
* Builds a new instance of {@link HttpServletStreamableServerTransportProvider}
* with the configured settings.
@@ -921,7 +947,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete,
- contextExtractor, keepAliveInterval, securityValidator);
+ contextExtractor, keepAliveInterval, securityValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java
new file mode 100644
index 0000000..ddca5db
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/MaxSizeExceededException.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+/**
+ * Thrown when reading an inbound message would exceed the configured maximum size.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class MaxSizeExceededException extends Exception {
+
+ MaxSizeExceededException(String message) {
+ super(message);
+ }
+
+}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java
new file mode 100644
index 0000000..5f35031
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/BoundedBodySubscriberTests.java
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.net.http.HttpResponse.BodySubscriber;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Flow;
+
+import io.modelcontextprotocol.client.transport.ResponseSubscribers.BoundedLineBodySubscriber;
+import io.modelcontextprotocol.client.transport.ResponseSubscribers.BoundedTotalBodySubscriber;
+import io.modelcontextprotocol.spec.McpTransportException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests the size accounting in {@link ResponseSubscribers.BoundedBodySubscriber} and its
+ * two implementations. These bound how much of a response the transport will buffer, so
+ * the accounting is exercised directly rather than only through a live HTTP exchange:
+ * buffer boundaries, line terminators split across buffers, and the exact limit are all
+ * places where an off-by-one either lets a peer past the bound or rejects a legitimate
+ * message.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class BoundedBodySubscriberTests {
+
+ private static final int MAX_SIZE = 16;
+
+ private final RecordingBodySubscriber delegate = new RecordingBodySubscriber();
+
+ private final RecordingSubscription subscription = new RecordingSubscription();
+
+ // --- BoundedLineBodySubscriber: per-line accounting -----------------------
+
+ @Test
+ void lineSubscriberAcceptsEmptyBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer(""))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberAcceptsLineOfExactlyMaxSize() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberRejectsLineOneByteOverMaxSize() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE + 1)))).isFalse();
+ }
+
+ @Test
+ void lineSubscriberAccumulatesAcrossBuffers() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(10)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("a".repeat(6)))).isTrue();
+ // 17th byte of the same unterminated line.
+ assertThat(subscriber.checkSize(buffer("a"))).isFalse();
+ }
+
+ @Test
+ void lineSubscriberAcceptsUnboundedTotalOfTerminatedLines() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ // Far more than MAX_SIZE in total, but no single line comes close to it.
+ for (int i = 0; i < 100; i++) {
+ assertThat(subscriber.checkSize(buffer("aaaa\n"))).isTrue();
+ }
+ }
+
+ @Test
+ void lineSubscriberResetsOnTerminatorAtEndOfBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(10) + "\n"))).isTrue();
+ // A fresh line, so the previous 10 bytes must not count towards it.
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberResetsOnTerminatorAtStartOfBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(MAX_SIZE)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("\n" + "a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberHandlesCrLfSplitAcrossBuffers() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(12) + "\r"))).isTrue();
+ assertThat(subscriber.checkSize(buffer("\n" + "a".repeat(MAX_SIZE)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberAcceptsBufferLargerThanMaxSizeHoldingOnlyShortLines() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ // Forces the exact per-byte accounting path: the buffer alone is well over the
+ // limit, yet every line in it is legitimate.
+ assertThat(subscriber.checkSize(buffer("aaaa\n".repeat(20)))).isTrue();
+ }
+
+ @Test
+ void lineSubscriberRejectsRunSpanningManyBuffers() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ boolean accepted = true;
+ for (int i = 0; i < 10 && accepted; i++) {
+ accepted = subscriber.checkSize(buffer("aa"));
+ }
+
+ assertThat(accepted).isFalse();
+ }
+
+ @Test
+ void lineSubscriberOnlyCountsFromTheBufferPosition() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2));
+ partiallyConsumed.position(MAX_SIZE * 2 - 4);
+
+ assertThat(subscriber.checkSize(partiallyConsumed)).isTrue();
+ }
+
+ @Test
+ void lineSubscriberDoesNotConsumeTheBuffer() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ ByteBuffer buffer = buffer("aaaa\nbbbb");
+ buffer.position(2);
+
+ subscriber.checkSize(buffer);
+
+ assertThat(buffer.position()).isEqualTo(2);
+ assertThat(buffer.limit()).isEqualTo(9);
+ }
+
+ // --- BoundedTotalBodySubscriber: whole-body accounting --------------------
+
+ @Test
+ void totalSubscriberAcceptsBodyOfExactlyMaxSize() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
+ }
+
+ @Test
+ void totalSubscriberRejectsBodyOneByteOverMaxSize() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ assertThat(subscriber.checkSize(buffer("a".repeat(8)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("a".repeat(9)))).isFalse();
+ }
+
+ @Test
+ void totalSubscriberIsNotResetByLineTerminators() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ // Unlike the per-line bound, terminated lines still count towards the total.
+ assertThat(subscriber.checkSize(buffer("aaaa\n".repeat(3)))).isTrue();
+ assertThat(subscriber.checkSize(buffer("aaaa\n"))).isFalse();
+ }
+
+ @Test
+ void totalSubscriberOnlyCountsFromTheBufferPosition() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+ ByteBuffer partiallyConsumed = buffer("a".repeat(MAX_SIZE * 2));
+ partiallyConsumed.position(MAX_SIZE);
+
+ assertThat(subscriber.checkSize(partiallyConsumed)).isTrue();
+ }
+
+ @Test
+ void totalSubscriberDoesNotConsumeTheBuffer() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+ ByteBuffer buffer = buffer("aaaa");
+
+ subscriber.checkSize(buffer);
+
+ assertThat(buffer.position()).isZero();
+ assertThat(buffer.remaining()).isEqualTo(4);
+ }
+
+ // --- onNext: what a failed check does ------------------------------------
+
+ @Test
+ void forwardsBuffersWhileWithinBounds() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ List buffers = List.of(buffer("aaaa\n"), buffer("bbbb\n"));
+
+ subscriber.onNext(buffers);
+
+ assertThat(this.delegate.received).containsExactly(buffers);
+ assertThat(this.delegate.error).isNull();
+ assertThat(this.subscription.cancellations).isZero();
+ }
+
+ @Test
+ void abortsTheResponseWhenTheLineBoundIsExceeded() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
+
+ assertThat(this.subscription.cancellations).isEqualTo(1);
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isInstanceOf(McpTransportException.class)
+ .hasMessage("Inbound line exceeds the maximum allowed size of " + MAX_SIZE + " bytes");
+ }
+
+ @Test
+ void abortsTheResponseWhenTheTotalBoundIsExceeded() {
+ BoundedTotalBodySubscriber subscriber = totalSubscriber();
+
+ subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
+
+ assertThat(this.subscription.cancellations).isEqualTo(1);
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isInstanceOf(McpTransportException.class)
+ .hasMessage("Inbound response body exceeds the maximum allowed size of " + MAX_SIZE + " bytes");
+ }
+
+ @Test
+ void withholdsTheWholeListWhenALaterBufferExceedsTheBound() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+
+ subscriber.onNext(List.of(buffer("a".repeat(8)), buffer("a".repeat(9))));
+
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isInstanceOf(McpTransportException.class);
+ }
+
+ @Test
+ void ignoresFurtherSignalsOnceAborted() {
+ BoundedLineBodySubscriber subscriber = lineSubscriber();
+ subscriber.onNext(List.of(buffer("a".repeat(MAX_SIZE + 1))));
+ Throwable firstError = this.delegate.error;
+
+ // The HTTP client may still signal after the subscription is cancelled.
+ subscriber.onNext(List.of(buffer("aaaa")));
+ subscriber.onError(new RuntimeException("late failure"));
+ subscriber.onComplete();
+
+ assertThat(this.subscription.cancellations).isEqualTo(1);
+ assertThat(this.delegate.received).isEmpty();
+ assertThat(this.delegate.error).isSameAs(firstError);
+ assertThat(this.delegate.completed).isFalse();
+ }
+
+ // --- fixtures ------------------------------------------------------------
+
+ private BoundedLineBodySubscriber lineSubscriber() {
+ BoundedLineBodySubscriber subscriber = new BoundedLineBodySubscriber(this.delegate, MAX_SIZE);
+ subscriber.onSubscribe(this.subscription);
+ return subscriber;
+ }
+
+ private BoundedTotalBodySubscriber totalSubscriber() {
+ BoundedTotalBodySubscriber subscriber = new BoundedTotalBodySubscriber<>(this.delegate, MAX_SIZE);
+ subscriber.onSubscribe(this.subscription);
+ return subscriber;
+ }
+
+ private static ByteBuffer buffer(String content) {
+ return ByteBuffer.wrap(content.getBytes(StandardCharsets.US_ASCII));
+ }
+
+ private static final class RecordingBodySubscriber implements BodySubscriber {
+
+ private final List> received = new ArrayList<>();
+
+ private final CompletableFuture body = new CompletableFuture<>();
+
+ private Throwable error;
+
+ private boolean completed;
+
+ @Override
+ public CompletionStage getBody() {
+ return this.body;
+ }
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ }
+
+ @Override
+ public void onNext(List item) {
+ this.received.add(item);
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ this.error = throwable;
+ this.body.completeExceptionally(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ this.completed = true;
+ this.body.complete(null);
+ }
+
+ }
+
+ private static final class RecordingSubscription implements Flow.Subscription {
+
+ private int cancellations;
+
+ @Override
+ public void request(long n) {
+ }
+
+ @Override
+ public void cancel() {
+ this.cancellations++;
+ }
+
+ }
+
+}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java
new file mode 100644
index 0000000..cf2e045
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+package io.modelcontextprotocol.client.transport;
+
+import java.net.URI;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
+
+/**
+ * Tests for {@link DefaultSseMessageEndpointValidator}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+class DefaultSseMessageEndpointValidatorTests {
+
+ private static final URI SSE_URI = URI.create("https://mcp.example.com/sse");
+
+ private final DefaultSseMessageEndpointValidator validator = new DefaultSseMessageEndpointValidator();
+
+ @ParameterizedTest
+ @ValueSource(strings = { "/messages", "messages?session=abc", "/", "https://mcp.example.com/messages" })
+ void valid(String endpoint) {
+ assertThatCode(() -> validator.validate(SSE_URI, endpoint)).doesNotThrowAnyException();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "", " ", "\t" })
+ @NullSource
+ void invalidEmpty(String endpoint) {
+ assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("messageEndpoint must not be empty");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "/foo/../bar", "/foo/./bar", "../bar", "./bar", "/foo/%2E%2E/bar", "/foo/%2e/bar" })
+ void invalidPathTraversal(String endpoint) {
+ assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint))
+ .hasMessageContaining("must not contain path-traversal segments")
+ .asInstanceOf(type(InvalidSseMessageEndpointException.class))
+ .extracting(InvalidSseMessageEndpointException::getMessageEndpoint)
+ .isEqualTo(endpoint);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "https://127.0.0.1/messages", "https://mcp.example.com:8443/messages",
+ "http://localhost:1234/messages", "file:///etc/passwd", "gopher://mcp.example.com/_test" })
+ void invalidAbsoluteUris(String endpoint) {
+ // Absolute URIs must be same-origin.
+ assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint))
+ .hasMessageContaining("must be a relative path or a same-origin URI")
+ .asInstanceOf(type(InvalidSseMessageEndpointException.class))
+ .extracting(InvalidSseMessageEndpointException::getMessageEndpoint)
+ .isEqualTo(endpoint);
+
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "//example/messages", "//user:secret@example/messages", "//mcp.example.com/messages" })
+ void invalidNetworkReference(String endpoint) {
+ // `//host/...` introduces an authority and is therefore not a pure path.
+ // It is missing a scheme, so it fails same-origin check.
+ assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint))
+ .hasMessageContaining("must be a relative path or a same-origin URI")
+ .asInstanceOf(type(InvalidSseMessageEndpointException.class))
+ .extracting(InvalidSseMessageEndpointException::getMessageEndpoint)
+ .isEqualTo(endpoint);
+ }
+
+}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java
new file mode 100644
index 0000000..48c997c
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Daniel Garnier-Moiroux
+ */
+class HttpServletRequestUtilsTests {
+
+ @Test
+ void readsBodyWithinLimit() throws Exception {
+ HttpServletRequest request = requestWithBody("hello world", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEqualTo("hello world");
+ }
+
+ @Test
+ void readsEmptyBody() throws Exception {
+ HttpServletRequest request = requestWithBody("", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEmpty();
+ }
+
+ @Test
+ void allowsBodyExactlyAtLimit() throws Exception {
+ HttpServletRequest request = requestWithBody("12345", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 5);
+
+ assertThat(body).isEqualTo("12345");
+ }
+
+ @Test
+ void rejectsBodyLargerThanLimit() throws Exception {
+ HttpServletRequest request = requestWithBody("123456", null);
+
+ assertThatThrownBy(() -> HttpServletRequestUtils.readBody(request, 5))
+ .isInstanceOf(MaxSizeExceededException.class);
+ }
+
+ @Test
+ void rejectsBodySpanningMultipleReadBuffers() throws Exception {
+ // larger than the internal 8192-byte read buffer, to exercise multiple loop
+ // iterations before the limit is exceeded
+ String largeBody = "a".repeat(9000);
+ HttpServletRequest request = requestWithBody(largeBody, null);
+
+ assertThatThrownBy(() -> HttpServletRequestUtils.readBody(request, 8500))
+ .isInstanceOf(MaxSizeExceededException.class);
+ }
+
+ @Test
+ void readsBodySpanningMultipleReadBuffersWithinLimit() throws Exception {
+ String largeBody = "a".repeat(9000);
+ HttpServletRequest request = requestWithBody(largeBody, null);
+
+ String body = HttpServletRequestUtils.readBody(request, 9000);
+
+ assertThat(body).isEqualTo(largeBody);
+ }
+
+ @Test
+ void defaultsToUtf8WhenCharacterEncodingIsMissing() throws Exception {
+ HttpServletRequest request = requestWithBody("héllo wörld", null);
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEqualTo("héllo wörld");
+ }
+
+ @Test
+ void honorsRequestCharacterEncoding() throws Exception {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ byte[] bytes = "café".getBytes(StandardCharsets.ISO_8859_1);
+ when(request.getInputStream()).thenReturn(servletInputStream(bytes));
+ when(request.getCharacterEncoding()).thenReturn("ISO-8859-1");
+
+ String body = HttpServletRequestUtils.readBody(request, 1024);
+
+ assertThat(body).isEqualTo("café");
+ }
+
+ private static HttpServletRequest requestWithBody(String body, String characterEncoding) throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getInputStream()).thenReturn(servletInputStream(body.getBytes(StandardCharsets.UTF_8)));
+ when(request.getCharacterEncoding()).thenReturn(characterEncoding);
+ return request;
+ }
+
+ private static ServletInputStream servletInputStream(byte[] data) {
+ ByteArrayInputStream delegate = new ByteArrayInputStream(data);
+ return new ServletInputStream() {
+
+ @Override
+ public boolean isFinished() {
+ return delegate.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(ReadListener readListener) {
+ }
+
+ @Override
+ public int read() {
+ return delegate.read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ return delegate.read(b, off, len);
+ }
+
+ };
+ }
+
+}
diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml
index d36762a..a604169 100644
--- a/mcp-json-jackson2/pom.xml
+++ b/mcp-json-jackson2/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4mcp-json-jackson2jar
@@ -72,7 +72,7 @@
io.modelcontextprotocol.sdkmcp-core
- 1.1.0-SNAPSHOT
+ 1.1.4com.networknt
diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml
index cd6ecaa..f8cd043 100644
--- a/mcp-json-jackson3/pom.xml
+++ b/mcp-json-jackson3/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4mcp-json-jackson3jar
@@ -66,7 +66,7 @@
io.modelcontextprotocol.sdkmcp-core
- 1.1.0-SNAPSHOT
+ 1.1.4tools.jackson.core
diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml
index 53fb849..6b373e2 100644
--- a/mcp-test/pom.xml
+++ b/mcp-test/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4mcp-testjar
@@ -24,7 +24,7 @@
io.modelcontextprotocol.sdkmcp-core
- 1.1.0-SNAPSHOT
+ 1.1.4
@@ -159,7 +159,7 @@
io.modelcontextprotocol.sdkmcp-json-jackson3
- 1.1.0-SNAPSHOT
+ 1.1.4test
@@ -170,7 +170,7 @@
io.modelcontextprotocol.sdkmcp-json-jackson2
- 1.1.0-SNAPSHOT
+ 1.1.4test
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
index e5d55c3..688bf5a 100644
--- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
@@ -59,6 +60,7 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
@@ -69,6 +71,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
abstract protected void prepareClients(int port, String mcpEndpoint);
+ protected static final int MAX_REQUEST_SIZE = 2048;
+
abstract protected McpServer.AsyncSpecification> prepareAsyncServerBuilder();
abstract protected McpServer.SyncSpecification> prepareSyncServerBuilder();
@@ -914,6 +918,62 @@ public abstract class AbstractMcpClientServerIntegrationTests {
}
}
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @MethodSource("clientsForTesting")
+ void testToolWithNonAsciiCharacters(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ String inputSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "username": { "type": "string" }
+ },
+ "required": ["username"]
+ }
+ """;
+
+ McpServerFeatures.SyncToolSpecification nonAsciiTool = McpServerFeatures.SyncToolSpecification.builder()
+ .tool(Tool.builder()
+ .name("greeter")
+ .description("打招呼")
+ .inputSchema(McpJsonDefaults.getMapper(), inputSchema)
+ .build())
+ .callHandler((exchange, request) -> {
+ String username = (String) request.arguments().get("username");
+ return McpSchema.CallToolResult.builder()
+ .addContent(new McpSchema.TextContent("Hello " + username))
+ .build();
+ })
+ .build();
+
+ var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(nonAsciiTool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ var tools = mcpClient.listTools().tools();
+ assertThat(tools).hasSize(1);
+ assertThat(tools.get(0).name()).isEqualTo("greeter");
+ assertThat(tools.get(0).description()).isEqualTo("打招呼");
+
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("greeter", Map.of("username", "测试用户")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+ assertThat(response.content()).hasSize(1);
+ assertThat(((McpSchema.TextContent) response.content().get(0)).text()).isEqualTo("Hello 测试用户");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
@ParameterizedTest(name = "{0} : {displayName} ")
@MethodSource("clientsForTesting")
void testToolListChangeHandlingSuccess(String clientType) {
@@ -1837,6 +1897,53 @@ public abstract class AbstractMcpClientServerIntegrationTests {
}
}
+ // Bounded read
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @MethodSource("clientsForTesting")
+ void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) throws Exception {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ String inputSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" }
+ },
+ "required": ["message"]
+ }
+ """;
+
+ McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
+ .tool(Tool.builder()
+ .name("tool1")
+ .inputSchema(McpJsonDefaults.getMapper(), inputSchema)
+ .description("tool1 description")
+ .build())
+ .callHandler((exchange, request) -> CallToolResult.builder()
+ .addContent(new TextContent(request.arguments().get("message").toString()))
+ .build())
+ .build();
+
+ var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(false).build())
+ .tools(tool1)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+ String oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1);
+
+ mcpClient.initialize();
+ assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
+
+ assertThatThrownBy(() -> mcpClient.callTool(McpSchema.CallToolRequest.builder()
+ .name("tool1")
+ .arguments(Map.of("message", oversizedBody))
+ .build())).isInstanceOf(RuntimeException.class).hasMessageContaining("413");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
index 26d6056..7fe7bd6 100644
--- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
@@ -154,6 +154,19 @@ public abstract class AbstractMcpSyncClientTests {
});
}
+ @Test
+ void testListToolsWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ Map meta = java.util.Map.of("requestId", "test-123");
+ ListToolsResult tools = mcpSyncClient.listTools(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(tools).isNotNull().satisfies(result -> {
+ assertThat(result.tools()).isNotNull().isNotEmpty();
+ });
+ });
+ }
+
@Test
void testListAllTools() {
withClient(createMcpTransport(), mcpSyncClient -> {
@@ -678,4 +691,43 @@ public abstract class AbstractMcpSyncClientTests {
});
}
+ @Test
+ void testListResourcesWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ Map meta = java.util.Map.of("requestId", "test-123");
+ ListResourcesResult resources = mcpSyncClient.listResources(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(resources).isNotNull().satisfies(result -> {
+ assertThat(result.resources()).isNotNull();
+ });
+ });
+ }
+
+ @Test
+ void testListResourceTemplatesWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ Map meta = java.util.Map.of("requestId", "test-123");
+ ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(result).isNotNull().satisfies(r -> {
+ assertThat(r.resourceTemplates()).isNotNull();
+ });
+ });
+ }
+
+ @Test
+ void testListPromptsWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ Map meta = java.util.Map.of("requestId", "test-123");
+ McpSchema.ListPromptsResult result = mcpSyncClient.listPrompts(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(result).isNotNull().satisfies(r -> {
+ assertThat(r.prompts()).isNotNull();
+ });
+ });
+ }
+
}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
index 48bf1da..a349fca 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
@@ -239,72 +239,159 @@ class McpAsyncClientTests {
}
@Test
- void testListToolsWithEmptyCursor() {
- McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build();
- McpSchema.Tool subtractTool = McpSchema.Tool.builder()
- .name("subtract")
- .description("calculate subtract")
- .build();
- McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool, subtractTool), "");
+ void testListToolsWithCursorAndMeta() {
+ var transport = new TestMcpClientTransport();
+ McpAsyncClient client = McpClient.async(transport).build();
- McpClientTransport transport = new McpClientTransport() {
- Function, Mono> handler;
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListToolsResult result = client.listTools("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.tools()).hasSize(1);
+ assertThat(transport.getCapturedRequest()).isNotNull();
+ assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1");
+ assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue");
+ }
- @Override
- public Mono connect(
- Function, Mono> handler) {
- return Mono.deferContextual(ctx -> {
- this.handler = handler;
- return Mono.empty();
- });
- }
+ @Test
+ void testListResourcesWithCursorAndMeta() {
+ var transport = new TestMcpClientTransport();
+ McpAsyncClient client = McpClient.async(transport).build();
- @Override
- public Mono closeGracefully() {
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.resources()).hasSize(1);
+ assertThat(transport.getCapturedRequest()).isNotNull();
+ assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1");
+ assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testListResourceTemplatesWithCursorAndMeta() {
+ var transport = new TestMcpClientTransport();
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.resourceTemplates()).hasSize(1);
+ assertThat(transport.getCapturedRequest()).isNotNull();
+ assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1");
+ assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testListPromptsWithCursorAndMeta() {
+ var transport = new TestMcpClientTransport();
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.prompts()).hasSize(1);
+ assertThat(transport.getCapturedRequest()).isNotNull();
+ assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1");
+ assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue");
+
+ }
+
+ static class TestMcpClientTransport implements McpClientTransport {
+
+ private Function, Mono> handler;
+
+ private McpSchema.PaginatedRequest capturedRequest = null;
+
+ @Override
+ public Mono connect(Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
return Mono.empty();
}
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder()
+ .prompts(false)
+ .resources(false, false)
+ .tools(false)
+ .build();
- @Override
- public Mono sendMessage(McpSchema.JSONRPCMessage message) {
- if (!(message instanceof McpSchema.JSONRPCRequest request)) {
- return Mono.empty();
- }
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05,
+ caps, MOCK_SERVER_INFO, null);
- McpSchema.JSONRPCResponse response;
- if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
- response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT,
- null);
- }
- else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) {
- response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult,
- null);
- }
- else {
- return Mono.empty();
- }
-
- return handler.apply(Mono.just(response)).then();
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
}
+ else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) {
+ capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
- @Override
- public T unmarshalFrom(Object data, TypeRef typeRef) {
- return JSON_MAPPER.convertValue(data, new TypeRef<>() {
- @Override
- public java.lang.reflect.Type getType() {
- return typeRef.getType();
- }
- });
+ McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of());
+ McpSchema.ListPromptsResult mockPromptResult = new McpSchema.ListPromptsResult(List.of(mockPrompt),
+ null);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockPromptResult,
+ null);
}
- };
+ else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) {
+ capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
- McpAsyncClient client = McpClient.async(transport).enableCallToolSchemaCaching(true).build();
+ McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template",
+ null, null, null);
+ McpSchema.ListResourceTemplatesResult mockResourceTemplateResult = new McpSchema.ListResourceTemplatesResult(
+ List.of(mockTemplate), null);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
+ mockResourceTemplateResult, null);
+ }
+ else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) {
+ capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
- Mono mono = client.listTools();
- McpSchema.ListToolsResult toolsResult = mono.block();
- assertThat(toolsResult).isNotNull();
+ McpSchema.Resource mockResource = McpSchema.Resource.builder()
+ .uri("file:///test.txt")
+ .name("test.txt")
+ .build();
+ McpSchema.ListResourcesResult mockResourceResult = new McpSchema.ListResourcesResult(
+ List.of(mockResource), null);
+
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResourceResult,
+ null);
+ }
+ else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) {
+ capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+
+ McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build();
+ McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult,
+ null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+
+ public McpSchema.PaginatedRequest getCapturedRequest() {
+ return capturedRequest;
+ }
- Set names = toolsResult.tools().stream().map(McpSchema.Tool::name).collect(Collectors.toSet());
- assertThat(names).containsExactlyInAnyOrder("subtract", "add");
}
}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java
new file mode 100644
index 0000000..a22dc13
--- /dev/null
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientBoundedReadTestSupport.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.Executors;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Shared fixture for the transport bounded-read tests: a bare {@link HttpServer} whose
+ * response body is written by a per-test {@link Responder}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+abstract class HttpClientBoundedReadTestSupport {
+
+ protected static final int MAX_SIZE = 1024;
+
+ private HttpServer server;
+
+ protected String host;
+
+ /**
+ * Writes a response body to the exchange.
+ */
+ @FunctionalInterface
+ protected interface Responder {
+
+ void respond(OutputStream body) throws IOException;
+
+ }
+
+ /**
+ * The path the transport under test talks to.
+ */
+ protected abstract String endpoint();
+
+ @BeforeEach
+ void startServer() throws IOException {
+ int port = TomcatTestUtil.findAvailablePort();
+ this.host = "http://localhost:" + port;
+ this.server = HttpServer.create(new InetSocketAddress(port), 0);
+ this.server.setExecutor(Executors.newCachedThreadPool());
+ this.server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (this.server != null) {
+ this.server.stop(0);
+ }
+ }
+
+ /**
+ * Registers a handler that responds with the given content type and body.
+ */
+ protected void respondWith(String path, String contentType, Responder responder) {
+ this.server.createContext(path, exchange -> {
+ exchange.getResponseHeaders().set("Content-Type", contentType);
+ exchange.sendResponseHeaders(200, 0);
+ try (OutputStream body = exchange.getResponseBody()) {
+ responder.respond(body);
+ }
+ catch (IOException ignored) {
+ // The client aborts the response once the limit is exceeded, which closes
+ // the connection and makes further writes fail. That is the behaviour
+ // under test.
+ }
+ finally {
+ exchange.close();
+ }
+ });
+ }
+
+ /**
+ * A responder that writes {@code chunks} blocks of {@code 'a'} with no line
+ * terminator anywhere, so nothing downstream can ever flush a line.
+ */
+ protected static Responder unterminatedLine(int chunks) {
+ return body -> {
+ byte[] chunk = new byte[MAX_SIZE];
+ java.util.Arrays.fill(chunk, (byte) 'a');
+ for (int i = 0; i < chunks; i++) {
+ body.write(chunk);
+ body.flush();
+ }
+ };
+ }
+
+ /**
+ * A responder that writes enough short, properly terminated lines to exceed the limit
+ * in aggregate.
+ */
+ protected static Responder manyShortLines(String prefix) {
+ return body -> {
+ byte[] line = (prefix + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n").getBytes(StandardCharsets.UTF_8);
+ for (int i = 0; i < (MAX_SIZE / line.length) + 64; i++) {
+ body.write(line);
+ body.flush();
+ }
+ };
+ }
+
+ protected static boolean messageContains(Throwable t, String expected) {
+ for (Throwable current = t; current != null; current = current.getCause()) {
+ if (current.getMessage() != null && current.getMessage().contains(expected)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java
new file mode 100644
index 0000000..df61326
--- /dev/null
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportBoundedReadTests.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * Verifies that {@link HttpClientSseClientTransport} bounds the amount of memory a single
+ * inbound message can occupy, so a malicious or buggy server cannot exhaust the client's
+ * memory by streaming an unterminated line, an endless event, or an oversized response to
+ * a posted message.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+@Timeout(15)
+class HttpClientSseClientTransportBoundedReadTests extends HttpClientBoundedReadTestSupport {
+
+ private static final String MESSAGE_ENDPOINT = "/message";
+
+ private final CountDownLatch keepStreamOpen = new CountDownLatch(1);
+
+ @Override
+ protected String endpoint() {
+ return "/sse";
+ }
+
+ @AfterEach
+ void releaseStream() {
+ this.keepStreamOpen.countDown();
+ }
+
+ @Test
+ void shouldRejectSingleLineExceedingMaxSize() {
+ // A line that never terminates, so the line buffer underneath the SSE parser
+ // would grow without limit before any event could be flushed.
+ respondWith(endpoint(), "text/event-stream", unterminatedLine(8));
+
+ StepVerifier.create(connect())
+ .verifyErrorMatches(t -> messageContains(t, "Inbound line exceeds the maximum allowed size"));
+ }
+
+ @Test
+ void shouldRejectEventExceedingMaxSize() {
+ // Many short, terminated "data:" lines with no blank line to end the event. Each
+ // line is small, but the accumulated event data would grow without limit.
+ respondWith(endpoint(), "text/event-stream", manyShortLines("data:"));
+
+ StepVerifier.create(connect())
+ .verifyErrorMatches(t -> messageContains(t, "Inbound SSE event exceeds the maximum allowed size"));
+ }
+
+ @Test
+ void shouldRejectPostResponseExceedingMaxSize() throws Exception {
+ // The response to a posted message is read into a string in full, so an oversized
+ // one must abort rather than accumulate.
+ respondWith(endpoint(), "text/event-stream", body -> {
+ body.write(("event:endpoint\ndata:" + MESSAGE_ENDPOINT + "\n\n").getBytes(StandardCharsets.UTF_8));
+ body.flush();
+ awaitTeardown();
+ });
+ respondWith(MESSAGE_ENDPOINT, "application/json", unterminatedLine(64));
+
+ HttpClientSseClientTransport transport = transport();
+ transport.connect(Function.identity()).block(Duration.ofSeconds(5));
+
+ StepVerifier.create(sendMessage(transport))
+ .verifyErrorMatches(t -> messageContains(t, "Inbound response body exceeds the maximum allowed size"));
+ }
+
+ private void awaitTeardown() {
+ try {
+ this.keepStreamOpen.await(10, TimeUnit.SECONDS);
+ }
+ catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private HttpClientSseClientTransport transport() {
+ return HttpClientSseClientTransport.builder(this.host).maxResponseSize(MAX_SIZE).build();
+ }
+
+ private Mono connect() {
+ return transport().connect(Function.identity());
+ }
+
+ private Mono sendMessage(HttpClientSseClientTransport transport) {
+ JSONRPCRequest request = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
+ Map.of("key", "value"));
+ return transport.sendMessage(request);
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java
index a24805a..c06b0aa 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java
@@ -19,7 +19,6 @@ import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequ
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
-
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
@@ -35,13 +34,13 @@ import reactor.test.StepVerifier;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.util.UriComponentsBuilder;
-
import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.matches;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -66,6 +65,8 @@ class HttpClientSseClientTransportTests {
private TestHttpClientSseClientTransport transport;
+ private SseMessageEndpointValidator sseMessageEndpointValidator = mock(SseMessageEndpointValidator.class);
+
private final McpTransportContext context = McpTransportContext.create(Map.of("some-key", "some-value"));
// Test class to access protected methods
@@ -75,10 +76,11 @@ class HttpClientSseClientTransportTests {
private Sinks.Many> events = Sinks.many().unicast().onBackpressureBuffer();
- public TestHttpClientSseClientTransport(final String baseUri) {
+ public TestHttpClientSseClientTransport(final String baseUri,
+ SseMessageEndpointValidator sseMessageEndpointValidator) {
super(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(),
HttpRequest.newBuilder().header("Content-Type", "application/json"), baseUri, "/sse", JSON_MAPPER,
- McpAsyncHttpClientRequestCustomizer.NOOP);
+ McpAsyncHttpClientRequestCustomizer.NOOP, sseMessageEndpointValidator, 16 * 1024 * 1024);
}
public int getInboundMessageCount() {
@@ -112,7 +114,7 @@ class HttpClientSseClientTransportTests {
@BeforeEach
void setUp() {
- transport = new TestHttpClientSseClientTransport(host);
+ transport = new TestHttpClientSseClientTransport(host, sseMessageEndpointValidator);
transport.connect(Function.identity()).block();
}
@@ -477,4 +479,44 @@ class HttpClientSseClientTransportTests {
customizedTransport.closeGracefully().block();
}
+ @Test
+ void testMessageEndpointValidation() throws InvalidSseMessageEndpointException {
+ var uriCaptor = ArgumentCaptor.forClass(URI.class);
+ verify(sseMessageEndpointValidator).validate(uriCaptor.capture(), matches("/message\\?sessionId=[a-z0-9-]+"));
+ assertThat(uriCaptor.getValue().toString()).matches(host + "/sse");
+ }
+
+ @Test
+ void testMessageEndpointValidationRejects() {
+ TestHttpClientSseClientTransport transport = new TestHttpClientSseClientTransport(host,
+ (sseUri, messageEndpoint) -> {
+ throw new InvalidSseMessageEndpointException("boom", messageEndpoint);
+ });
+
+ try {
+ // fails to connect
+ StepVerifier.create(transport.connect(Function.identity()))
+ .verifyErrorMatches(HttpClientSseClientTransportTests::isInvalidEndpointError);
+
+ // Since connection failed, there is no message endpoint, and no message can
+ // be sent
+ JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
+ Map.of("key", "value"));
+
+ StepVerifier.create(transport.sendMessage(testMessage))
+ .verifyErrorMatches(HttpClientSseClientTransportTests::isInvalidEndpointError);
+ }
+ finally {
+ transport.closeGracefully();
+ }
+ }
+
+ private static boolean isInvalidEndpointError(Throwable e) {
+ if (e instanceof InvalidSseMessageEndpointException ismee) {
+ return ismee.getMessageEndpoint().matches("/message\\?sessionId=[a-z0-9-]+")
+ && ismee.getMessage().equals("boom");
+ }
+ return false;
+ }
+
}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java
new file mode 100644
index 0000000..856dc88
--- /dev/null
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportBoundedReadTests.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.function.Function;
+
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * Verifies that {@link HttpClientStreamableHttpTransport} bounds the amount of memory a
+ * single inbound message can occupy, so a malicious or buggy server cannot exhaust the
+ * client's memory by streaming an unterminated line or an endless event.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+@Timeout(15)
+class HttpClientStreamableHttpTransportBoundedReadTests extends HttpClientBoundedReadTestSupport {
+
+ @Override
+ protected String endpoint() {
+ return "/mcp";
+ }
+
+ @Test
+ void shouldRejectSingleLineExceedingMaxSize() {
+ // A line that never terminates, so the line buffer underneath the SSE parser
+ // would grow without limit before any event could be flushed.
+ respondWith(endpoint(), "text/event-stream", unterminatedLine(8));
+
+ StepVerifier.create(sendMessage())
+ .verifyErrorMatches(t -> messageContains(t, "Inbound line exceeds the maximum allowed size"));
+ }
+
+ @Test
+ void shouldRejectEventExceedingMaxSize() {
+ // Many short, terminated "data:" lines with no blank line to end the event. Each
+ // line is small, but the accumulated event data would grow without limit.
+ respondWith(endpoint(), "text/event-stream", manyShortLines("data:"));
+
+ StepVerifier.create(sendMessage())
+ .verifyErrorMatches(t -> messageContains(t, "Inbound SSE event exceeds the maximum allowed size"));
+ }
+
+ @Test
+ void shouldRejectJsonResponseExceedingMaxSize() {
+ // A multi-line application/json response whose total size exceeds the limit. Each
+ // line is small, but the aggregated body would grow without limit.
+ respondWith(endpoint(), "application/json", manyShortLines(""));
+
+ StepVerifier.create(sendMessage())
+ .verifyErrorMatches(t -> messageContains(t, "Inbound response body exceeds the maximum allowed size"));
+ }
+
+ @Test
+ void shouldRejectDiscardedResponseExceedingMaxSize() {
+ // A content type the transport neither parses as SSE nor as JSON, so the body is
+ // discarded. The line subscriber underneath still buffers each line, so an
+ // unterminated one must abort the response rather than accumulate.
+ respondWith(endpoint(), "text/plain", unterminatedLine(8));
+
+ StepVerifier.create(sendMessage())
+ .verifyErrorMatches(t -> messageContains(t, "Inbound line exceeds the maximum allowed size"));
+ }
+
+ @Test
+ void shouldAcceptEventOfExactlyMaxSize() {
+ // The bound is inclusive and the SSE framing around the payload is given its own
+ // headroom, so a message of exactly maxResponseSize must still be delivered.
+ respondWith(endpoint(), "text/event-stream", body -> body
+ .write(("data:" + jsonRpcResponseOfExactly(MAX_SIZE) + "\n\n").getBytes(StandardCharsets.UTF_8)));
+
+ StepVerifier.create(sendMessage()).verifyComplete();
+ }
+
+ @Test
+ void shouldAcceptJsonResponseOfExactlyMaxSize() {
+ // Same inclusive bound on the aggregated body.
+ respondWith(endpoint(), "application/json",
+ body -> body.write(jsonRpcResponseOfExactly(MAX_SIZE).getBytes(StandardCharsets.UTF_8)));
+
+ StepVerifier.create(sendMessage()).verifyComplete();
+ }
+
+ /**
+ * A single-line JSON-RPC response padded to exactly {@code size} bytes.
+ */
+ private static String jsonRpcResponseOfExactly(int size) {
+ String prefix = "{\"jsonrpc\":\"2.0\",\"id\":\"test-id\",\"result\":{\"pad\":\"";
+ String suffix = "\"}}";
+ return prefix + "a".repeat(size - prefix.length() - suffix.length()) + suffix;
+ }
+
+ private Mono sendMessage() {
+ HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(this.host)
+ .maxResponseSize(MAX_SIZE)
+ .build();
+ JSONRPCRequest request = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
+ Map.of("key", "value"));
+ return transport.connect(Function.identity()).then(transport.sendMessage(request));
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java
new file mode 100644
index 0000000..29a18a6
--- /dev/null
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportDeleteBoundedReadTests.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import com.sun.net.httpserver.HttpServer;
+import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
+import io.modelcontextprotocol.spec.McpTransportException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Verifies that {@link HttpClientStreamableHttpTransport} bounds the response to the
+ * session-terminating DELETE, so a server cannot exhaust the client's memory on the way
+ * out. Checks both that the failure surfaces to the caller and that the client actually
+ * stops reading rather than draining whatever the server sends.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+@Timeout(30)
+class HttpClientStreamableHttpTransportDeleteBoundedReadTests {
+
+ private static final int MAX_SIZE = 1024;
+
+ private static final int FLOOD_BYTES = 32 * 1024 * 1024;
+
+ private HttpServer server;
+
+ private String host;
+
+ private final AtomicLong deleteBytesWritten = new AtomicLong();
+
+ private final CountDownLatch deleteFinished = new CountDownLatch(1);
+
+ @BeforeEach
+ void setUp() throws IOException {
+ int port = TomcatTestUtil.findAvailablePort();
+ this.host = "http://localhost:" + port;
+ this.server = HttpServer.create(new InetSocketAddress(port), 0);
+ this.server.createContext("/mcp", exchange -> {
+ if ("DELETE".equals(exchange.getRequestMethod())) {
+ floodDeleteResponse(exchange);
+ return;
+ }
+ // Hand out a session id so the transport issues a DELETE on close.
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.getResponseHeaders().set("Mcp-Session-Id", "test-session");
+ byte[] body = "{\"jsonrpc\":\"2.0\",\"id\":\"test-id\",\"result\":{}}".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(body);
+ }
+ });
+ this.server.setExecutor(Executors.newCachedThreadPool());
+ this.server.start();
+ }
+
+ private void floodDeleteResponse(com.sun.net.httpserver.HttpExchange exchange) throws IOException {
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, 0);
+ try (OutputStream body = exchange.getResponseBody()) {
+ byte[] chunk = new byte[64 * 1024];
+ java.util.Arrays.fill(chunk, (byte) 'a'); // no line terminator, ever
+ while (this.deleteBytesWritten.get() < FLOOD_BYTES) {
+ body.write(chunk);
+ body.flush();
+ this.deleteBytesWritten.addAndGet(chunk.length);
+ }
+ }
+ catch (IOException ignored) {
+ // Expected: the client aborts the response once the limit is exceeded.
+ }
+ finally {
+ exchange.close();
+ this.deleteFinished.countDown();
+ }
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (this.server != null) {
+ this.server.stop(0);
+ }
+ }
+
+ @Test
+ void shouldStopReadingDeleteResponseOnceLimitExceeded() throws Exception {
+ HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(this.host)
+ .maxResponseSize(MAX_SIZE)
+ .build();
+ transport.connect(m -> m).block(Duration.ofSeconds(5));
+ JSONRPCRequest request = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
+ Map.of("key", "value"));
+ transport.sendMessage(request).block(Duration.ofSeconds(5));
+
+ assertThatThrownBy(() -> transport.closeGracefully().block(Duration.ofSeconds(10)))
+ .isInstanceOf(McpTransportException.class)
+ .hasMessageContaining("Inbound response body exceeds the maximum allowed size");
+
+ assertThat(this.deleteFinished.await(15, TimeUnit.SECONDS))
+ .as("the server's DELETE response should be cut short by the client")
+ .isTrue();
+ assertThat(this.deleteBytesWritten.get()).as("bytes the client let the server stream on DELETE")
+ .isLessThan(FLOOD_BYTES);
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
index c4857e5..d3793ca 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
@@ -18,7 +18,6 @@ import java.util.function.Predicate;
import com.sun.net.httpserver.HttpServer;
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
import io.modelcontextprotocol.common.McpTransportContext;
-import org.reactivestreams.Publisher;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
@@ -34,6 +33,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -369,6 +369,26 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest {
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
+ @Test
+ void test405OnConnectReturnsEmptyFlux() {
+ serverSseResponseStatus.set(405);
+ AtomicReference capturedException = new AtomicReference<>();
+ var transport = HttpClientStreamableHttpTransport.builder(HOST).openConnectionOnStartup(true).build();
+ transport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(transport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isNull();
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
@Nested
class AuthorizationError {
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
index 5841c13..7a07220 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
@@ -4,6 +4,12 @@
package io.modelcontextprotocol.server;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.stream.Stream;
@@ -17,11 +23,13 @@ import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.provider.Arguments;
@@ -51,6 +59,7 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
+ .maxRequestSize(MAX_REQUEST_SIZE)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
@@ -99,6 +108,79 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT
protected void prepareClients(int port, String mcpEndpoint) {
}
+ @Test
+ void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
+ var httpClient = HttpClient.newHttpClient();
+
+ // Establish an SSE session to obtain a valid session ID
+ prepareAsyncServerBuilder().build();
+ var sseRequest = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + CUSTOM_SSE_ENDPOINT))
+ .header("Accept", "text/event-stream")
+ .GET()
+ .build();
+ var sseResponseRef = new java.util.concurrent.atomic.AtomicReference>();
+ var sessionIdFuture = new java.util.concurrent.CompletableFuture();
+ httpClient.sendAsync(sseRequest, HttpResponse.BodyHandlers.ofInputStream()).thenAccept(response -> {
+ sseResponseRef.set(response);
+ try (var reader = new java.io.BufferedReader(
+ new java.io.InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.startsWith("data:") && line.contains("sessionId=")) {
+ String data = line.substring("data:".length()).strip();
+ String sessionId = data.substring(data.indexOf("sessionId=") + "sessionId=".length());
+ sessionIdFuture.complete(sessionId);
+ return;
+ }
+ }
+ sessionIdFuture.completeExceptionally(new RuntimeException("sessionId not found in SSE stream"));
+ response.body().close();
+ }
+ catch (Exception e) {
+ sessionIdFuture.completeExceptionally(e);
+ }
+ });
+ String sessionId = sessionIdFuture.get(5, java.util.concurrent.TimeUnit.SECONDS);
+
+ // Send POST request with an over-sized body
+ byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
+ HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() {
+ @Override
+ public long contentLength() {
+ // A publisher with unknown content length forces chunked transfer
+ // encoding, bypassing the Content-Length header check and exercising the
+ // body byte count
+ return -1;
+ }
+
+ @Override
+ public void subscribe(java.util.concurrent.Flow.Subscriber super ByteBuffer> subscriber) {
+ subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onNext(ByteBuffer.wrap(oversizedBody));
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+ };
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT + "?sessionId=" + sessionId))
+ .header("Content-Type", "application/json")
+ .header("Accept", "text/event-stream, application/json")
+ .POST(chunkedPublisher)
+ .build();
+
+ var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
+
static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
.create(Map.of("important", "value"));
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
index 491c2d4..92b32df 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
@@ -4,6 +4,12 @@
package io.modelcontextprotocol.server;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@@ -30,6 +36,7 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.ProtocolVersions;
+import jakarta.servlet.http.HttpServletResponse;
import net.javacrumbs.jsonunit.core.Option;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
@@ -52,6 +59,7 @@ import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
@Timeout(15)
@@ -61,6 +69,8 @@ class HttpServletStatelessIntegrationTests {
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
+ private static final int MAX_REQUEST_SIZE = 2048;
+
private HttpServletStatelessServerTransport mcpStatelessServerTransport;
ConcurrentHashMap clientBuilders = new ConcurrentHashMap<>();
@@ -71,6 +81,7 @@ class HttpServletStatelessIntegrationTests {
public void before() {
this.mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder()
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
+ .maxRequestSize(MAX_REQUEST_SIZE)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpStatelessServerTransport);
@@ -639,6 +650,103 @@ class HttpServletStatelessIntegrationTests {
mcpServer.close();
}
+ // ---------------------------------------
+ // Bounded read
+ // ---------------------------------------
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient" })
+ void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) {
+ String inputSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" }
+ },
+ "required": ["message"]
+ }
+ """;
+
+ McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification
+ .builder()
+ .tool(Tool.builder()
+ .name("tool1")
+ .inputSchema(JSON_MAPPER, inputSchema)
+ .description("tool1 description")
+ .build())
+ .callHandler((transportContext, request) -> CallToolResult.builder()
+ .addContent(new TextContent(request.arguments().get("message").toString()))
+ .build())
+ .build();
+
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .capabilities(ServerCapabilities.builder().tools(false).build())
+ .tools(tool1)
+ .build();
+
+ try (var mcpClient = clientBuilders.get(clientType).build()) {
+ String oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1);
+
+ mcpClient.initialize();
+ assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
+
+ assertThatThrownBy(() -> mcpClient.callTool(McpSchema.CallToolRequest.builder()
+ .name("tool1")
+ .arguments(Map.of("message", oversizedBody))
+ .build())).isInstanceOf(RuntimeException.class).hasMessageContaining("413");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
+ @Test
+ void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport).build();
+
+ try {
+ var httpClient = HttpClient.newHttpClient();
+
+ // A publisher with unknown content length forces chunked transfer
+ // encoding, bypassing the Content-Length header check and exercising the
+ // body byte count
+ byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
+ HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() {
+ @Override
+ public long contentLength() {
+ return -1;
+ }
+
+ @Override
+ public void subscribe(java.util.concurrent.Flow.Subscriber super ByteBuffer> subscriber) {
+ subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onNext(ByteBuffer.wrap(oversizedBody));
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+ };
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT))
+ .header("Content-Type", "application/json")
+ .header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
+ .POST(chunkedPublisher)
+ .build();
+
+ var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
index 5b934e4..fa94bfe 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
@@ -4,6 +4,12 @@
package io.modelcontextprotocol.server;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.stream.Stream;
@@ -17,9 +23,11 @@ import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
+import org.junit.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
@@ -49,6 +57,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
.mcpEndpoint(MESSAGE_ENDPOINT)
.keepAliveInterval(Duration.ofSeconds(1))
+ .maxRequestSize(MAX_REQUEST_SIZE)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
@@ -100,4 +109,44 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg
static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
.create(Map.of("important", "value"));
+ @Test
+ public void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
+ var httpClient = HttpClient.newHttpClient();
+ // A publisher with unknown content length forces chunked transfer encoding,
+ // bypassing the Content-Length header check and exercising the body byte
+ // count
+ byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
+ HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() {
+ @Override
+ public long contentLength() {
+ return -1;
+ }
+
+ @Override
+ public void subscribe(java.util.concurrent.Flow.Subscriber super ByteBuffer> subscriber) {
+ subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() {
+ @Override
+ public void request(long n) {
+ subscriber.onNext(ByteBuffer.wrap(oversizedBody));
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+ };
+
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
+ .header("Content-Type", "application/json")
+ .header("Accept", "text/event-stream, application/json")
+ .POST(chunkedPublisher)
+ .build();
+
+ var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
+ assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ }
+
}
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 5dc8016..be7fabc 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4mcpjar
@@ -25,13 +25,13 @@
io.modelcontextprotocol.sdkmcp-json-jackson3
- 1.1.0-SNAPSHOT
+ 1.1.4io.modelcontextprotocol.sdkmcp-core
- 1.1.0-SNAPSHOT
+ 1.1.4
diff --git a/mkdocs.yml b/mkdocs.yml
index 3e27c3f..5730940 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -49,8 +49,6 @@ nav:
- Contributing Guide: contribute.md
- Documentation: development.md
- API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/latest
- - News:
- - blog/index.md
markdown_extensions:
- admonition
diff --git a/pom.xml b/pom.xml
index cdbeb25..ecd51ae 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 1.1.0-SNAPSHOT
+ 1.1.4pomhttps://github.com/modelcontextprotocol/java-sdkInternal server error - Forgejo: Beyond coding. We Forge.