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 d6b01e1..137a0f0 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 @@ -601,6 +601,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)); 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 0decc4e..c2ea6dd 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) { @@ -321,6 +334,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); @@ -363,15 +381,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 @@ -379,6 +392,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 { @@ -574,6 +590,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 @@ -660,6 +678,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. @@ -672,7 +703,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 af25df2..e61375b 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); @@ -152,14 +180,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements } try { - BufferedReader reader = request.getReader(); - StringBuilder body = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - body.append(line); - } + String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString()); + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { try { @@ -201,6 +224,9 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements new McpError("The server accepts either requests or notifications")); } } + 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, new McpError("Invalid message format")); @@ -265,6 +291,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 } @@ -322,6 +350,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. @@ -332,7 +373,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 c7eb4d0..059acda 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) { @@ -392,6 +403,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); @@ -415,14 +430,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 @@ -524,6 +534,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet new McpError("Unknown message type")); } } + 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, @@ -813,6 +826,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. @@ -884,6 +899,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. @@ -894,7 +922,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/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-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 270bc43..e320a16 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -70,6 +70,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(); 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..2640ce1 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; @@ -16,16 +22,23 @@ import io.modelcontextprotocol.server.McpServer.AsyncSpecification; import io.modelcontextprotocol.server.McpServer.SyncSpecification; import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.McpSchema; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; import org.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.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @Timeout(15) class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationTests { @@ -51,6 +64,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 +113,117 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT protected void prepareClients(int port, String mcpEndpoint) { } + // Bounded read + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) throws Exception { + var clientBuilder = clientBuilders.get(clientType); + + McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("tool1") + .inputSchema(EMPTY_JSON_SCHEMA) + .description("tool1 description") + .build()) + .callHandler((exchange, request) -> McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent(request.arguments().get("message").toString())) + .build()) + .build(); + + var mcpServer = prepareSyncServerBuilder() + .capabilities(McpSchema.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(); + } + } + + @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 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 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..60ad658 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; @@ -16,16 +22,23 @@ import io.modelcontextprotocol.server.McpServer.AsyncSpecification; import io.modelcontextprotocol.server.McpServer.SyncSpecification; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.McpSchema; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; +import org.junit.Test; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @Timeout(15) class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests { @@ -49,6 +62,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 +114,82 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext .create(Map.of("important", "value")); + // Bounded read + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) throws Exception { + var clientBuilder = clientBuilders.get(clientType); + + McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("tool1") + .inputSchema(EMPTY_JSON_SCHEMA) + .description("tool1 description") + .build()) + .callHandler((exchange, request) -> McpSchema.CallToolResult.builder() + .addContent(new McpSchema.TextContent(request.arguments().get("message").toString())) + .build()) + .build(); + + var mcpServer = prepareSyncServerBuilder() + .capabilities(McpSchema.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(); + } + } + + @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 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); + } + }