diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java index 2362b03b0..f216ebccf 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java @@ -391,14 +391,27 @@ private void writeJson(HttpServletResponse response, int status, JSONRPCResponse /** * Validates {@code Mcp-Method}, {@code MCP-Protocol-Version} and, for the three named * methods, {@code Mcp-Name} against the request body. + *

+ * Each of these headers must appear on exactly one header line. Intermediaries + * disagree on which copy of a repeated header counts (the first, the last, or all of + * them joined with {@code ", "}), so accepting a repeated header would let a request + * be routed or authorized on one value while the server validates another. + *

+ * Values already joined into one line by an intermediary are only caught by the body + * comparison. A joined {@code Mcp-Name} can still match when the name or URI itself + * contains {@code ", "}. * @return a human-readable mismatch description, or {@code null} if the headers are * consistent with the body */ private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRpcRequest) { - String methodHeader = request.getHeader("Mcp-Method"); - if (methodHeader == null) { + List methodHeaders = Collections.list(request.getHeaders("Mcp-Method")); + if (methodHeaders.isEmpty()) { return "Missing required header: Mcp-Method"; } + if (methodHeaders.size() > 1) { + return "Mcp-Method header has more than one value"; + } + String methodHeader = methodHeaders.get(0); if (!methodHeader.equals(jsonRpcRequest.method())) { return "Mcp-Method header does not match request method"; } @@ -407,10 +420,14 @@ private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRp // which answers it with -32602. String bodyVersion = protocolVersionFromMeta(jsonRpcRequest.params()); if (bodyVersion != null) { - String versionHeader = request.getHeader("MCP-Protocol-Version"); - if (versionHeader == null) { + List versionHeaders = Collections.list(request.getHeaders("MCP-Protocol-Version")); + if (versionHeaders.isEmpty()) { return "Missing required header: MCP-Protocol-Version"; } + if (versionHeaders.size() > 1) { + return "MCP-Protocol-Version header has more than one value"; + } + String versionHeader = versionHeaders.get(0); if (!versionHeader.equals(bodyVersion)) { return "MCP-Protocol-Version header does not match _meta protocol version"; } @@ -427,10 +444,14 @@ private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRp if (!(paramsMap.get(nameKey) instanceof String expectedName)) { return null; } - String nameHeader = request.getHeader("Mcp-Name"); - if (nameHeader == null) { + List nameHeaders = Collections.list(request.getHeaders("Mcp-Name")); + if (nameHeaders.isEmpty()) { return "Missing required header: Mcp-Name"; } + if (nameHeaders.size() > 1) { + return "Mcp-Name header has more than one value"; + } + String nameHeader = nameHeaders.get(0); String decoded = McpHeaderDecoder.decodeValue(nameHeader); if (decoded == null) { return "Mcp-Name header has a malformed value"; diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java index 68c56aefe..9476e9f8f 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java @@ -55,6 +55,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Flux; @@ -403,6 +404,39 @@ void headerMismatchIsRejected() throws Exception { assertThat(((Number) error.get("code")).intValue()).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @ParameterizedTest + @CsvSource({ "Mcp-Method, tools/call", "Mcp-Method, tools/list", "Mcp-Name, echo", "Mcp-Name, other", + "MCP-Protocol-Version, " + McpSchema.LATEST_PROTOCOL_VERSION }) + void repeatedStandardHeaderIsRejected(String name, String extraValue) throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + // The request already carries a valid value for every standard header; this adds + // a second line for one of them. + HttpRequest request = post("tools/call", params).header("Mcp-Name", "echo").header(name, extraValue).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.HEADER_MISMATCH); + assertThatJson(response.body()).inPath("$.error.message").isEqualTo(name + " header has more than one value"); + } + + @Test + void commaJoinedMcpMethodIsRejected() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + HttpRequest request = post("tools/call", params).header("Mcp-Name", "echo") + .setHeader("Mcp-Method", "tools/call, tools/list") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.HEADER_MISMATCH); + assertThatJson(response.body()).inPath("$.error.message") + .isEqualTo("Mcp-Method header does not match request method"); + } + @Test void protocolVersionHeaderMismatchIsRejected() throws Exception { Map meta = meta();