diff --git a/docs/client.md b/docs/client.md index c2ec9342d..07d831c23 100644 --- a/docs/client.md +++ b/docs/client.md @@ -165,6 +165,9 @@ McpTransport transport = new StdioClientTransport(params, McpJsonDefaults.getMap - Configurable connect timeout - Custom HTTP request customization - Multiple protocol version negotiation + - SEP-2243 header mirroring: every POST carries an `Mcp-Method` header, and requests + targeting a tool, prompt, or resource also carry `Mcp-Name` (the name or URI), so + servers can validate the headers against the body without parsing it. === "Streamable WebClient (external)" diff --git a/docs/server.md b/docs/server.md index 8d74359d4..ec86c703f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -167,6 +167,10 @@ Key features: - Configurable keep-alive intervals - Security validation support - Graceful shutdown support + - SEP-2243 validation: the servlet transport rejects requests whose present + `Mcp-Method` / `Mcp-Name` headers do not mirror the request body, and rejects + unsupported `MCP-Protocol-Version` values. Missing headers are tolerated so legacy + clients keep working. === "Streamable HTTP WebFlux (external)" 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 07a8f5e23..527c56a68 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 @@ -543,11 +543,32 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { var builder = requestBuilder.uri(uri) .header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8) - .header(HttpHeaders.CACHE_CONTROL, "no-cache") - .header(HttpHeaders.PROTOCOL_VERSION, - ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, - this.latestSupportedProtocolVersion)) - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + .header(HttpHeaders.CACHE_CONTROL, "no-cache"); + // Per the Streamable HTTP transport spec, the MCP-Protocol-Version header + // is required on all requests after initialization completes. The + // initialize request itself carries no negotiated version yet -- the + // client's supported versions are conveyed in the request body for + // server-side negotiation -- so the header must not be sent. + if (!(sentMessage instanceof McpSchema.JSONRPCRequest jsonrpcMessage + && McpSchema.METHOD_INITIALIZE.equals(jsonrpcMessage.method()))) { + builder = builder.header(HttpHeaders.PROTOCOL_VERSION, ctx + .getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, this.latestSupportedProtocolVersion)); + } + // Per SEP-2243, mirror the JSON-RPC method and, where applicable, the + // target name/URI in dedicated headers so the server can validate + // them without parsing the body. + if (sentMessage instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcRequest.method()); + String name = extractNameFromParams(jsonrpcRequest.method(), jsonrpcRequest.params()); + if (name != null) { + builder = builder.header(HttpHeaders.MCP_NAME, name); + } + } + else if (sentMessage instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcNotification.method()); + } + + builder = builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody)); var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); return Mono .from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext)); @@ -740,6 +761,45 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { return this.jsonMapper.convertValue(data, typeRef); } + /** + * Extracts the name or URI of the tool, prompt, or resource referenced by a request, + * used to populate the SEP-2243 {@code Mcp-Name} header. + * @param method the JSON-RPC method of the request + * @param params the request parameters + * @return the target name or URI when the method references one, otherwise + * {@code null} + */ + private String extractNameFromParams(String method, Object params) { + if (params == null) { + return null; + } + + try { + return switch (method) { + case McpSchema.METHOD_TOOLS_CALL -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).name(); + case McpSchema.METHOD_PROMPT_GET -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).name(); + case McpSchema.METHOD_RESOURCES_READ -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + case McpSchema.METHOD_RESOURCES_SUBSCRIBE -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + default -> null; + }; + } + catch (Exception e) { + logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage()); + return null; + } + } + /** * Builder for {@link HttpClientStreamableHttpTransport}. */ 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 54f0ac030..3a553d227 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 @@ -14,10 +14,12 @@ import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpStatelessServerHandler; import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpStatelessServerTransport; @@ -186,6 +188,22 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + // The MCP-Protocol-Version header can only be strictly validated once a + // version has been negotiated; during 'initialize' the client advertises its + // versions in the request body and any header value is resolved by regular + // version negotiation instead of being rejected. + boolean initializationRequest = message instanceof McpSchema.JSONRPCRequest initRequestCheck + && McpSchema.METHOD_INITIALIZE.equals(initRequestCheck.method()); + if (!initializationRequest && !validateProtocolVersion(request, response)) { + return; + } + + // Per SEP-2243, reject header/body mismatches (missing headers are tolerated + // so legacy clients keep working). + if (!validateMcpHeaders(request, response, message)) { + return; + } + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { try { McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler @@ -266,6 +284,120 @@ private void responseError(HttpServletResponse response, int httpCode, McpError writer.flush(); } + /** + * Validates the {@code MCP-Protocol-Version} header against the protocol versions + * supported by this transport. A missing header is allowed and falls back to the + * negotiated protocol version, while a header carrying an unsupported version is + * rejected with a 400 Bad Request. Initialize requests are exempt: no version has + * been negotiated yet, so any header value carried on them is resolved through + * regular body-based version negotiation. + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @return true if the header is missing or contains a supported version, false if a + * 400 error response has been written + * @throws IOException if an I/O error occurs + */ + private boolean validateProtocolVersion(HttpServletRequest request, HttpServletResponse response) + throws IOException { + String protocolVersion = request.getHeader(HttpHeaders.PROTOCOL_VERSION); + if (protocolVersion == null || this.protocolVersions().contains(protocolVersion)) { + return true; + } + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Unsupported protocol version (supported versions: " + + String.join(", ", this.protocolVersions()) + ")") + .build()); + return false; + } + + /** + * Validates the SEP-2243 {@code Mcp-Method} and {@code Mcp-Name} request headers + * against the deserialized message body. Missing headers are permitted for backwards + * compatibility with legacy clients, but any header that is supplied must match the + * corresponding payload attribute. Mismatches are rejected with a 400 Bad Request. + * @param request the incoming servlet request + * @param response the servlet response used to write an error payload if validation + * fails + * @param message the parsed JSON-RPC message + * @return {@code true} if validation passed, {@code false} if a 400 response was + * written + * @throws IOException if writing the error response fails + */ + private boolean validateMcpHeaders(HttpServletRequest request, HttpServletResponse response, + McpSchema.JSONRPCMessage message) throws IOException { + String method = message instanceof McpSchema.JSONRPCRequest req ? req.method() + : message instanceof McpSchema.JSONRPCNotification notif ? notif.method() : null; + + if (method == null) { + return true; + } + + String methodHeader = request.getHeader(HttpHeaders.MCP_METHOD); + if (methodHeader != null && !methodHeader.isBlank() && !method.equals(methodHeader)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'") + .build()); + return false; + } + + Object params = message instanceof McpSchema.JSONRPCRequest req ? req.params() + : message instanceof McpSchema.JSONRPCNotification notif ? notif.params() : null; + String name = extractNameFromParams(method, params); + if (name != null) { + String nameHeader = request.getHeader(HttpHeaders.MCP_NAME); + if (nameHeader != null && !nameHeader.isBlank() && !name.equals(nameHeader)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + "'") + .build()); + return false; + } + } + + return true; + } + + /** + * Extracts the name or URI of the tool, prompt, or resource referenced by a request, + * as used to validate the SEP-2243 {@code Mcp-Name} header. + * @param method the JSON-RPC method of the request + * @param params the request parameters + * @return the target name or URI when the method references one, otherwise + * {@code null} + */ + private String extractNameFromParams(String method, Object params) { + if (params == null) { + return null; + } + + try { + return switch (method) { + case McpSchema.METHOD_TOOLS_CALL -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).name(); + case McpSchema.METHOD_PROMPT_GET -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).name(); + case McpSchema.METHOD_RESOURCES_READ -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + case McpSchema.METHOD_RESOURCES_SUBSCRIBE -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + default -> null; + }; + } + catch (Exception e) { + logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage()); + return null; + } + } + /** * Cleans up resources when the servlet is being destroyed. *

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 324a2ecd3..dc0a632ef 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 @@ -273,6 +273,10 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } + if (!validateProtocolVersion(request, response)) { + return; + } + try { Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); @@ -439,6 +443,23 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + // The MCP-Protocol-Version header can only be strictly validated once a + // version has been negotiated; during 'initialize' the client advertises its + // versions in the request body and any header value is resolved by the + // regular version negotiation below instead of being rejected. + boolean initializationRequest = message instanceof McpSchema.JSONRPCRequest initRequestCheck + && McpSchema.METHOD_INITIALIZE.equals(initRequestCheck.method()); + if (!initializationRequest && !validateProtocolVersion(request, response)) { + return; + } + + // Per SEP-2243, reject header/body mismatches (missing headers are tolerated + // so + // legacy clients keep working). + if (!validateMcpHeaders(request, response, message)) { + return; + } + // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { @@ -592,6 +613,10 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response return; } + if (!validateProtocolVersion(request, response)) { + return; + } + try { Map> headers = HttpServletRequestUtils.extractHeaders(request); this.securityValidator.validateHeaders(headers); @@ -653,6 +678,117 @@ public void responseError(HttpServletResponse response, int httpCode, McpError m return; } + /** + * Validates the {@code MCP-Protocol-Version} header against the protocol versions + * supported by this transport. A missing header is allowed and falls back to the + * negotiated protocol version, while a header carrying an unsupported version is + * rejected with a 400 Bad Request. Initialize requests are exempt: no version has + * been negotiated yet, so any header value carried on them is resolved through + * regular body-based version negotiation. + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @return true if the header is missing or contains a supported version, false if a + * 400 error response has been written + * @throws IOException if an I/O error occurs + */ + private boolean validateProtocolVersion(HttpServletRequest request, HttpServletResponse response) + throws IOException { + String protocolVersion = request.getHeader(HttpHeaders.PROTOCOL_VERSION); + if (protocolVersion == null || this.protocolVersions().contains(protocolVersion)) { + return true; + } + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Unsupported protocol version (supported versions: " + + String.join(", ", this.protocolVersions()) + ")") + .build()); + return false; + } + + /** + * Validates SEP-2243 {@code Mcp-Method} / {@code Mcp-Name} header-to-body mirroring. + * A present header that mismatches the request body is rejected. Absent headers are + * tolerated so that legacy clients keep working. + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param message the deserialized JSON-RPC message + * @return true if the headers are valid or absent, false if a 400 error response has + * been written + * @throws IOException if an I/O error occurs + */ + private boolean validateMcpHeaders(HttpServletRequest request, HttpServletResponse response, + McpSchema.JSONRPCMessage message) throws IOException { + String method = message instanceof McpSchema.JSONRPCRequest req ? req.method() + : message instanceof McpSchema.JSONRPCNotification notif ? notif.method() : null; + if (method == null) { + return true; + } + + String methodHeader = request.getHeader(HttpHeaders.MCP_METHOD); + if (methodHeader != null && !methodHeader.isBlank() && !method.equals(methodHeader)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'") + .build()); + return false; + } + + Object params = message instanceof McpSchema.JSONRPCRequest req ? req.params() + : message instanceof McpSchema.JSONRPCNotification notif ? notif.params() : null; + String name = extractNameFromParams(method, params); + if (name != null) { + String nameHeader = request.getHeader(HttpHeaders.MCP_NAME); + if (nameHeader != null && !nameHeader.isBlank() && !name.equals(nameHeader)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + "'") + .build()); + return false; + } + } + + return true; + } + + /** + * Extracts the name or URI of the tool, prompt, or resource referenced by a request, + * as used to validate the SEP-2243 {@code Mcp-Name} header. + * @param method the JSON-RPC method of the request + * @param params the request parameters + * @return the target name or URI when the method references one, otherwise + * {@code null} + */ + private String extractNameFromParams(String method, Object params) { + if (params == null) { + return null; + } + + try { + return switch (method) { + case McpSchema.METHOD_TOOLS_CALL -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).name(); + case McpSchema.METHOD_PROMPT_GET -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).name(); + case McpSchema.METHOD_RESOURCES_READ -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + case McpSchema.METHOD_RESOURCES_SUBSCRIBE -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE -> + this.jsonMapper.convertValue(params, new TypeRef() { + }).uri(); + default -> null; + }; + } + catch (Exception e) { + logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage()); + return null; + } + } + /** * Sends an SSE event to a client with a specific ID. * @param writer The writer to send the event through diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java index 6afc2c119..f403700e4 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java @@ -26,6 +26,26 @@ public interface HttpHeaders { */ String PROTOCOL_VERSION = "MCP-Protocol-Version"; + /** + * Mirrors the JSON-RPC method of the request or notification carried in the body. + * @see MCP + * Streamable HTTP transport + * @see SEP-2243 HTTP + * header standardisation + */ + String MCP_METHOD = "Mcp-Method"; + + /** + * Identifies the name or URI of the tool, prompt, or resource referenced by a + * request. + * @see SEP-2243 HTTP + * header standardisation + */ + String MCP_NAME = "Mcp-Name"; + /** * The HTTP Content-Length header. * @see (); + var seenNames = new java.util.concurrent.CopyOnWriteArrayList(); + var server = HttpServer.create(new InetSocketAddress(0), 0); + + try { + server.createContext("/mcp", exchange -> { + seenMethods.add(exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_METHOD)); + seenNames.add(exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_NAME)); + exchange.getRequestBody().readAllBytes(); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + server.start(); + + var transport = HttpClientStreamableHttpTransport + .builder("http://localhost:" + server.getAddress().getPort()) + .endpoint("/mcp") + .build(); + + try { + var request = new McpSchema.CallToolRequest("test-tool", Map.of(), null); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_TOOLS_CALL, "test-id", request); + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + } + finally { + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + + assertThat(seenMethods).contains(McpSchema.METHOD_TOOLS_CALL); + assertThat(seenNames).contains("test-tool"); + } + finally { + server.stop(0); + } + } + + @Test + void emitsMcpMethodForNotification() throws IOException { + var seenMethodHeaders = new java.util.concurrent.CopyOnWriteArrayList(); + var server = HttpServer.create(new InetSocketAddress(0), 0); + + try { + server.createContext("/mcp", exchange -> { + seenMethodHeaders.add(exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_METHOD)); + exchange.getRequestBody().readAllBytes(); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + server.start(); + + var transport = HttpClientStreamableHttpTransport + .builder("http://localhost:" + server.getAddress().getPort()) + .endpoint("/mcp") + .build(); + + try { + var notification = new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED); + StepVerifier.create(transport.sendMessage(notification)).verifyComplete(); + } + finally { + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + + assertThat(seenMethodHeaders).contains(McpSchema.METHOD_NOTIFICATION_INITIALIZED); + } + finally { + server.stop(0); + } + } + + @Test + void omitsMcpProtocolVersionHeaderOnInitializeRequest() throws IOException { + var seenProtocolVersions = new java.util.concurrent.CopyOnWriteArrayList(); + var server = HttpServer.create(new InetSocketAddress(0), 0); + + try { + server.createContext("/mcp", exchange -> { + seenProtocolVersions.add(exchange.getRequestHeaders().getFirst(HttpHeaders.PROTOCOL_VERSION)); + exchange.getRequestBody().readAllBytes(); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + server.start(); + + var transport = HttpClientStreamableHttpTransport + .builder("http://localhost:" + server.getAddress().getPort()) + .endpoint("/mcp") + .supportedProtocolVersions(java.util.List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18")) + .build(); + + try { + // The initialize request carries the client's latest supported version in + // its body for negotiation; sending an MCP-Protocol-Version header would + // make strict servers reject it before negotiation happens. + var initRequest = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", + Map.of("protocolVersion", "2263-03-18")); + StepVerifier.create(transport.sendMessage(initRequest)).verifyComplete(); + } + finally { + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + + assertThat(seenProtocolVersions).containsNull(); + } + finally { + server.stop(0); + } + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java index 563e52061..6506576d4 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -4,8 +4,10 @@ package io.modelcontextprotocol.common; +import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.BiFunction; import io.modelcontextprotocol.client.McpClient; @@ -22,6 +24,7 @@ import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; +import static org.awaitility.Awaitility.await; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -37,8 +40,10 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider .builder() - .contextExtractor( - req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version")))) + // The MCP-Protocol-Version header may legitimately be absent on initialize + // requests, so a missing header must not break context extraction. + .contextExtractor(req -> McpTransportContext + .create(Map.of("protocol-version", Objects.requireNonNullElse(req.getHeader("MCP-protocol-version"), "")))) .build(); private final McpSchema.Tool toolSpec = McpSchema.Tool.builder("test-tool") @@ -72,6 +77,12 @@ void usesLatestVersion() { McpSchema.CallToolResult response = client .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); + // The GET /mcp stream is opened asynchronously once the initialize response + // creates the session, so wait for it to be recorded before asserting. + await().atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(requestRecordingFilter.getCalls()).filteredOn(c -> "GET".equals(c.method())) + .hasSize(1)); + var calls = requestRecordingFilter.getCalls(); assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\"")) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java new file mode 100644 index 000000000..cce0c45a2 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import io.modelcontextprotocol.spec.HttpHeaders; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.McpJsonMapperUtils; +import jakarta.servlet.http.HttpServlet; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the SEP-2243 server-side validation added to the servlet transports: an + * unsupported {@code MCP-Protocol-Version} is rejected, and a present {@code Mcp-Method} + * / {@code Mcp-Name} header that does not mirror the request body is rejected, while + * absent headers remain tolerated and do not by themselves trigger a validation error. + */ +class Sep2243ServerHeaderValidationTests { + + private static final String ACCEPT = "application/json, text/event-stream"; + + private static byte[] toolCallBody(String toolName) throws Exception { + var request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_TOOLS_CALL, "test-id", + new McpSchema.CallToolRequest(toolName, Map.of(), null)); + return McpJsonMapperUtils.JSON_MAPPER.writeValueAsString(request).getBytes(StandardCharsets.UTF_8); + } + + private static MockHttpServletRequest req(String uri, Map headers) { + var req = new MockHttpServletRequest(); + req.setMethod("POST"); + req.setRequestURI(uri); + req.setContentType("application/json"); + req.addHeader("Accept", ACCEPT); + headers.forEach(req::addHeader); + return req; + } + + private static MockHttpServletResponse invoke(HttpServlet servlet, String uri, Map headers, + byte[] body) throws Exception { + var req = req(uri, headers); + req.setContent(body); + var resp = new MockHttpServletResponse(); + servlet.service(req, resp); + return resp; + } + + // --- Streamable servlet provider -------------------------------------------------- + + @Test + void streamableRejectsUnsupportedProtocolVersion() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build(); + + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.PROTOCOL_VERSION, "junk"), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Unsupported protocol version"); + } + + @Test + void streamableRejectsMcpMethodMismatch() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build(); + + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_METHOD, "wrong/method"), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch"); + } + + @Test + void streamableRejectsMcpNameMismatch() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build(); + + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_NAME, "wrong-name"), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch"); + } + + @Test + void streamableToleratesAbsentHeaders() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build(); + + // Absent SEP-2243 headers must not by themselves trigger a validation error; the + // request may legitimately fail later (e.g. missing session), but the rejection + // must not be one of the SEP-2243 validation errors. + var resp = invoke(provider, "/mcp", Map.of(), toolCallBody("t")); + + assertThat(resp.getContentAsString()).doesNotContain("Unsupported protocol version", "Mcp-Method header", + "Mcp-Name header"); + } + + // --- Stateless transport --------------------------------------------------------- + + @Test + void statelessRejectsUnsupportedProtocolVersionHeader() throws Exception { + var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build(); + + var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.PROTOCOL_VERSION, "junk"), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Unsupported protocol version"); + } + + @Test + void statelessRejectsMcpMethodMismatch() throws Exception { + var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build(); + + var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_METHOD, "wrong/method"), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch"); + } + + @Test + void statelessRejectsMcpNameMismatch() throws Exception { + var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build(); + + var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_NAME, "wrong-name"), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch"); + } + + @Test + void statelessToleratesAbsentHeaders() throws Exception { + var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build(); + + var resp = invoke(transport, "/mcp", Map.of(), toolCallBody("t")); + + assertThat(resp.getContentAsString()).doesNotContain("Unsupported protocol version", "Mcp-Method header", + "Mcp-Name header"); + } + + // --- Initialize requests must not be rejected on MCP-Protocol-Version ------------ + + private static byte[] initializeBody() throws Exception { + var request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", + Map.of("protocolVersion", "2263-03-18")); + return McpJsonMapperUtils.JSON_MAPPER.writeValueAsString(request).getBytes(StandardCharsets.UTF_8); + } + + @Test + void streamableToleratesUnsupportedProtocolVersionOnInitialize() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build(); + + // During initialization no protocol version has been negotiated yet, so any + // header value must be resolved through regular version negotiation instead of + // a hard 400. + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.PROTOCOL_VERSION, "junk"), initializeBody()); + + assertThat(resp.getContentAsString()).doesNotContain("Unsupported protocol version"); + } + + @Test + void statelessToleratesUnsupportedProtocolVersionOnInitialize() throws Exception { + var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build(); + + var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.PROTOCOL_VERSION, "junk"), initializeBody()); + + assertThat(resp.getContentAsString()).doesNotContain("Unsupported protocol version"); + } + +} \ No newline at end of file