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..c844367b8 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,47 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { transportSession.sessionId().get()); } + // Extract method and params for Mcp-Name header + 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(); + } + + // Extract method and params for Mcp-Name header + 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 +732,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..dfd471216 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,8 @@ public interface HttpHeaders { /** * Identifies individual MCP sessions. */ - String MCP_SESSION_ID = "Mcp-Session-Id"; + String MCP_SESSION_ID = " + "; /** * Identifies events within an SSE Stream. @@ -26,6 +27,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); + } + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index 002bf5f6d..2d3917362 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -4,14 +4,18 @@ package io.modelcontextprotocol.client.transport; +import com.sun.net.httpserver.HttpServer; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpTransportSessionClosedException; import io.modelcontextprotocol.spec.ProtocolVersions; +import java.io.IOException; +import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -166,4 +170,97 @@ void testCloseInitialized() { .verify(); } + @Test + void testMcpMethodHeaderIsAddedForInitializeRequest() 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 (!"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 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); + } + } + }