diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java new file mode 100644 index 000000000..cb9e8637c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.util.Assert; +import reactor.util.annotation.Nullable; + +/** + * The decoded {@code Mcp-Name} header of a JSON-RPC request over HTTP, or its absence or + * malformed state. + *

+ * An HTTP transport decodes the header and adds it to the transport context under + * {@link #TRANSPORT_CONTEXT_KEY} on every JSON-RPC request. Transports without headers + * use {@link #NOT_MIRRORED} instead. The transport only decodes and forwards this value; + * features whose methods the transport does not validate (for example, {@code tasks/get}, + * {@code tasks/update}, and {@code tasks/cancel}) must compare it against their request + * parameters and treat {@link #isMalformed()} as a mismatch. + *

+ * Header names are matched ignoring case. A present header either has a decoded value or + * is malformed, never both. + * + * @author Neeraj Bhatt + * @see SEP-2243: + * HTTP Standardization + * @see SEP-2663: + * Streamable HTTP Routing Headers + */ +public final class McpNameHeader { + + /** + * The transport context key under which a transport provides the {@code Mcp-Name} + * header of a JSON-RPC request. + */ + public static final String TRANSPORT_CONTEXT_KEY = McpNameHeader.class.getName(); + + /** + * Marks a transport without headers, such as stdio. Compared by identity with + * {@link #ABSENT} to distinguish it from an HTTP request without the header. + */ + public static final McpNameHeader NOT_MIRRORED = new McpNameHeader(State.NOT_MIRRORED, null); + + /** + * Marks an HTTP request without the {@code Mcp-Name} header. Compared by identity + * with {@link #NOT_MIRRORED} to distinguish the two states. + */ + public static final McpNameHeader ABSENT = new McpNameHeader(State.ABSENT, null); + + /** + * Marks a present header whose value was missing, could not be decoded, or was sent + * more than once. + */ + public static final McpNameHeader MALFORMED = new McpNameHeader(State.MALFORMED, null); + + private final State state; + + @Nullable + private final String value; + + private McpNameHeader(State state, @Nullable String value) { + this.state = state; + this.value = value; + } + + /** + * Creates a header holding a decoded value. + * @param decodedValue the decoded {@code Mcp-Name} value, not {@code null} + * @return the present header + */ + public static McpNameHeader of(String decodedValue) { + Assert.notNull(decodedValue, "decodedValue must not be null"); + return new McpNameHeader(State.PRESENT, decodedValue); + } + + /** + * Whether the header was sent, whether its value is valid or malformed. + * @return {@code true} if the header is present + */ + public boolean isPresent() { + return this.state == State.PRESENT || this.state == State.MALFORMED; + } + + /** + * Whether the header was sent but had no valid decoded value. + * @return {@code true} if the header is malformed + */ + public boolean isMalformed() { + return this.state == State.MALFORMED; + } + + /** + * The decoded value of a present, valid header. + * @return the decoded value, or {@code null} if the header is absent, malformed, or + * the transport has no headers + */ + @Nullable + public String value() { + return this.value; + } + + @Override + public String toString() { + return "McpNameHeader[" + this.state + "]"; + } + + private enum State { + + NOT_MIRRORED, ABSENT, MALFORMED, PRESENT + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java index 2362b03b0..1657328c3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java @@ -28,6 +28,7 @@ import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpServer; @@ -240,8 +241,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + // Added after the user's extractor ran, so these entries always win. + var mcpNameHeader = McpHeaderDecoder.nameHeader(headerAccessor(request)); + transportContext = transportContext.putAll(Map.of(McpNameHeader.TRANSPORT_CONTEXT_KEY, mcpNameHeader)); + if (McpSchema.METHOD_TOOLS_CALL.equals(jsonRpcRequest.method())) { - // Added after the user's extractor ran, so this entry always wins. var mcpParamHeaders = McpHeaderDecoder.paramHeaders(headerAccessor(request)); transportContext = transportContext.putAll(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, mcpParamHeaders)); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java index 818e78d06..8939f21e0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.Set; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.server.transport.HeaderAccessor; import reactor.util.annotation.Nullable; @@ -90,6 +91,31 @@ public static String decodeValue(String raw) { return raw; } + /** + * Collects the {@code Mcp-Name} header, ignoring the header name's case. A repeated + * header or an undecodable value is marked malformed. + * @param headers the request headers + * @return the decoded header, or its absent or malformed state + */ + public static McpNameHeader nameHeader(HeaderAccessor headers) { + List values = new ArrayList<>(); + boolean found = false; + for (String name : headers.getHeaderNames()) { + if ("Mcp-Name".equalsIgnoreCase(name)) { + found = true; + values.addAll(headers.getHeader(name)); + } + } + if (!found) { + return McpNameHeader.ABSENT; + } + if (values.size() != 1) { + return McpNameHeader.MALFORMED; + } + String decoded = decodeValue(values.get(0)); + return decoded == null ? McpNameHeader.MALFORMED : McpNameHeader.of(decoded); + } + /** * Collects the {@code Mcp-Param-{Name}} headers of a request. *

diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java index e908992b0..56a30a137 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java @@ -24,6 +24,7 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpTransportResponse; @@ -51,10 +52,10 @@ public class StdioMcpTransport { private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; - // Stdio has no headers, so Mcp-Param-* headers are never mirrored and the tools - // feature skips their validation. + // Stdio has no headers, so mirrored header validation is skipped. private static final McpTransportContext TRANSPORT_CONTEXT = McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)); + .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED, + McpNameHeader.TRANSPORT_CONTEXT_KEY, McpNameHeader.NOT_MIRRORED)); private final McpRequestManager requestManager; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java index 126753d5f..5efa1c14f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.server.transport.HeaderAccessor; import org.junit.jupiter.api.Test; @@ -87,6 +88,89 @@ void plainValueWithCharactersOutsideVisibleAsciiSpaceAndTabIsMalformed(String ra assertThat(McpHeaderDecoder.decodeValue(raw)).isNull(); } + @Test + void nameHeaderIsAbsentWhenMissing() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Method", "tools/call")); + + assertThat(header).isSameAs(McpNameHeader.ABSENT); + assertThat(header.isPresent()).isFalse(); + assertThat(header.value()).isNull(); + } + + @Test + void nameHeaderWithNoValuesIsMalformed() { + HeaderAccessor emptyValue = new HeaderAccessor() { + @Override + public List getHeader(String name) { + return List.of(); + } + + @Override + public List getHeaderNames() { + return List.of("Mcp-Name"); + } + }; + + assertThat(McpHeaderDecoder.nameHeader(emptyValue)).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void nameHeaderIsReadIgnoringCase() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("mCP-NaMe", "private-name")); + + assertThat(header.isPresent()).isTrue(); + assertThat(header.isMalformed()).isFalse(); + assertThat(header.value()).isEqualTo("private-name"); + assertThat(header.toString()).doesNotContain("private-name"); + } + + @Test + void nameHeaderValueIsDecoded() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Name", "=?base64?SGVsbG8=?=")); + + assertThat(header.value()).isEqualTo("Hello"); + } + + @Test + void malformedNameHeaderValueIsMarkedMalformed() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Name", "=?base64?SGVsbG8?=")); + + assertThat(header).isSameAs(McpNameHeader.MALFORMED); + assertThat(header.isPresent()).isTrue(); + assertThat(header.value()).isNull(); + } + + @Test + void nameHeaderWithSeveralValuesIsMarkedMalformed() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Name", "one", "Mcp-Name", "two")); + + assertThat(header).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void nameHeadersDifferingOnlyInCaseAreMarkedMalformed() { + Map> values = Map.of("Mcp-Name", List.of("one"), "mcp-name", List.of("two")); + HeaderAccessor caseSensitive = new HeaderAccessor() { + @Override + public List getHeader(String name) { + return values.getOrDefault(name, List.of()); + } + + @Override + public List getHeaderNames() { + return List.copyOf(values.keySet()); + } + }; + + assertThat(McpHeaderDecoder.nameHeader(caseSensitive)).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void notMirroredNameHeaderIsDistinctFromAbsent() { + assertThat(McpNameHeader.NOT_MIRRORED).isNotSameAs(McpNameHeader.ABSENT); + assertThat(McpNameHeader.NOT_MIRRORED.isPresent()).isFalse(); + } + @Test void paramHeadersSelectsMcpParamHeadersIgnoringThePrefixCase() { McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Region", "eu", "MCP-PARAM-Tenant", diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java index 68c56aefe..cd87a74c7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -38,6 +39,7 @@ import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; @@ -94,6 +96,8 @@ class HttpServletMcpTransportIntegrationTests { private static final CountDownLatch LISTEN_CANCELLED = new CountDownLatch(1); + private static final AtomicReference LIST_NAME_HEADER = new AtomicReference<>(); + private static CallToolResult text(String text) { return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); } @@ -103,6 +107,7 @@ static void startServer() { McpAsyncToolRepository repo = new McpAsyncToolRepository() { @Override public Mono list(McpRequestContext ctx, String cursor) { + LIST_NAME_HEADER.set((McpNameHeader) ctx.transportContext().get(McpNameHeader.TRANSPORT_CONTEXT_KEY)); return Mono.just(ToolsPage.of(List.of(ECHO_TOOL))); } @@ -152,7 +157,8 @@ private Mono> call(McpRequestContext ctx, Call .errorStatus(QUOTA_EXCEEDED, 429) .contextExtractor(request -> request.getHeader(FORGE_NOT_MIRRORED) != null ? McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)) + .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED, + McpNameHeader.TRANSPORT_CONTEXT_KEY, McpNameHeader.NOT_MIRRORED)) : McpTransportContext.EMPTY) .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") @@ -414,6 +420,74 @@ void protocolVersionHeaderMismatchIsRejected() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void nameHeaderIsForwardedForAnUnnamedMethod() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("mcp-name", "task-123").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get().value()).isEqualTo("task-123"); + assertThat(LIST_NAME_HEADER.get().toString()).doesNotContain("task-123"); + } + + @Test + void missingNameHeaderIsForwardedAsAbsent() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get()).isSameAs(McpNameHeader.ABSENT); + } + + @Test + void malformedNameHeaderIsForwardedWithoutAValue() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("Mcp-Name", "=?base64?not*base64?=") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get()).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void repeatedNameHeaderIsForwardedAsMalformed() throws Exception { + LIST_NAME_HEADER.set(null); + byte[] body = JSON_MAPPER + .writeValueAsString( + Map.of("jsonrpc", "2.0", "id", 1, "method", "tools/list", "params", Map.of("_meta", meta()))) + .getBytes(StandardCharsets.UTF_8); + String headers = "POST " + ENDPOINT + " HTTP/1.0\r\n" + "Host: localhost:" + PORT + "\r\n" + + "Content-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\n" + + "Mcp-Method: tools/list\r\n" + "MCP-Protocol-Version: " + McpSchema.LATEST_PROTOCOL_VERSION + "\r\n" + + "Mcp-Name: first\r\n" + "mcp-name: second\r\n" + "Content-Length: " + body.length + "\r\n\r\n"; + String response; + try (Socket socket = new Socket("localhost", PORT)) { + socket.setSoTimeout(5000); + socket.getOutputStream().write(headers.getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().write(body); + socket.getOutputStream().flush(); + response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(response.substring(0, response.indexOf("\r\n"))).contains(" 200 "); + assertThat(LIST_NAME_HEADER.get()).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void contextExtractorCannotForgeNameHeader() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("Mcp-Name", "task-123") + .header(FORGE_NOT_MIRRORED, "true") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get().value()).isEqualTo("task-123"); + } + @Test void missingProtocolVersionHeaderIsRejected() throws Exception { HttpRequest request = HttpRequest.newBuilder() @@ -478,6 +552,16 @@ void mcpNameIsCheckedAgainstTheUriOfAResourceRead() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void mcpNameIsStillCheckedAgainstTheNameOfAToolCall() throws Exception { + HttpRequest request = post("tools/call", Map.of("_meta", meta(), "name", "echo")).header("Mcp-Name", "other") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + @Test void missingMcpNameIsRejectedForNonAsciiName() throws Exception { Map params = new HashMap<>(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java index 39ffa5ebf..595ef174c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java @@ -43,6 +43,7 @@ import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpRequestManager; @@ -534,7 +535,7 @@ void requestAfterCloseIsNotHandled() throws Exception { } @Test - void requestsAndNotificationsStateThatParamHeadersAreNotMirrored() throws Exception { + void requestsAndNotificationsStateThatHeadersAreNotMirrored() throws Exception { AtomicReference requestContext = new AtomicReference<>(); AtomicReference notificationContext = new AtomicReference<>(); start(new McpRequestManager() { @@ -567,6 +568,9 @@ public Mono handleNotification(McpTransportContext transportContext, .isSameAs(McpParamHeaders.NOT_MIRRORED); assertThat(notificationContext.get().get(McpParamHeaders.TRANSPORT_CONTEXT_KEY)) .isSameAs(McpParamHeaders.NOT_MIRRORED); + assertThat(requestContext.get().get(McpNameHeader.TRANSPORT_CONTEXT_KEY)).isSameAs(McpNameHeader.NOT_MIRRORED); + assertThat(notificationContext.get().get(McpNameHeader.TRANSPORT_CONTEXT_KEY)) + .isSameAs(McpNameHeader.NOT_MIRRORED); } @Test