Bound HTTP server reads

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-06-23 12:04:17 +02:00
commit 9e4d5fa827
4 changed files with 621 additions and 38 deletions

View file

@ -48,7 +48,6 @@ import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.util.McpJsonMapperUtils;
import io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.params.ParameterizedTest;
@ -61,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;
@ -71,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();
@ -1895,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) {

View file

@ -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<HttpResponse<java.io.InputStream>>();
var sessionIdFuture = new java.util.concurrent.CompletableFuture<String>();
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<HttpServletRequest> TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
.create(Map.of("important", "value"));

View file

@ -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<String, McpClient.SyncSpec> 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) {

View file

@ -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<HttpServletRequest> 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);
}
}