Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* Header names are matched ignoring case. A present header either has a decoded value or
* is malformed, never both.
*
* @author Neeraj Bhatt
* @see <a href="https://modelcontextprotocol.io/seps/2243-http-standardization">SEP-2243:
* HTTP Standardization</a>
* @see <a href=
* "https://modelcontextprotocol.io/seps/2663-tasks-extension#streamable-http-routing-headers">SEP-2663:
* Streamable HTTP Routing Headers</a>
*/
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

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> getHeader(String name) {
return List.of();
}

@Override
public List<String> 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<String, List<String>> values = Map.of("Mcp-Name", List.of("one"), "mcp-name", List.of("two"));
HeaderAccessor caseSensitive = new HeaderAccessor() {
@Override
public List<String> getHeader(String name) {
return values.getOrDefault(name, List.of());
}

@Override
public List<String> 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",
Expand Down
Loading
Loading