Bound HTTP server reads
- Tests are not in the abstract base class because the spring transports are not vulnerable Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
34ee267a1d
commit
8c0fdd1b1f
11 changed files with 659 additions and 37 deletions
|
|
@ -601,6 +601,10 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
|||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
|
||||
}
|
||||
else if (statusCode >= 400 && statusCode < 500) {
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpTransportException("Invalid request. Status code: " + statusCode));
|
||||
}
|
||||
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new RuntimeException("Failed to send message: " + responseEvent));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HttpServletRequest> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HttpServletRequest> 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<String, List<String>> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HttpServletRequest> 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<String, List<String>> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<HttpServletRequest> 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<? 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue