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