diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeader.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeader.java
new file mode 100644
index 000000000..c85bfd11f
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeader.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.modern.server;
+
+import java.util.Objects;
+
+import io.modelcontextprotocol.util.Assert;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A request header that a client mirrors from the JSON-RPC body, as decoded by the
+ * transport. A header that was sent but couldn't be decoded, or was sent more than once,
+ * is malformed and has no value.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @see McpHeaders
+ */
+public final class McpHeader {
+
+ private final String name;
+
+ private final @Nullable String value;
+
+ private McpHeader(String name, @Nullable String value) {
+ Assert.notNull(name, "name must not be null");
+ this.name = name;
+ this.value = value;
+ }
+
+ /**
+ * Creates a header with a decoded value.
+ * @param name the header name, for instance {@code Mcp-Param-Region}
+ * @param value the decoded value
+ * @return the header
+ */
+ public static McpHeader of(String name, String value) {
+ Assert.notNull(value, "value must not be null");
+ return new McpHeader(name, value);
+ }
+
+ /**
+ * Creates a header that was sent but couldn't be decoded, or was sent more than once.
+ * @param name the header name, for instance {@code Mcp-Param-Region}
+ * @return the malformed header
+ */
+ public static McpHeader malformed(String name) {
+ return new McpHeader(name, null);
+ }
+
+ /**
+ * The header name, such as {@code Mcp-Name} or {@code Mcp-Param-Region}. Header names
+ * are case-insensitive, so compare it ignoring case.
+ * @return the header name
+ */
+ public String name() {
+ return this.name;
+ }
+
+ /**
+ * The decoded value.
+ * @return the decoded value, or {@code null} if the header is malformed
+ */
+ public @Nullable String value() {
+ return this.value;
+ }
+
+ /**
+ * Whether the header was sent but has no usable value, because it couldn't be decoded
+ * or was sent more than once.
+ * @return {@code true} if the header is malformed
+ */
+ public boolean isMalformed() {
+ return this.value == null;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return (this == other) || (other instanceof McpHeader that && this.name.equals(that.name)
+ && Objects.equals(this.value, that.value));
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.name, this.value);
+ }
+
+ @Override
+ public String toString() {
+ // The value stays out: it may end up in logs.
+ return "McpHeader[name=" + this.name + (isMalformed() ? ", malformed" : "") + "]";
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeaders.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeaders.java
new file mode 100644
index 000000000..94a607e6c
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpHeaders.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.modern.server;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import io.modelcontextprotocol.modern.McpHttp.Headers;
+import io.modelcontextprotocol.util.Assert;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The request headers a client mirrors from the JSON-RPC body:
+ *
+ *
{@code Mcp-Name}, which mirrors the tool or prompt name or the resource URI, and
+ * for methods defined outside the core specification, such as {@code tasks/get}, the
+ * parameter that method designates;
+ *
the {@code Mcp-Param-{Name}} headers of a {@code tools/call} request, which mirror
+ * the arguments a tool annotates with {@code x-mcp-header}.
+ *
+ *
+ * An HTTP transport decodes the headers, for instance with
+ * {@code McpHeaderDecoder.headers}, and adds them to the transport context of every
+ * request under {@link #TRANSPORT_CONTEXT_KEY}. A transport that has no headers, such as
+ * stdio, maps the key to {@link #NOT_MIRRORED} instead. The tools feature validates the
+ * {@code Mcp-Param-{Name}} headers against the arguments, and fails the call as an
+ * internal error when the key is missing.
+ *
+ * Headers are looked up ignoring case.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @see SEP-2243:
+ * HTTP Standardization
+ */
+public final class McpHeaders {
+
+ /**
+ * The transport context key under which a transport provides the {@code McpHeaders}
+ * of a request.
+ */
+ public static final String TRANSPORT_CONTEXT_KEY = McpHeaders.class.getName();
+
+ /**
+ * Marks a transport that never mirrors the body into headers, such as stdio, so
+ * header validation is skipped. Compared by identity: headers built with
+ * {@link #builder()} are never {@code NOT_MIRRORED}, even when empty.
+ */
+ public static final McpHeaders NOT_MIRRORED = new McpHeaders(Map.of());
+
+ // Keyed by the lower-cased full header name, such as mcp-param-region, so headers
+ // other than Mcp-Param-{Name} can share the map.
+ private final Map headers;
+
+ private McpHeaders(Map headers) {
+ this.headers = headers;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * The header with the given full name, for instance {@code Mcp-Param-Region}.
+ * @param headerName the header name, in any case
+ * @return the header, possibly malformed, or {@code null} if it was not sent
+ */
+ public @Nullable McpHeader get(String headerName) {
+ Assert.notNull(headerName, "headerName must not be null");
+ return this.headers.get(normalize(headerName));
+ }
+
+ /**
+ * The {@code Mcp-Param-{Name}} header with the given {@code {Name}}.
+ * @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
+ * @return the header, possibly malformed, or {@code null} if it was not sent
+ */
+ public @Nullable McpHeader param(String name) {
+ return get(paramHeaderName(name));
+ }
+
+ private static String paramHeaderName(String name) {
+ Assert.notNull(name, "name must not be null");
+ return Headers.MCP_PARAM_PREFIX + name;
+ }
+
+ private static String normalize(String headerName) {
+ return headerName.toLowerCase(Locale.ROOT);
+ }
+
+ @Override
+ public String toString() {
+ return (this == NOT_MIRRORED) ? "McpHeaders[NOT_MIRRORED]" : "McpHeaders" + this.headers.values();
+ }
+
+ public static final class Builder {
+
+ private final Map headers = new LinkedHashMap<>();
+
+ private Builder() {
+ }
+
+ /**
+ * Adds a header by its full name, such as {@code Mcp-Name}. A header added more
+ * than once, ignoring case, is malformed, because a header sent more than once
+ * has no single value to compare. The name is not checked: callers are expected
+ * to add only the headers a client mirrors from the body.
+ *
+ * A {@code null} value marks the header as malformed: it was sent, but has no
+ * single usable value, for instance because it couldn't be decoded or carried
+ * several values. Don't add a header that wasn't sent, so that
+ * {@link McpHeaders#get(String)} returns {@code null} for it: passing the result
+ * of a lookup that returns {@code null} for absent headers, such as
+ * {@code HttpServletRequest.getHeader}, would report it as malformed instead of
+ * missing.
+ * @param name the full name of the header, in any case
+ * @param value the decoded value, or {@code null} if the header was sent but is
+ * malformed
+ * @return this builder
+ */
+ public Builder header(String name, @Nullable String value) {
+ var header = (value != null) ? McpHeader.of(name, value) : McpHeader.malformed(name);
+ return add(header);
+ }
+
+ /**
+ * Adds an {@code Mcp-Param-{Name}} header. A paramName added more than once,
+ * ignoring case, is malformed, because a header sent more than once has no single
+ * value to compare. As with {@link #header(String, String)}, a {@code null} value
+ * marks the header as malformed, and a header that wasn't sent must not be added.
+ * @param paramName the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any
+ * case
+ * @param value the decoded value, or {@code null} if the header was sent but is
+ * malformed
+ * @return this builder
+ */
+ public Builder paramHeader(String paramName, @Nullable String value) {
+ return header(paramHeaderName(paramName), value);
+ }
+
+ // A header added again, ignoring case, becomes malformed and keeps the name it
+ // was first added with.
+ private Builder add(McpHeader header) {
+ this.headers.merge(normalize(header.name()), header,
+ (previous, added) -> McpHeader.malformed(previous.name()));
+ return this;
+ }
+
+ public McpHeaders build() {
+ return new McpHeaders(Collections.unmodifiableMap(new LinkedHashMap<>(this.headers)));
+ }
+
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpParamHeaders.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpParamHeaders.java
deleted file mode 100644
index dcf2e4580..000000000
--- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpParamHeaders.java
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright 2026-2026 the original author or authors.
- */
-
-package io.modelcontextprotocol.modern.server;
-
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-
-import io.modelcontextprotocol.modern.McpHttp;
-import io.modelcontextprotocol.util.Assert;
-import org.jspecify.annotations.Nullable;
-
-/**
- * The {@code Mcp-Param-{Name}} headers of a {@code tools/call} request, which a client
- * mirrors from the arguments a tool annotates with {@code x-mcp-header}.
- *
- * An HTTP transport decodes the headers, for instance with
- * {@code McpHeaderDecoder.paramHeaders}, and adds them to the transport context under
- * {@link #TRANSPORT_CONTEXT_KEY}. A transport that has no headers, such as stdio, maps
- * the key to {@link #NOT_MIRRORED} instead. The tools feature validates the headers
- * against the arguments, and fails the call as an internal error when the key is missing.
- *
- * Headers are looked up by the {@code {Name}} part of the header name, ignoring case. A
- * present header either has a decoded value or is malformed, never both.
- *
- * @author Daniel Garnier-Moiroux
- * @see SEP-2243:
- * HTTP Standardization
- */
-public final class McpParamHeaders {
-
- /**
- * The transport context key under which a transport provides the
- * {@code McpParamHeaders} of a {@code tools/call} request.
- */
- public static final String TRANSPORT_CONTEXT_KEY = McpParamHeaders.class.getName();
-
- /**
- * The prefix of every {@code Mcp-Param-{Name}} header name.
- */
- public static final String HEADER_PREFIX = McpHttp.Headers.MCP_PARAM_PREFIX;
-
- /**
- * Marks a transport that never mirrors arguments into headers, such as stdio, so
- * header validation is skipped. Compared by identity: headers built with
- * {@link #builder()} are never {@code NOT_MIRRORED}, even when empty.
- */
- public static final McpParamHeaders NOT_MIRRORED = new McpParamHeaders(Map.of(), Set.of());
-
- private final Map values;
-
- private final Set malformed;
-
- private McpParamHeaders(Map values, Set malformed) {
- this.values = values;
- this.malformed = malformed;
- }
-
- public static Builder builder() {
- return new Builder();
- }
-
- /**
- * Whether a header with the given {@code {Name}} is present, malformed or not.
- * @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
- * @return {@code true} if the header is present
- */
- public boolean contains(String name) {
- String key = normalize(name);
- return this.values.containsKey(key) || this.malformed.contains(key);
- }
-
- /**
- * Whether the header with the given {@code {Name}} is present but malformed, because
- * its value couldn't be decoded or the header was sent more than once.
- * @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
- * @return {@code true} if the header is malformed
- */
- public boolean isMalformed(String name) {
- return this.malformed.contains(normalize(name));
- }
-
- /**
- * The decoded value of the header with the given {@code {Name}}.
- * @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
- * @return the decoded value, or {@code null} if the header is absent or malformed
- */
- @Nullable public String value(String name) {
- return this.values.get(normalize(name));
- }
-
- private static String normalize(String name) {
- Assert.notNull(name, "name must not be null");
- return name.toLowerCase(Locale.ROOT);
- }
-
- @Override
- public String toString() {
- // Values stay out: they may end up in logs.
- return (this == NOT_MIRRORED) ? "McpParamHeaders[NOT_MIRRORED]"
- : "McpParamHeaders[values=" + this.values.keySet() + ", malformed=" + this.malformed + "]";
- }
-
- public static final class Builder {
-
- private final Map values = new LinkedHashMap<>();
-
- private final Set malformed = new LinkedHashSet<>();
-
- private Builder() {
- }
-
- /**
- * Adds a decoded header value. A name added more than once, ignoring case, is
- * malformed.
- * @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
- * @param value the decoded value
- * @return this builder
- */
- public Builder value(String name, String value) {
- Assert.notNull(value, "value must not be null");
- String key = normalize(name);
- if (this.malformed.contains(key) || this.values.containsKey(key)) {
- return malformed(key);
- }
- this.values.put(key, value);
- return this;
- }
-
- /**
- * Adds a header whose value is malformed.
- * @param name the {@code {Name}} part of {@code Mcp-Param-{Name}}, in any case
- * @return this builder
- */
- public Builder malformed(String name) {
- String key = normalize(name);
- this.values.remove(key);
- this.malformed.add(key);
- return this;
- }
-
- public McpParamHeaders build() {
- return new McpParamHeaders(Collections.unmodifiableMap(new LinkedHashMap<>(this.values)),
- Collections.unmodifiableSet(new LinkedHashSet<>(this.malformed)));
- }
-
- }
-
-}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java
index 8c9375717..35731b9a2 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestManager.java
@@ -14,9 +14,11 @@
* {@link McpTransportResponse}. Use {@link #handleBlocking} when the calling thread may
* block, {@link #handle} when it must not. Neither completes with an error.
*
- * For {@code tools/call}, the transport context must map
- * {@link McpParamHeaders#TRANSPORT_CONTEXT_KEY} to the request's {@code Mcp-Param-{Name}}
- * headers, or to {@link McpParamHeaders#NOT_MIRRORED} for a transport without headers.
+ * For every request, the transport context must map
+ * {@link McpHeaders#TRANSPORT_CONTEXT_KEY} to the request's mirrored headers
+ * ({@code Mcp-Name} and {@code Mcp-Param-{Name}}), or to {@link McpHeaders#NOT_MIRRORED}
+ * for a transport without headers. Features read them to check the headers against the
+ * body, for instance the tools feature for {@code tools/call}.
*
* @author Dariusz Jędrzejczyk
*/
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java
index 1fbed134b..6640a2b08 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java
@@ -10,9 +10,11 @@
import java.util.Map;
import java.util.regex.Pattern;
+import io.modelcontextprotocol.modern.McpHttp.Headers;
import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Annotation;
import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Type;
-import io.modelcontextprotocol.modern.server.McpParamHeaders;
+import io.modelcontextprotocol.modern.server.McpHeader;
+import io.modelcontextprotocol.modern.server.McpHeaders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -46,8 +48,7 @@ private ParamHeaderValidator() {
* @param headers the request's headers
* @return {@link Valid}, or {@link Invalid} with the first failure
*/
- static Result validate(List annotations, Map callToolArguments,
- McpParamHeaders headers) {
+ static Result validate(List annotations, Map callToolArguments, McpHeaders headers) {
for (Annotation annotation : annotations) {
Object argument = resolve(callToolArguments, annotation.path());
String problem = check(annotation, argument, headers);
@@ -61,8 +62,9 @@ static Result validate(List annotations, Map callToo
}
// null if the header agrees with the argument.
- private static String check(Annotation annotation, Object argument, McpParamHeaders headers) {
- boolean present = headers.contains(annotation.name());
+ private static String check(Annotation annotation, Object argument, McpHeaders headers) {
+ McpHeader header = headers.param(annotation.name());
+ boolean present = header != null;
String mismatch = header(annotation) + " header does not match argument '" + annotation.argument() + "'";
if (argument == null) {
return present
@@ -77,10 +79,10 @@ private static String check(Annotation annotation, Object argument, McpParamHead
if (!present) {
return "Missing required header: " + header(annotation);
}
- if (headers.isMalformed(annotation.name())) {
+ if (header.isMalformed()) {
return header(annotation) + " header is malformed";
}
- return matches(annotation.type(), argument, headers.value(annotation.name())) ? null : mismatch;
+ return matches(annotation.type(), argument, header.value()) ? null : mismatch;
}
private static Object resolve(Map arguments, List path) {
@@ -125,15 +127,15 @@ private static BigDecimal decimal(Number number) {
}
private static String header(Annotation annotation) {
- return McpParamHeaders.HEADER_PREFIX + annotation.name();
+ return Headers.MCP_PARAM_PREFIX + annotation.name();
}
- private static String describe(McpParamHeaders headers, String name) {
- if (headers.isMalformed(name)) {
- return "";
+ private static String describe(McpHeaders headers, String name) {
+ McpHeader header = headers.param(name);
+ if (header == null) {
+ return "";
}
- String value = headers.value(name);
- return (value != null) ? value : "";
+ return header.isMalformed() ? "" : header.value();
}
/**
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java
index 196e9d12b..bdadbe7d2 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java
@@ -32,7 +32,7 @@
import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.InvalidAnnotation;
import io.modelcontextprotocol.modern.server.McpAsyncResponse;
import io.modelcontextprotocol.modern.server.McpFeature;
-import io.modelcontextprotocol.modern.server.McpParamHeaders;
+import io.modelcontextprotocol.modern.server.McpHeaders;
import io.modelcontextprotocol.modern.server.McpRequestContext;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
@@ -134,8 +134,8 @@ private Mono> call(McpRequestContext ctx, McpA
}
private static void validateParamHeaders(McpRequestContext ctx, Tool tool, Map arguments) {
- Object headers = ctx.transportContext().get(McpParamHeaders.TRANSPORT_CONTEXT_KEY);
- if (headers == McpParamHeaders.NOT_MIRRORED) {
+ Object headers = ctx.transportContext().get(McpHeaders.TRANSPORT_CONTEXT_KEY);
+ if (headers == McpHeaders.NOT_MIRRORED) {
return;
}
// Parsed on every call, without caching, because repositories are user code and
@@ -153,16 +153,15 @@ private static void validateParamHeaders(McpRequestContext ctx, Tool tool, Map
- * Headers are selected by their {@code Mcp-Param-} prefix, ignoring case. A name that
- * carries more than one value, including names that differ only in case, is
+ * {@code Mcp-Name} is selected by its exact name and {@code Mcp-Param-{Name}} by its
+ * prefix, both ignoring case, so {@code Mcp-Param-Mcp-Name} is a param header. A name
+ * that carries more than one value, including names that differ only in case, is
* malformed. Every other value is decoded with {@link #decodeValue(String)}, and
* marked malformed when it can't be decoded. Rejecting a malformed header is left to
- * the tools feature, because only a header that the called tool declares may cause a
+ * whoever compares it with the body: the transport for the methods it validates, and
+ * the feature handling the method otherwise. For instance, only a
+ * {@code Mcp-Param-{Name}} header that the called tool declares may cause a
* rejection.
*