Skip to content

Commit c04c159

Browse files
committed
Forward Mcp-Name header in McpTransportContext -> McpHeaders
- Fixes #1317 Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 5628f31 commit c04c159

13 files changed

Lines changed: 332 additions & 110 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeader.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ public static McpHeader malformed(String name) {
5050
}
5151

5252
/**
53-
* The header name, in the case it was received.
53+
* The header name, such as {@code Mcp-Name} or {@code Mcp-Param-Region}. Header names
54+
* are case-insensitive, so compare it ignoring case.
5455
* @return the header name
5556
*/
5657
public String name() {

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeaders.java‎

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,26 @@
99
import java.util.Locale;
1010
import java.util.Map;
1111

12+
import io.modelcontextprotocol.modern.McpHttp.Headers;
1213
import io.modelcontextprotocol.util.Assert;
1314
import org.jspecify.annotations.Nullable;
1415

1516
/**
16-
* The request headers a client mirrors from the JSON-RPC body: the
17-
* {@code Mcp-Param-{Name}} headers of a {@code tools/call} request, which mirror the
18-
* arguments a tool annotates with {@code x-mcp-header}.
17+
* The request headers a client mirrors from the JSON-RPC body:
18+
* <ul>
19+
* <li>{@code Mcp-Name}, which mirrors the tool or prompt name or the resource URI, and
20+
* for methods defined outside the core specification, such as {@code tasks/get}, the
21+
* parameter that method designates;</li>
22+
* <li>the {@code Mcp-Param-{Name}} headers of a {@code tools/call} request, which mirror
23+
* the arguments a tool annotates with {@code x-mcp-header}.</li>
24+
* </ul>
1925
* <p>
2026
* An HTTP transport decodes the headers, for instance with
21-
* {@code McpHeaderDecoder.paramHeaders}, and adds them to the transport context under
22-
* {@link #TRANSPORT_CONTEXT_KEY}. A transport that has no headers, such as stdio, maps
23-
* the key to {@link #NOT_MIRRORED} instead. The tools feature validates the headers
24-
* against the arguments, and fails the call as an internal error when the key is missing.
27+
* {@code McpHeaderDecoder.headers}, and adds them to the transport context of every
28+
* request under {@link #TRANSPORT_CONTEXT_KEY}. A transport that has no headers, such as
29+
* stdio, maps the key to {@link #NOT_MIRRORED} instead. The tools feature validates the
30+
* {@code Mcp-Param-{Name}} headers against the arguments, and fails the call as an
31+
* internal error when the key is missing.
2532
* <p>
2633
* Headers are looked up ignoring case.
2734
*
@@ -37,11 +44,6 @@ public final class McpHeaders {
3744
*/
3845
public static final String TRANSPORT_CONTEXT_KEY = McpHeaders.class.getName();
3946

40-
/**
41-
* The prefix of every {@code Mcp-Param-{Name}} header name.
42-
*/
43-
public static final String PARAM_PREFIX = "Mcp-Param-";
44-
4547
/**
4648
* Marks a transport that never mirrors the body into headers, such as stdio, so
4749
* header validation is skipped. Compared by identity: headers built with
@@ -82,7 +84,7 @@ public static Builder builder() {
8284

8385
private static String paramHeaderName(String name) {
8486
Assert.notNull(name, "name must not be null");
85-
return PARAM_PREFIX + name;
87+
return Headers.MCP_PARAM_PREFIX + name;
8688
}
8789

8890
private static String normalize(String headerName) {
@@ -102,17 +104,32 @@ private Builder() {
102104
}
103105

104106
/**
105-
* Adds an {@code Mcp-Param-{Name}} header. A name added more than once, ignoring
106-
* case, is malformed, because a header sent more than once has no single value to
107-
* compare.
108-
* @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
107+
* Adds a header by its full name, such as {@code Mcp-Name}. A header added more
108+
* than once, ignoring case, is malformed, because a header sent more than once
109+
* has no single value to compare. The name is not checked: callers are expected
110+
* to add only the headers a client mirrors from the body.
111+
* @param name the full name of the header, in any case
112+
* @param value the decoded value, or {@code null} if the header was sent but
113+
* couldn't be decoded
114+
* @return this builder
115+
*/
116+
public Builder header(String name, @Nullable String value) {
117+
var header = (value != null) ? McpHeader.of(name, value) : McpHeader.malformed(name);
118+
return add(header);
119+
}
120+
121+
/**
122+
* Adds an {@code Mcp-Param-{Name}} header. A paramName added more than once,
123+
* ignoring case, is malformed, because a header sent more than once has no single
124+
* value to compare.
125+
* @param paramName the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any
126+
* case
109127
* @param value the decoded value, or {@code null} if the header was sent but
110128
* couldn't be decoded
111129
* @return this builder
112130
*/
113-
public Builder param(String name, @Nullable String value) {
114-
String headerName = paramHeaderName(name);
115-
return add((value != null) ? McpHeader.of(headerName, value) : McpHeader.malformed(headerName));
131+
public Builder paramHeader(String paramName, @Nullable String value) {
132+
return header(paramHeaderName(paramName), value);
116133
}
117134

118135
// A header added again, ignoring case, becomes malformed and keeps the name it

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
* {@link McpTransportResponse}. Use {@link #handleBlocking} when the calling thread may
1515
* block, {@link #handle} when it must not. Neither completes with an error.
1616
* <p>
17-
* For {@code tools/call}, the transport context must map
18-
* {@link McpHeaders#TRANSPORT_CONTEXT_KEY} to the request's {@code Mcp-Param-{Name}}
19-
* headers, or to {@link McpHeaders#NOT_MIRRORED} for a transport without headers.
17+
* For every request, the transport context must map
18+
* {@link McpHeaders#TRANSPORT_CONTEXT_KEY} to the request's mirrored headers
19+
* ({@code Mcp-Name} and {@code Mcp-Param-{Name}}), or to {@link McpHeaders#NOT_MIRRORED}
20+
* for a transport without headers. Features read them to check the headers against the
21+
* body, for instance the tools feature for {@code tools/call}.
2022
*
2123
* @author Dariusz Jędrzejczyk
2224
*/

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.Map;
1111
import java.util.regex.Pattern;
1212

13+
import io.modelcontextprotocol.modern.McpHttp.Headers;
1314
import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Annotation;
1415
import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Type;
1516
import io.modelcontextprotocol.modern.server.McpHeader;
@@ -126,7 +127,7 @@ private static BigDecimal decimal(Number number) {
126127
}
127128

128129
private static String header(Annotation annotation) {
129-
return McpHeaders.PARAM_PREFIX + annotation.name();
130+
return Headers.MCP_PARAM_PREFIX + annotation.name();
130131
}
131132

132133
private static String describe(McpHeaders headers, String name) {

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,15 @@ private static void validateParamHeaders(McpRequestContext ctx, Tool tool, Map<S
153153
}
154154
// Failing rather than skipping, so a transport that forgets the headers can't
155155
// silently disable validation.
156-
if (!(headers instanceof McpHeaders paramHeaders)) {
156+
if (!(headers instanceof McpHeaders mcpHeaders)) {
157157
logger.warn(
158158
"Cannot validate Mcp-Param headers for tool '{}': transport context key '{}' is {}. The transport "
159159
+ "must map it to an McpHeaders, or to McpHeaders.NOT_MIRRORED if it has no headers",
160160
tool.name(), McpHeaders.TRANSPORT_CONTEXT_KEY,
161161
(headers == null) ? "missing" : "a " + headers.getClass().getName());
162162
throw new McpException(ErrorCodes.INTERNAL_ERROR, "Internal error");
163163
}
164-
ParamHeaderValidator.Result result = ParamHeaderValidator.validate(annotations.valid(), arguments,
165-
paramHeaders);
164+
ParamHeaderValidator.Result result = ParamHeaderValidator.validate(annotations.valid(), arguments, mcpHeaders);
166165
if (result instanceof ParamHeaderValidator.Invalid invalid) {
167166
throw new McpException(ErrorCodes.HEADER_MISMATCH, invalid.problem());
168167
}

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
248248
return;
249249
}
250250

251-
if (McpSchema.METHOD_TOOLS_CALL.equals(jsonRpcRequest.method())) {
252-
// Added after the user's extractor ran, so this entry always wins.
253-
var mcpParamHeaders = McpHeaderDecoder.paramHeaders(headerAccessor(request));
254-
transportContext = transportContext.putAll(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, mcpParamHeaders));
255-
}
251+
// Extract headers on every request, because extensions may mirror a parameter
252+
// into Mcp-Name. Added after the user's extractor ran, so this entry always wins.
253+
var mcpHeaders = McpHeaderDecoder.headers(headerAccessor(request));
254+
transportContext = transportContext.putAll(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, mcpHeaders));
256255

257256
boolean listen = McpSchema.METHOD_SUBSCRIPTIONS_LISTEN.equals(jsonRpcRequest.method());
258257
McpTransportResponse transportResponse = (listen ? this.requestManager.handle(transportContext, jsonRpcRequest)

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,37 +88,47 @@ private McpHeaderDecoder() {
8888
}
8989

9090
/**
91-
* Collects the {@code Mcp-Param-{Name}} headers of a request.
91+
* Collects the {@code Mcp-Name} and {@code Mcp-Param-{Name}} headers of a request.
9292
* <p>
93-
* Headers are selected by their {@code Mcp-Param-} prefix, ignoring case. A name that
94-
* carries more than one value, including names that differ only in case, is
93+
* {@code Mcp-Name} is selected by its exact name and {@code Mcp-Param-{Name}} by its
94+
* prefix, both ignoring case, so {@code Mcp-Param-Mcp-Name} is a param header. A name
95+
* that carries more than one value, including names that differ only in case, is
9596
* malformed. Every other value is decoded with {@link #decodeValue(String)}, and
9697
* marked malformed when it can't be decoded. Rejecting a malformed header is left to
97-
* the tools feature, because only a header that the called tool declares may cause a
98+
* whoever compares it with the body: the transport for the methods it validates, and
99+
* the feature handling the method otherwise. For instance, only a
100+
* {@code Mcp-Param-{Name}} header that the called tool declares may cause a
98101
* rejection.
99102
* <p>
100103
* As with {@link #decodeValue(String)}, the accessor must return values with their
101104
* leading and trailing whitespace already removed.
102105
* @param headers the request headers
103106
* @return the decoded headers
104107
*/
105-
public static McpHeaders paramHeaders(HeaderAccessor headers) {
108+
public static McpHeaders headers(HeaderAccessor headers) {
106109
// The accessor may be case-sensitive, so values are gathered under every name
107110
// that differs only in case.
108111
Map<String, Set<String>> names = new LinkedHashMap<>();
109112
for (String name : headers.getHeaderNames()) {
110-
if (name.regionMatches(true, 0, McpHeaders.PARAM_PREFIX, 0, McpHeaders.PARAM_PREFIX.length())) {
113+
if (name.equalsIgnoreCase(Headers.MCP_NAME)
114+
|| name.regionMatches(true, 0, Headers.MCP_PARAM_PREFIX, 0, Headers.MCP_PARAM_PREFIX.length())) {
111115
names.computeIfAbsent(name.toLowerCase(Locale.ROOT), k -> new LinkedHashSet<>()).add(name);
112116
}
113117
}
114118
McpHeaders.Builder builder = McpHeaders.builder();
115119
for (Set<String> variants : names.values()) {
116-
String paramName = variants.iterator().next().substring(McpHeaders.PARAM_PREFIX.length());
117120
List<String> values = new ArrayList<>();
118121
for (String name : variants) {
119122
values.addAll(headers.getHeader(name));
120123
}
121-
builder.param(paramName, values.size() == 1 ? decodeValue(values.get(0)) : null);
124+
String decoded = values.size() == 1 ? decodeValue(values.get(0)) : null;
125+
String headerName = variants.iterator().next();
126+
if (headerName.equalsIgnoreCase(Headers.MCP_NAME)) {
127+
builder.header(Headers.MCP_NAME, decoded);
128+
}
129+
else {
130+
builder.paramHeader(headerName.substring(Headers.MCP_PARAM_PREFIX.length()), decoded);
131+
}
122132
}
123133
return builder.build();
124134
}

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ public class StdioMcpTransport {
5050

5151
private static final Logger logger = LoggerFactory.getLogger(StdioMcpTransport.class);
5252

53-
// Stdio has no headers, so Mcp-Param-* headers are never mirrored and the tools
54-
// feature skips their validation.
53+
// Stdio has no headers, so nothing is mirrored (neither Mcp-Name nor Mcp-Param-*)
54+
// and features skip header validation.
5555
private static final McpTransportContext TRANSPORT_CONTEXT = McpTransportContext
5656
.create(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, McpHeaders.NOT_MIRRORED));
5757

‎mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpHeadersTests.java‎

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package io.modelcontextprotocol.modern.server;
66

7+
import io.modelcontextprotocol.modern.McpHttp;
78
import org.junit.jupiter.api.Test;
89

910
import static org.assertj.core.api.Assertions.assertThat;
@@ -12,28 +13,28 @@ class McpHeadersTests {
1213

1314
@Test
1415
void paramAddedTwiceIgnoringCaseIsMalformed() {
15-
McpHeaders headers = McpHeaders.builder().param("Region", "eu").param("region", "eu").build();
16+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").paramHeader("region", "eu").build();
1617

1718
assertThat(headers.param("region")).isEqualTo(McpHeader.malformed("Mcp-Param-Region"));
1819
}
1920

2021
@Test
2122
void paramAddedAfterNullStaysMalformed() {
22-
McpHeaders headers = McpHeaders.builder().param("Region", null).param("region", "eu").build();
23+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", null).paramHeader("region", "eu").build();
2324

2425
assertThat(headers.param("region").isMalformed()).isTrue();
2526
}
2627

2728
@Test
2829
void nullParamAddedAfterValueMakesItMalformed() {
29-
McpHeaders headers = McpHeaders.builder().param("Region", "eu").param("region", null).build();
30+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").paramHeader("region", null).build();
3031

3132
assertThat(headers.param("region").isMalformed()).isTrue();
3233
}
3334

3435
@Test
3536
void presentParamHasItsValue() {
36-
McpHeaders headers = McpHeaders.builder().param("Region", "eu").build();
37+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build();
3738

3839
McpHeader header = headers.param("region");
3940
assertThat(header.name()).isEqualTo("Mcp-Param-Region");
@@ -43,21 +44,21 @@ void presentParamHasItsValue() {
4344

4445
@Test
4546
void paramIsLookedUpIgnoringCase() {
46-
McpHeaders headers = McpHeaders.builder().param("Region", "eu").build();
47+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build();
4748

4849
assertThat(headers.param("REGION")).isEqualTo(headers.param("region"));
4950
}
5051

5152
@Test
5253
void absentParamIsNull() {
53-
McpHeaders headers = McpHeaders.builder().param("Region", "eu").build();
54+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build();
5455

5556
assertThat(headers.param("tenant")).isNull();
5657
}
5758

5859
@Test
5960
void paramIsLookedUpByFullHeaderNameIgnoringCase() {
60-
McpHeaders headers = McpHeaders.builder().param("Region", "eu").build();
61+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build();
6162

6263
assertThat(headers.get("mcp-param-REGION")).isEqualTo(McpHeader.of("Mcp-Param-Region", "eu"));
6364
assertThat(headers.get("Region")).isNull();
@@ -67,17 +68,56 @@ void paramIsLookedUpByFullHeaderNameIgnoringCase() {
6768
void paramNamedLikeAnotherMcpHeaderKeepsItsPrefix() {
6869
// "Mcp-Name" is a valid x-mcp-header name, so a tool can make the client send
6970
// Mcp-Param-Mcp-Name, which is not the Mcp-Name header.
70-
McpHeaders headers = McpHeaders.builder().param("Mcp-Name", "echo").build();
71+
McpHeaders headers = McpHeaders.builder().paramHeader("Mcp-Name", "echo").build();
7172

7273
assertThat(headers.get("Mcp-Param-Mcp-Name")).isEqualTo(McpHeader.of("Mcp-Param-Mcp-Name", "echo"));
7374
assertThat(headers.get("Mcp-Name")).isNull();
75+
assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isNull();
7476
}
7577

7678
@Test
77-
void toStringOmitsValues() {
78-
McpHeaders headers = McpHeaders.builder().param("Region", "eu-west1").param("Tenant", null).build();
79+
void nameIsLookedUpAsMcpName() {
80+
McpHeaders headers = McpHeaders.builder().header(McpHttp.Headers.MCP_NAME, "echo").build();
81+
82+
assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.of("Mcp-Name", "echo"));
83+
assertThat(headers.get("mcp-name")).isEqualTo(headers.get(McpHttp.Headers.MCP_NAME));
84+
assertThat(headers.param("Name")).isNull();
85+
}
86+
87+
@Test
88+
void nameAddedTwiceIsMalformed() {
89+
McpHeaders headers = McpHeaders.builder()
90+
.header(McpHttp.Headers.MCP_NAME, "echo")
91+
.header(McpHttp.Headers.MCP_NAME, "echo")
92+
.build();
93+
94+
assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.malformed("Mcp-Name"));
95+
}
96+
97+
@Test
98+
void nullNameIsMalformed() {
99+
McpHeaders headers = McpHeaders.builder().header(McpHttp.Headers.MCP_NAME, null).build();
79100

80-
assertThat(headers.toString()).contains("Mcp-Param-Region", "Mcp-Param-Tenant").doesNotContain("eu-west1");
101+
assertThat(headers.get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue();
102+
}
103+
104+
@Test
105+
void absentNameIsNull() {
106+
McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build();
107+
108+
assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isNull();
109+
}
110+
111+
@Test
112+
void toStringOmitsValues() {
113+
McpHeaders headers = McpHeaders.builder()
114+
.header(McpHttp.Headers.MCP_NAME, "secret-task")
115+
.paramHeader("Region", "eu-west1")
116+
.paramHeader("Tenant", null)
117+
.build();
118+
119+
assertThat(headers.toString()).contains("Mcp-Name", "Mcp-Param-Region", "Mcp-Param-Tenant")
120+
.doesNotContain("secret-task", "eu-west1");
81121
assertThat(headers.param("region").toString()).doesNotContain("eu-west1");
82122
}
83123

0 commit comments

Comments
 (0)