From 87fb59bebefcd0bef0aa6ce30b156cfac5a17fad Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 27 Aug 2026 12:56:46 +0200 Subject: [PATCH 1/5] feat(transport): add SEP-2243 Mcp-Method / Mcp-Name header mirroring with MCP-Protocol-Version validation Implement SEP-2243 HTTP header standardization across client and server servlet transports. * Client: Emit 'Mcp-Method' on outbound Streamable HTTP requests and notifications, and 'Mcp-Name' when targeting named tools, prompts, or resources. * Server: Validate 'Mcp-Method' and 'Mcp-Name' headers against deserialized JSON-RPC payloads in HttpServletStreamableServerTransportProvider and HttpServletStatelessServerTransport. Reject mismatches with HTTP 400 while tolerating absent headers for backward compatibility. * Versioning: Validate 'MCP-Protocol-Version' against supported protocol versions on incoming servlet requests. * Tests: Add Sep2243ClientRequestHeaderTests and Sep2243ServerHeaderValidationTests verifying emission, mismatch rejections, and absent-header tolerance. --- docs/client.md | 3 + docs/server.md | 4 + .../HttpClientStreamableHttpTransport.java | 60 +++++++- .../HttpServletStatelessServerTransport.java | 124 +++++++++++++++ ...vletStreamableServerTransportProvider.java | 128 ++++++++++++++++ .../spec/HttpHeaders.java | 20 +++ .../Sep2243ClientRequestHeaderTests.java | 96 ++++++++++++ .../Sep2243ServerHeaderValidationTests.java | 141 ++++++++++++++++++ 8 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java 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 93fcf68bc..688b472b9 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 5517823b6..ba341d585 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 @@ -544,10 +544,23 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { .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.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 +753,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 9cd0d04e1..33cd6266b 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 @@ -13,10 +13,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; @@ -160,6 +162,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + if (!validateProtocolVersion(request, response)) { + return; + } + try { this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } @@ -184,6 +190,12 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + // 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 @@ -264,6 +276,118 @@ 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. + * @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 cacb30522..2e674368f 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 { this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } @@ -423,6 +427,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + if (!validateProtocolVersion(request, response)) { + return; + } + try { this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } @@ -448,6 +456,13 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + // 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)) { @@ -601,6 +616,10 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response return; } + if (!validateProtocolVersion(request, response)) { + return; + } + try { this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } @@ -661,6 +680,115 @@ 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. + * @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); + } + } + +} \ No newline at end of file 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..8eeb5487d --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java @@ -0,0 +1,141 @@ +/* + * 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"); + } + +} \ No newline at end of file From ec34539a20f4818592e2c5736883e0b5382a4c18 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 27 Aug 2026 13:47:08 +0200 Subject: [PATCH 2/5] fix(transport): exempt initialize requests from MCP-Protocol-Version checks Per the Streamable HTTP spec the MCP-Protocol-Version header is required only after initialization completes; version selection for initialize happens through body-level negotiation, not header validation. * Client: stop sending MCP-Protocol-Version on initialize requests * Servlet servers: skip strict header validation for initialize so clients advertising an unsupported version negotiate instead of getting 400 * Tests: pin client omission and server tolerance for initialize; make version-negotiation test contextExtractor null-safe for absent headers --- .../HttpClientStreamableHttpTransport.java | 14 +++++-- .../HttpServletStatelessServerTransport.java | 18 +++++--- ...vletStreamableServerTransportProvider.java | 18 +++++--- .../Sep2243ClientRequestHeaderTests.java | 42 ++++++++++++++++++- ...ttpVersionNegotiationIntegrationTests.java | 7 +++- .../Sep2243ServerHeaderValidationTests.java | 29 +++++++++++++ 6 files changed, 112 insertions(+), 16 deletions(-) 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 ba341d585..8d5312b3f 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,9 +543,17 @@ 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)); + .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. 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 33cd6266b..c9a6b43da 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 @@ -162,10 +162,6 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } - if (!validateProtocolVersion(request, response)) { - return; - } - try { this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } @@ -190,6 +186,16 @@ 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)) { @@ -280,7 +286,9 @@ private void responseError(HttpServletResponse response, int httpCode, McpError * 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. + * 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 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 2e674368f..8f709482d 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 @@ -427,10 +427,6 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } - if (!validateProtocolVersion(request, response)) { - return; - } - try { this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } @@ -456,6 +452,16 @@ 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). @@ -684,7 +690,9 @@ public void responseError(HttpServletResponse response, int httpCode, McpError m * 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. + * 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 diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java index fad9312c1..cac3165b0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java @@ -7,6 +7,7 @@ import com.sun.net.httpserver.HttpServer; import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import java.io.IOException; import java.net.InetSocketAddress; import java.util.Map; @@ -93,4 +94,43 @@ void emitsMcpMethodForNotification() throws IOException { } } -} \ No newline at end of file + @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 28432316c..d37d81c88 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.BiFunction; import io.modelcontextprotocol.client.McpClient; @@ -42,8 +43,10 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { private static 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") 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 index 8eeb5487d..cce0c45a2 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java @@ -138,4 +138,33 @@ void statelessToleratesAbsentHeaders() throws Exception { "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 From c34cc7898cea11ae314d2c4b6953c2888db065ac Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 27 Aug 2026 15:18:26 +0200 Subject: [PATCH 3/5] test(mcp-test): await async GET stream in version-negotiation assertions The GET /mcp stream is opened asynchronously once initialize creates the session, so asserting recorded calls immediately races under load (seen as Jackson 2 Integration Tests failing usesLatestVersion with Expected size: 3 but was: 2). Await the recorded GET before asserting header propagation. --- ...StreamableHttpVersionNegotiationIntegrationTests.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 d37d81c88..3ed1a7536 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.common; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Objects; @@ -32,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.awaitility.Awaitility.await; class HttpClientStreamableHttpVersionNegotiationIntegrationTests { @@ -102,6 +104,13 @@ 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\"")) From ac6969253fe56185ccc44899eb1637f36b511481 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Wed, 2 Sep 2026 22:07:30 +0200 Subject: [PATCH 4/5] feat(transport): support Base64 sentinel header encoding for Mcp-Name and return -32020 HeaderMismatch * Encode non-ASCII and sentinel-matching Mcp-Name values in Base64 sentinel format (=?base64?...?=) in HttpClientStreamableHttpTransport to prevent JDK HttpClient IllegalArgumentException on non-Latin-1 characters. * Decode Base64 sentinel Mcp-Name values on HttpServletStreamableServerTransportProvider and HttpServletStatelessServerTransport prior to body validation. * Add HEADER_MISMATCH (-32020) error code to McpSchema.ErrorCodes per SEP-2243 and return it on header/body mismatches. * Add unit and integration tests for Base64 sentinel encoding/decoding and -32020 mismatch error codes. Co-authored-by: Nikita Kibitkin --- .../HttpClientStreamableHttpTransport.java | 2 +- .../HttpServletStatelessServerTransport.java | 18 +++-- ...vletStreamableServerTransportProvider.java | 18 +++-- .../spec/HttpHeaders.java | 74 +++++++++++++++++++ .../modelcontextprotocol/spec/McpSchema.java | 6 ++ .../Sep2243ClientRequestHeaderTests.java | 35 +++++++++ .../Sep2243ServerHeaderValidationTests.java | 28 ++++++- 7 files changed, 162 insertions(+), 19 deletions(-) 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 8d5312b3f..e985dcb81 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 @@ -561,7 +561,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { 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); + builder = builder.header(HttpHeaders.MCP_NAME, HttpHeaders.encodeHeaderValue(name)); } } else if (sentMessage instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { 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 c9a6b43da..069a7fef0 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 @@ -334,7 +334,7 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon 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) + McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) .message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'") .build()); return false; @@ -345,12 +345,16 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon 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; + if (nameHeader != null && !nameHeader.isBlank()) { + String decodedName = HttpHeaders.decodeHeaderValue(nameHeader); + if (!name.equals(decodedName)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) + .message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + + "'") + .build()); + return false; + } } } 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 8f709482d..66d55d8e2 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 @@ -735,7 +735,7 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon 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) + McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) .message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'") .build()); return false; @@ -746,12 +746,16 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon 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; + if (nameHeader != null && !nameHeader.isBlank()) { + String decodedName = HttpHeaders.decodeHeaderValue(nameHeader); + if (!name.equals(decodedName)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) + .message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + + "'") + .build()); + return false; + } } } 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 f403700e4..4a8477c18 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java @@ -73,4 +73,78 @@ public interface HttpHeaders { */ String CACHE_CONTROL = "Cache-Control"; + /** + * Prefix used for Base64 sentinel-encoded header values per SEP-2243. + */ + String BASE64_SENTINEL_PREFIX = "=?base64?"; + + /** + * Suffix used for Base64 sentinel-encoded header values per SEP-2243. + */ + String BASE64_SENTINEL_SUFFIX = "?="; + + /** + * Encodes an HTTP header value per SEP-2243. If the value contains non-ASCII + * characters, control characters, leading/trailing whitespace, or matches the + * sentinel pattern, it is wrapped in Base64 sentinel encoding + * ({@code =?base64?...?=}). + * @param value the raw string value + * @return the header-safe value, encoded if necessary + */ + static String encodeHeaderValue(String value) { + if (value == null || value.isEmpty()) { + return value; + } + if (requiresBase64Encoding(value)) { + return BASE64_SENTINEL_PREFIX + + java.util.Base64.getEncoder() + .encodeToString(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + + BASE64_SENTINEL_SUFFIX; + } + return value; + } + + /** + * Decodes an HTTP header value that may be Base64 sentinel-encoded per SEP-2243. + * @param headerValue the raw header value from the HTTP request + * @return the decoded string value, or the original value if not encoded or malformed + */ + static String decodeHeaderValue(String headerValue) { + if (headerValue == null || headerValue.isEmpty()) { + return headerValue; + } + if (headerValue.startsWith(BASE64_SENTINEL_PREFIX) && headerValue.endsWith(BASE64_SENTINEL_SUFFIX)) { + String encoded = headerValue.substring(BASE64_SENTINEL_PREFIX.length(), + headerValue.length() - BASE64_SENTINEL_SUFFIX.length()); + try { + byte[] decoded = java.util.Base64.getDecoder().decode(encoded); + return new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + } + catch (IllegalArgumentException ignored) { + return headerValue; + } + } + return headerValue; + } + + private static boolean requiresBase64Encoding(String s) { + if (s.isEmpty()) { + return false; + } + if (s.charAt(0) == ' ' || s.charAt(0) == '\t' || s.charAt(s.length() - 1) == ' ' + || s.charAt(s.length() - 1) == '\t') { + return true; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < 0x20 || c > 0x7E) { + return true; + } + } + if (s.startsWith(BASE64_SENTINEL_PREFIX) && s.endsWith(BASE64_SENTINEL_SUFFIX)) { + return true; + } + return false; + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 648be8b4b..2aa571a18 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -158,6 +158,12 @@ public static final class ErrorCodes { */ public static final int URL_ELICITATION_REQUIRED = -32042; + /** + * An MCP HTTP header (Mcp-Method, Mcp-Name, or MCP-Protocol-Version) does not + * match the JSON-RPC body. + */ + public static final int HEADER_MISMATCH = -32020; + } /** diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java index cac3165b0..388319175 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java @@ -133,4 +133,39 @@ void omitsMcpProtocolVersionHeaderOnInitializeRequest() throws IOException { } } + @Test + void emitsBase64EncodedMcpNameForNonAsciiToolCall() throws IOException { + var seenNames = new java.util.concurrent.CopyOnWriteArrayList(); + var server = HttpServer.create(new InetSocketAddress(0), 0); + + try { + server.createContext("/mcp", exchange -> { + 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("計算機", 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(seenNames).contains("=?base64?6KiI566X5qmf?="); + } + finally { + server.stop(0); + } + } + } 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 index cce0c45a2..d254c9f4a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java @@ -70,7 +70,7 @@ void streamableRejectsMcpMethodMismatch() throws Exception { 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"); + assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch", "-32020"); } @Test @@ -80,7 +80,17 @@ void streamableRejectsMcpNameMismatch() throws Exception { 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"); + assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch", "-32020"); + } + + @Test + void streamableAcceptsBase64EncodedMcpName() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build(); + + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_NAME, "=?base64?6KiI566X5qmf?="), + toolCallBody("計算機")); + + assertThat(resp.getContentAsString()).doesNotContain("Mcp-Name header mismatch"); } @Test @@ -115,7 +125,7 @@ void statelessRejectsMcpMethodMismatch() throws Exception { 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"); + assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch", "-32020"); } @Test @@ -125,7 +135,17 @@ void statelessRejectsMcpNameMismatch() throws Exception { 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"); + assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch", "-32020"); + } + + @Test + void statelessAcceptsBase64EncodedMcpName() throws Exception { + var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build(); + + var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_NAME, "=?base64?6KiI566X5qmf?="), + toolCallBody("計算機")); + + assertThat(resp.getContentAsString()).doesNotContain("Mcp-Name header mismatch"); } @Test From 67e89e70b8a12975632a809d65e282c769c23b36 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 10 Sep 2026 14:51:48 +0200 Subject: [PATCH 5/5] Add opt-in requireMcpHeaders and share SEP-2243 validation across servlet transports Move the duplicated MCP-Protocol-Version / Mcp-Method / Mcp-Name validation out of both servlet transports into a package-private Sep2243RequestValidator they share. An unsupported MCP-Protocol-Version now returns INVALID_REQUEST instead of the previously incorrect METHOD_NOT_FOUND. requireMcpHeaders defaults to false, so clients that do not send the SEP-2243 headers are unaffected. --- docs/server.md | 3 +- .../HttpServletStatelessServerTransport.java | 169 ++++------------ ...vletStreamableServerTransportProvider.java | 176 +++++----------- .../transport/Sep2243RequestValidator.java | 190 ++++++++++++++++++ .../Sep2243ServerHeaderValidationTests.java | 92 ++++++++- 5 files changed, 369 insertions(+), 261 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/transport/Sep2243RequestValidator.java diff --git a/docs/server.md b/docs/server.md index 688b472b9..637634704 100644 --- a/docs/server.md +++ b/docs/server.md @@ -170,7 +170,8 @@ Key features: - 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. + clients keep working. Call `.requireMcpHeaders(true)` on the builder to also + reject requests that omit them. === "Streamable HTTP WebFlux (external)" 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 069a7fef0..1a43c8e9c 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 @@ -13,12 +13,10 @@ 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; @@ -71,6 +69,12 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements */ private final ServerHttpHeaderValidator httpHeaderValidator; + /** + * Validator for the SEP-2243 {@code MCP-Protocol-Version}, {@code Mcp-Method}, and + * {@code Mcp-Name} header checks that need the parsed JSON-RPC body. + */ + private final Sep2243RequestValidator sep2243Validator; + /** * Maximum size, in bytes, of a single request body accepted by this transport. */ @@ -86,11 +90,13 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements * @param httpHeaderValidator The HTTP header validator for validating HTTP requests. * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * positive. + * @param requireMcpHeaders Whether a POST lacking the SEP-2243 {@code Mcp-Method} / + * {@code Mcp-Name} headers is rejected instead of tolerated. * @throws IllegalArgumentException if any parameter is null */ private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, McpTransportContextExtractor contextExtractor, - ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) { + ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize, boolean requireMcpHeaders) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); Assert.notNull(contextExtractor, "contextExtractor must not be null"); @@ -102,6 +108,7 @@ private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcp this.contextExtractor = contextExtractor; this.httpHeaderValidator = httpHeaderValidator; this.requestMaxSize = requestMaxSize; + this.sep2243Validator = new Sep2243RequestValidator(jsonMapper, this::protocolVersions, requireMcpHeaders); } @Override @@ -187,18 +194,20 @@ 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)) { + // version has been negotiated; 'initialize' requests are exempt. + HttpServletHeaderAccessor headerAccessor = new HttpServletHeaderAccessor(request); + McpError protocolVersionError = this.sep2243Validator.validateProtocolVersion(headerAccessor, + Sep2243RequestValidator.isInitializeRequest(message)); + if (protocolVersionError != null) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, protocolVersionError); return; } // Per SEP-2243, reject header/body mismatches (missing headers are tolerated - // so legacy clients keep working). - if (!validateMcpHeaders(request, response, message)) { + // by default so legacy clients keep working). + McpError mirroringError = this.sep2243Validator.validateMirroringHeaders(headerAccessor, message); + if (mirroringError != null) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, mirroringError); return; } @@ -282,124 +291,6 @@ 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.HEADER_MISMATCH) - .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()) { - String decodedName = HttpHeaders.decodeHeaderValue(nameHeader); - if (!name.equals(decodedName)) { - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) - .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. *

@@ -438,6 +329,8 @@ public static class Builder { private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; + private boolean requireMcpHeaders = false; + private Builder() { // used by a static method } @@ -523,6 +416,22 @@ public Builder maxRequestSize(int requestMaxSize) { return this; } + /** + * Opt-in strict mode for SEP-2243. When enabled, a POST whose JSON-RPC request or + * notification carries no {@code Mcp-Method} header, or targets a tool, prompt or + * resource without an {@code Mcp-Name} header, is rejected with HTTP 400 and + * error code {@code HEADER_MISMATCH} (-32020). Disabled by default so that + * clients that do not send these headers keep working. A present header that + * mismatches the body is always rejected regardless of this setting. + * @param requireMcpHeaders whether to require the SEP-2243 {@code Mcp-Method} / + * {@code Mcp-Name} headers + * @return this builder instance + */ + public Builder requireMcpHeaders(boolean requireMcpHeaders) { + this.requireMcpHeaders = requireMcpHeaders; + return this; + } + /** * Builds a new instance of {@link HttpServletStatelessServerTransport} with the * configured settings. @@ -533,7 +442,7 @@ public HttpServletStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new HttpServletStatelessServerTransport( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor, - httpHeaderValidator, requestMaxSize); + httpHeaderValidator, requestMaxSize, requireMcpHeaders); } } 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 66d55d8e2..7865feae8 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 @@ -132,6 +132,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private final ServerHttpHeaderValidator httpHeaderValidator; + /** + * Validator for the SEP-2243 {@code MCP-Protocol-Version}, {@code Mcp-Method}, and + * {@code Mcp-Name} header checks that need the parsed JSON-RPC body. + */ + private final Sep2243RequestValidator sep2243Validator; + /** * Constructs a new HttpServletStreamableServerTransportProvider instance. * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of @@ -145,11 +151,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * @param httpHeaderValidator The HTTP header validator for validating HTTP requests. * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * positive. + * @param requireMcpHeaders Whether a POST lacking the SEP-2243 {@code Mcp-Method} / + * {@code Mcp-Name} headers is rejected instead of tolerated. * @throws IllegalArgumentException if any parameter is null */ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) { + Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize, + boolean requireMcpHeaders) { 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"); @@ -162,6 +171,7 @@ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, S this.contextExtractor = contextExtractor; this.httpHeaderValidator = httpHeaderValidator; this.requestMaxSize = requestMaxSize; + this.sep2243Validator = new Sep2243RequestValidator(jsonMapper, this::protocolVersions, requireMcpHeaders); if (keepAliveInterval != null) { @@ -273,7 +283,10 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } - if (!validateProtocolVersion(request, response)) { + McpError protocolVersionError = this.sep2243Validator + .validateProtocolVersion(new HttpServletHeaderAccessor(request), false); + if (protocolVersionError != null) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, protocolVersionError); return; } @@ -453,19 +466,20 @@ 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)) { + // version has been negotiated; 'initialize' requests are exempt. + HttpServletHeaderAccessor headerAccessor = new HttpServletHeaderAccessor(request); + McpError protocolVersionError = this.sep2243Validator.validateProtocolVersion(headerAccessor, + Sep2243RequestValidator.isInitializeRequest(message)); + if (protocolVersionError != null) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, protocolVersionError); return; } // Per SEP-2243, reject header/body mismatches (missing headers are tolerated - // so - // legacy clients keep working). - if (!validateMcpHeaders(request, response, message)) { + // by default so legacy clients keep working). + McpError mirroringError = this.sep2243Validator.validateMirroringHeaders(headerAccessor, message); + if (mirroringError != null) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, mirroringError); return; } @@ -622,7 +636,10 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response return; } - if (!validateProtocolVersion(request, response)) { + McpError protocolVersionError = this.sep2243Validator + .validateProtocolVersion(new HttpServletHeaderAccessor(request), false); + if (protocolVersionError != null) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, protocolVersionError); return; } @@ -686,121 +703,6 @@ 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.HEADER_MISMATCH) - .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()) { - String decodedName = HttpHeaders.decodeHeaderValue(nameHeader); - if (!name.equals(decodedName)) { - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) - .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 @@ -999,6 +901,8 @@ public static class Builder { private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; + private boolean requireMcpHeaders = false; + /** * Sets the JsonMapper to use for JSON serialization/deserialization of MCP * messages. @@ -1098,6 +1002,22 @@ public Builder maxRequestSize(int requestMaxSize) { return this; } + /** + * Opt-in strict mode for SEP-2243. When enabled, a POST whose JSON-RPC request or + * notification carries no {@code Mcp-Method} header, or targets a tool, prompt or + * resource without an {@code Mcp-Name} header, is rejected with HTTP 400 and + * error code {@code HEADER_MISMATCH} (-32020). Disabled by default so that + * clients that do not send these headers keep working. A present header that + * mismatches the body is always rejected regardless of this setting. + * @param requireMcpHeaders whether to require the SEP-2243 {@code Mcp-Method} / + * {@code Mcp-Name} headers + * @return this builder instance + */ + public Builder requireMcpHeaders(boolean requireMcpHeaders) { + this.requireMcpHeaders = requireMcpHeaders; + return this; + } + /** * Builds a new instance of {@link HttpServletStreamableServerTransportProvider} * with the configured settings. @@ -1108,7 +1028,7 @@ public HttpServletStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize); + contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize, requireMcpHeaders); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/Sep2243RequestValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/Sep2243RequestValidator.java new file mode 100644 index 000000000..9564cf1d6 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/Sep2243RequestValidator.java @@ -0,0 +1,190 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.List; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.HttpHeaders; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; + +/** + * Implements the SEP-2243 checks that need the parsed JSON-RPC body: negotiating the + * {@code MCP-Protocol-Version} header and mirroring {@code Mcp-Method} / {@code Mcp-Name} + * against the request or notification carried in the body. These checks sit beside rather + * than inside {@link ServerHttpHeaderValidator} because that validator only ever sees raw + * HTTP headers, never the deserialized message. + * + * @author Sylwester Lachiewicz + * @since 2.1.0 + */ +final class Sep2243RequestValidator { + + private static final Logger logger = LoggerFactory.getLogger(Sep2243RequestValidator.class); + + private final McpJsonMapper jsonMapper; + + private final Supplier> supportedProtocolVersions; + + private final boolean requireMcpHeaders; + + Sep2243RequestValidator(McpJsonMapper jsonMapper, Supplier> supportedProtocolVersions, + boolean requireMcpHeaders) { + Assert.notNull(jsonMapper, "jsonMapper must not be null"); + Assert.notNull(supportedProtocolVersions, "supportedProtocolVersions must not be null"); + this.jsonMapper = jsonMapper; + this.supportedProtocolVersions = supportedProtocolVersions; + this.requireMcpHeaders = requireMcpHeaders; + } + + /** + * Validates the {@code MCP-Protocol-Version} header against the supported protocol + * versions. A missing header is allowed and falls back to the negotiated protocol + * version, while a header carrying an unsupported version is rejected. 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 instead + * of being rejected here. + * @param headers the request headers + * @param initializationRequest whether the request being validated is an + * {@code initialize} request + * @return an {@link McpError} to reject the request with, or {@code null} if the + * request passes + */ + McpError validateProtocolVersion(HeaderAccessor headers, boolean initializationRequest) { + if (initializationRequest) { + return null; + } + + String protocolVersion = firstHeader(headers, HttpHeaders.PROTOCOL_VERSION); + if (protocolVersion == null || this.supportedProtocolVersions.get().contains(protocolVersion)) { + return null; + } + + return McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Unsupported protocol version (supported versions: " + + String.join(", ", this.supportedProtocolVersions.get()) + ")") + .build(); + } + + /** + * Validates SEP-2243 {@code Mcp-Method} / {@code Mcp-Name} header-to-body mirroring. + * A present header that mismatches the body is always rejected. Absent headers are + * tolerated unless {@code requireMcpHeaders} was enabled, in which case a request or + * notification must carry {@code Mcp-Method}, and one that targets a tool, prompt, or + * resource must also carry {@code Mcp-Name}. Responses carry no method and always + * pass. + * @param headers the request headers + * @param message the deserialized JSON-RPC message + * @return an {@link McpError} to reject the request with, or {@code null} if the + * request passes + */ + McpError validateMirroringHeaders(HeaderAccessor headers, McpSchema.JSONRPCMessage message) { + String method = message instanceof McpSchema.JSONRPCRequest req ? req.method() + : message instanceof McpSchema.JSONRPCNotification notif ? notif.method() : null; + if (method == null) { + return null; + } + + String methodHeader = firstHeader(headers, HttpHeaders.MCP_METHOD); + if (methodHeader == null || methodHeader.isBlank()) { + if (this.requireMcpHeaders) { + return McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) + .message("Missing required Mcp-Method header") + .build(); + } + } + else if (!method.equals(methodHeader)) { + return McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) + .message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'") + .build(); + } + + 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 = firstHeader(headers, HttpHeaders.MCP_NAME); + if (nameHeader == null || nameHeader.isBlank()) { + if (this.requireMcpHeaders) { + return McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) + .message("Missing required Mcp-Name header") + .build(); + } + } + else { + String decodedName = HttpHeaders.decodeHeaderValue(nameHeader); + if (!name.equals(decodedName)) { + return McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH) + .message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + "'") + .build(); + } + } + } + + return null; + } + + /** + * Returns whether the given message is an {@code initialize} request. + * @param message the deserialized JSON-RPC message + * @return {@code true} if the message is a JSON-RPC request for + * {@link McpSchema#METHOD_INITIALIZE} + */ + static boolean isInitializeRequest(McpSchema.JSONRPCMessage message) { + return message instanceof McpSchema.JSONRPCRequest req && McpSchema.METHOD_INITIALIZE.equals(req.method()); + } + + /** + * 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; + } + } + + private static String firstHeader(HeaderAccessor headers, String name) { + List values = headers.getHeader(name); + return values.isEmpty() ? null : values.get(0); + } + +} 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 index d254c9f4a..e05e32a87 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java @@ -60,7 +60,52 @@ void streamableRejectsUnsupportedProtocolVersion() throws Exception { 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"); + assertThat(resp.getContentAsString()).contains("Unsupported protocol version", "-32600"); + assertThat(resp.getContentAsString()).doesNotContain("-32601"); + } + + @Test + void streamableRejectsMissingMcpMethodWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpHeaders(true) + .build(); + + var resp = invoke(provider, "/mcp", Map.of(), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("-32020", "Mcp-Method"); + } + + @Test + void streamableRejectsMissingMcpNameWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpHeaders(true) + .build(); + + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_METHOD, McpSchema.METHOD_TOOLS_CALL), + toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Mcp-Name"); + } + + @Test + void streamableAcceptsCompleteHeadersWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpHeaders(true) + .build(); + + // A dummy session ID is supplied purely to get past the transport's unrelated + // "session required" check (a 404 for an unknown session) so that this test + // isolates the outcome of the SEP-2243 header validation itself. + var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_METHOD, McpSchema.METHOD_TOOLS_CALL, + HttpHeaders.MCP_NAME, "t", HttpHeaders.MCP_SESSION_ID, "test-session"), toolCallBody("t")); + + assertThat(resp.getStatus()).isNotEqualTo(400); + assertThat(resp.getContentAsString()).doesNotContain("-32020"); } @Test @@ -115,7 +160,50 @@ void statelessRejectsUnsupportedProtocolVersionHeader() throws Exception { 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"); + assertThat(resp.getContentAsString()).contains("Unsupported protocol version", "-32600"); + assertThat(resp.getContentAsString()).doesNotContain("-32601"); + } + + @Test + void statelessRejectsMissingMcpMethodWhenRequired() throws Exception { + var transport = HttpServletStatelessServerTransport.builder() + .messageEndpoint("/mcp") + .requireMcpHeaders(true) + .build(); + + var resp = invoke(transport, "/mcp", Map.of(), toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("-32020", "Mcp-Method"); + } + + @Test + void statelessRejectsMissingMcpNameWhenRequired() throws Exception { + var transport = HttpServletStatelessServerTransport.builder() + .messageEndpoint("/mcp") + .requireMcpHeaders(true) + .build(); + + var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_METHOD, McpSchema.METHOD_TOOLS_CALL), + toolCallBody("t")); + + assertThat(resp.getStatus()).isEqualTo(400); + assertThat(resp.getContentAsString()).contains("Mcp-Name"); + } + + @Test + void statelessAcceptsCompleteHeadersWhenRequired() throws Exception { + var transport = HttpServletStatelessServerTransport.builder() + .messageEndpoint("/mcp") + .requireMcpHeaders(true) + .build(); + + var resp = invoke(transport, "/mcp", + Map.of(HttpHeaders.MCP_METHOD, McpSchema.METHOD_TOOLS_CALL, HttpHeaders.MCP_NAME, "t"), + toolCallBody("t")); + + assertThat(resp.getStatus()).isNotEqualTo(400); + assertThat(resp.getContentAsString()).doesNotContain("-32020"); } @Test