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 48462c0db..2b8c15496 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 @@ -503,14 +503,35 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { transportSession.sessionId().get()); } + // Extract method and params for MCP headers. + String method = null; + Object params = null; + if (sentMessage instanceof McpSchema.JSONRPCRequest request) { + method = request.method(); + params = request.params(); + } + else if (sentMessage instanceof McpSchema.JSONRPCNotification notification) { + method = notification.method(); + params = notification.params(); + } + var builder = requestBuilder.uri(uri) .header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8) .header(HttpHeaders.CACHE_CONTROL, "no-cache") - .header(HttpHeaders.PROTOCOL_VERSION, - ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, - this.latestSupportedProtocolVersion)) - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + .header(HttpHeaders.PROTOCOL_VERSION, ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, + this.latestSupportedProtocolVersion)); + + // Add MCP-specific headers if applicable + if (method != null) { + builder = builder.header(HttpHeaders.MCP_METHOD, method); + String name = extractNameFromParams(method, params); + if (name != null) { + builder = builder.header(HttpHeaders.MCP_NAME, name); + } + } + + 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)); @@ -699,6 +720,48 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { return this.jsonMapper.convertValue(data, typeRef); } + /** + * Extracts the name/URI from the request params based on the method type. + * @param method the MCP method name + * @param params the request parameters + * @return the name/URI if applicable for the method, or null otherwise + */ + private String extractNameFromParams(String method, Object params) { + if (params == null) { + return null; + } + + try { + switch (method) { + case McpSchema.METHOD_TOOLS_CALL -> { + McpSchema.CallToolRequest request = this.jsonMapper.convertValue(params, + new TypeRef() { + }); + return request.name(); + } + case McpSchema.METHOD_RESOURCES_READ -> { + McpSchema.ReadResourceRequest request = this.jsonMapper.convertValue(params, + new TypeRef() { + }); + return request.uri(); + } + case McpSchema.METHOD_PROMPT_GET -> { + McpSchema.GetPromptRequest request = this.jsonMapper.convertValue(params, + new TypeRef() { + }); + return request.name(); + } + default -> { + return 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/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index e6af4fd0f..3b931be43 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 @@ -125,6 +125,10 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private final ServerTransportSecurityValidator securityValidator; + private final boolean requireMcpNameHeader; + + private final boolean requireMcpMethodHeader; + /** * Constructs a new HttpServletStreamableServerTransportProvider instance. * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of @@ -140,7 +144,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) { + Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, + boolean requireMcpNameHeader, boolean requireMcpMethodHeader) { 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"); @@ -151,6 +156,8 @@ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, S this.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; this.securityValidator = securityValidator; + this.requireMcpNameHeader = requireMcpNameHeader; + this.requireMcpMethodHeader = requireMcpMethodHeader; if (keepAliveInterval != null) { @@ -383,6 +390,101 @@ public void onStartAsync(jakarta.servlet.AsyncEvent event) throws IOException { * @throws ServletException If a servlet-specific error occurs * @throws IOException If an I/O error occurs */ + private String extractNameFromParams(String method, Object params) { + if (params == null) { + return null; + } + + try { + switch (method) { + case McpSchema.METHOD_TOOLS_CALL -> { + McpSchema.CallToolRequest request = jsonMapper.convertValue(params, + new TypeRef() { + }); + return request.name(); + } + case McpSchema.METHOD_RESOURCES_READ -> { + McpSchema.ReadResourceRequest request = jsonMapper.convertValue(params, + new TypeRef() { + }); + return request.uri(); + } + case McpSchema.METHOD_PROMPT_GET -> { + McpSchema.GetPromptRequest request = jsonMapper.convertValue(params, + new TypeRef() { + }); + return request.name(); + } + default -> { + return null; + } + } + } + catch (Exception e) { + logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage()); + return null; + } + } + + private boolean validateMcpMethodHeader(HttpServletRequest request, HttpServletResponse response, String method) + throws IOException { + if (!this.requireMcpMethodHeader) { + return true; + } + + String headerMethod = request.getHeader(HttpHeaders.MCP_METHOD); + if (headerMethod == null || headerMethod.isBlank()) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Method header required for method " + method) + .build()); + return false; + } + + if (!method.equals(headerMethod)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Method header mismatch: expected '" + method + "' but was '" + headerMethod + "'") + .build()); + return false; + } + + return true; + } + + private boolean validateMcpNameHeader(HttpServletRequest request, HttpServletResponse response, String method, + Object params) throws IOException { + if (!this.requireMcpNameHeader) { + return true; + } + + String expectedName = extractNameFromParams(method, params); + String headerName = request.getHeader(HttpHeaders.MCP_NAME); + if (headerName == null || headerName.isBlank()) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Name header required for method " + method) + .build()); + return false; + } + + if (expectedName == null || !headerName.equals(expectedName)) { + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Mcp-Name header mismatch: expected '" + expectedName + "' but was '" + headerName + + "'") + .build()); + return false; + } + + return true; + } + + private boolean isNameHeaderMethod(String method) { + return McpSchema.METHOD_TOOLS_CALL.equals(method) || McpSchema.METHOD_RESOURCES_READ.equals(method) + || McpSchema.METHOD_PROMPT_GET.equals(method); + } + @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { @@ -429,6 +531,23 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString()); + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + String method = jsonrpcRequest.method(); + if (!validateMcpMethodHeader(request, response, method)) { + return; + } + if (isNameHeaderMethod(method) + && !validateMcpNameHeader(request, response, method, jsonrpcRequest.params())) { + return; + } + } + else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + String method = jsonrpcNotification.method(); + if (!validateMcpMethodHeader(request, response, method)) { + return; + } + } + // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { @@ -823,6 +942,10 @@ public static class Builder { private boolean disallowDelete = false; + private boolean requireMcpNameHeader = false; + + private boolean requireMcpMethodHeader = false; + private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; @@ -901,6 +1024,30 @@ public Builder securityValidator(ServerTransportSecurityValidator securityValida return this; } + /** + * Sets whether the server should reject requests that do not include the + * {@code Mcp-Name} header for methods that may include it. + * @param requireMcpNameHeader true to reject missing headers, false to accept + * requests from older clients by default + * @return this builder instance + */ + public Builder requireMcpNameHeader(boolean requireMcpNameHeader) { + this.requireMcpNameHeader = requireMcpNameHeader; + return this; + } + + /** + * Sets whether the server should reject requests that do not include the + * {@code Mcp-Method} header for requests and notifications. + * @param requireMcpMethodHeader true to reject missing or mismatched method + * headers, false to accept requests from older clients by default + * @return this builder instance + */ + public Builder requireMcpMethodHeader(boolean requireMcpMethodHeader) { + this.requireMcpMethodHeader = requireMcpMethodHeader; + return this; + } + /** * Builds a new instance of {@link HttpServletStreamableServerTransportProvider} * with the configured settings. @@ -911,7 +1058,8 @@ public HttpServletStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval, securityValidator); + contextExtractor, keepAliveInterval, securityValidator, requireMcpNameHeader, + requireMcpMethodHeader); } } 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..fa697bb23 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java @@ -14,7 +14,7 @@ public interface HttpHeaders { /** * Identifies individual MCP sessions. */ - String MCP_SESSION_ID = "Mcp-Session-Id"; + String MCP_SESSION_ID = "mcp-session-id"; /** * Identifies events within an SSE Stream. @@ -26,6 +26,16 @@ public interface HttpHeaders { */ String PROTOCOL_VERSION = "MCP-Protocol-Version"; + /** + * The name or URI of the resource/tool/prompt being accessed. + */ + String MCP_NAME = "Mcp-Name"; + + /** + * The MCP method name for the current request or notification. + */ + String MCP_METHOD = "Mcp-Method"; + /** * The HTTP Content-Length header. * @see { + try (exchange) { + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + + String methodHeader = exchange.getRequestHeaders().getFirst("Mcp-Method"); + byte[] requestBody = exchange.getRequestBody().readAllBytes(); + String body = new String(requestBody, StandardCharsets.UTF_8); + + if (!"initialize".equals(methodHeader) || !body.contains("\"method\":\"initialize\"")) { + exchange.sendResponseHeaders(400, 0); + return; + } + + exchange.sendResponseHeaders(202, -1); + } + }); + server.start(); + + int port = server.getAddress().getPort(); + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port) + .endpoint("/mcp") + .build(); + try { + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, + McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", + initializeRequest); + + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + } + finally { + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + } + finally { + server.stop(0); + } + } + + @Test + void sendsMcpMethodHeaderForNotifications() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + try { + server.createContext("/mcp", exchange -> { + try (exchange) { + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + + String methodHeader = exchange.getRequestHeaders().getFirst("Mcp-Method"); + byte[] requestBody = exchange.getRequestBody().readAllBytes(); + String body = new String(requestBody, StandardCharsets.UTF_8); + + if (!McpSchema.METHOD_NOTIFICATION_INITIALIZED.equals(methodHeader) + || !body.contains("\"method\":\"notifications/initialized\"")) { + exchange.sendResponseHeaders(400, 0); + return; + } + + exchange.sendResponseHeaders(202, -1); + } + }); + server.start(); + + int port = server.getAddress().getPort(); + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port) + .endpoint("/mcp") + .build(); + try { + var testMessage = new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED); + + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + } + finally { + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + } + finally { + server.stop(0); + } + } + + @Test + void testMcpNameHeaderIsAddedForToolsCall() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + try { + server.createContext("/mcp", exchange -> { + try (exchange) { + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + + String nameHeader = exchange.getRequestHeaders().getFirst("Mcp-Name"); + byte[] requestBody = exchange.getRequestBody().readAllBytes(); + String body = new String(requestBody, StandardCharsets.UTF_8); + + if (!"test-tool".equals(nameHeader) || !body.contains("\"method\":\"tools/call\"")) { + exchange.sendResponseHeaders(400, 0); + return; + } + + exchange.sendResponseHeaders(202, -1); + } + }); + server.start(); + + int port = server.getAddress().getPort(); + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port) + .endpoint("/mcp") + .build(); + try { + var callToolRequest = McpSchema.CallToolRequest.builder("test-tool").build(); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_TOOLS_CALL, "test-id", callToolRequest); + + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + } + finally { + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + } + } + finally { + server.stop(0); + } + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java index c1dcc7c19..3a3c6f15a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -6,6 +6,7 @@ import java.net.URI; import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.stream.Stream; @@ -17,6 +18,7 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpSchema; import jakarta.servlet.http.HttpServlet; import org.apache.catalina.LifecycleException; @@ -26,6 +28,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; import org.junit.jupiter.params.BeforeParameterizedClassInvocation; import org.junit.jupiter.params.Parameter; import org.junit.jupiter.params.ParameterizedClass; @@ -129,6 +133,86 @@ void hostAllowed() { assertThat(tools.tools()).isEmpty(); } + @Test + void rejectsInitializeRequestWithoutMcpMethodHeaderWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpMethodHeader(true) + .build(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.setContent( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}" + .getBytes(StandardCharsets.UTF_8)); + request.addHeader("Accept", "application/json, text/event-stream"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + provider.doPost(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("Mcp-Method header required"); + } + + @Test + void rejectsNotificationWithoutMcpMethodHeaderWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpMethodHeader(true) + .build(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.setContent( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}".getBytes(StandardCharsets.UTF_8)); + request.addHeader("Accept", "application/json, text/event-stream"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + provider.doPost(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("Mcp-Method header required"); + } + + @Test + void rejectsToolCallRequestWithoutMcpNameHeaderWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpMethodHeader(true) + .requireMcpNameHeader(true) + .build(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.setContent( + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"message\":\"hello\"}}}" + .getBytes(StandardCharsets.UTF_8)); + request.addHeader(HttpHeaders.MCP_METHOD, McpSchema.METHOD_TOOLS_CALL); + request.addHeader("Accept", "application/json, text/event-stream"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + provider.doPost(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("Mcp-Name header required"); + } + + @Test + void rejectsToolCallRequestWithMismatchedMcpNameHeaderWhenRequired() throws Exception { + var provider = HttpServletStreamableServerTransportProvider.builder() + .mcpEndpoint("/mcp") + .requireMcpMethodHeader(true) + .requireMcpNameHeader(true) + .build(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.setContent( + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"message\":\"hello\"}}}" + .getBytes(StandardCharsets.UTF_8)); + request.addHeader(HttpHeaders.MCP_METHOD, McpSchema.METHOD_TOOLS_CALL); + request.addHeader(HttpHeaders.MCP_NAME, "wrong-name"); + request.addHeader("Accept", "application/json, text/event-stream"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + provider.doPost(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("Mcp-Name header mismatch"); + } + @Test void connectHostNotAllowed() { requestCustomizer.setHostHeader(DISALLOWED_HOST);