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: + * + *

+ * 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. *

* As with {@link #decodeValue(String)}, the accessor must return values with their @@ -102,28 +105,29 @@ private McpHeaderDecoder() { * @param headers the request headers * @return the decoded headers */ - public static McpParamHeaders paramHeaders(HeaderAccessor headers) { + public static McpHeaders headers(HeaderAccessor headers) { // The accessor may be case-sensitive, so values are gathered under every name // that differs only in case. Map> names = new LinkedHashMap<>(); for (String name : headers.getHeaderNames()) { - if (name.regionMatches(true, 0, McpParamHeaders.HEADER_PREFIX, 0, McpParamHeaders.HEADER_PREFIX.length())) { + if (name.equalsIgnoreCase(Headers.MCP_NAME) + || name.regionMatches(true, 0, Headers.MCP_PARAM_PREFIX, 0, Headers.MCP_PARAM_PREFIX.length())) { names.computeIfAbsent(name.toLowerCase(Locale.ROOT), k -> new LinkedHashSet<>()).add(name); } } - McpParamHeaders.Builder builder = McpParamHeaders.builder(); + McpHeaders.Builder builder = McpHeaders.builder(); for (Set variants : names.values()) { - String paramName = variants.iterator().next().substring(McpParamHeaders.HEADER_PREFIX.length()); List values = new ArrayList<>(); for (String name : variants) { values.addAll(headers.getHeader(name)); } String decoded = values.size() == 1 ? decodeValue(values.get(0)) : null; - if (decoded != null) { - builder.value(paramName, decoded); + String headerName = variants.iterator().next(); + if (headerName.equalsIgnoreCase(Headers.MCP_NAME)) { + builder.header(Headers.MCP_NAME, decoded); } else { - builder.malformed(paramName); + builder.paramHeader(headerName.substring(Headers.MCP_PARAM_PREFIX.length()), decoded); } } return builder.build(); 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 c70eef3f7..0f5676970 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 @@ -25,7 +25,7 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpStdio; -import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.modern.server.McpHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpTransportResponse; import io.modelcontextprotocol.util.Assert; @@ -50,10 +50,10 @@ public class StdioMcpTransport { private static final Logger logger = LoggerFactory.getLogger(StdioMcpTransport.class); - // Stdio has no headers, so Mcp-Param-* headers are never mirrored and the tools - // feature skips their validation. + // Stdio has no headers, so nothing is mirrored (neither Mcp-Name nor Mcp-Param-*) + // and features skip header validation. private static final McpTransportContext TRANSPORT_CONTEXT = McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)); + .create(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, McpHeaders.NOT_MIRRORED)); private final McpRequestManager requestManager; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpHeadersTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpHeadersTests.java new file mode 100644 index 000000000..dc7ffb907 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpHeadersTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.modern.McpHttp; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpHeadersTests { + + @Test + void paramAddedTwiceIgnoringCaseIsMalformed() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").paramHeader("region", "eu").build(); + + assertThat(headers.param("region")).isEqualTo(McpHeader.malformed("Mcp-Param-Region")); + } + + @Test + void paramAddedAfterNullStaysMalformed() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", null).paramHeader("region", "eu").build(); + + assertThat(headers.param("region").isMalformed()).isTrue(); + } + + @Test + void nullParamAddedAfterValueMakesItMalformed() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").paramHeader("region", null).build(); + + assertThat(headers.param("region").isMalformed()).isTrue(); + } + + @Test + void presentParamHasItsValue() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build(); + + McpHeader header = headers.param("region"); + assertThat(header.name()).isEqualTo("Mcp-Param-Region"); + assertThat(header.value()).isEqualTo("eu"); + assertThat(header.isMalformed()).isFalse(); + } + + @Test + void paramIsLookedUpIgnoringCase() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build(); + + assertThat(headers.param("REGION")).isEqualTo(headers.param("region")); + } + + @Test + void absentParamIsNull() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build(); + + assertThat(headers.param("tenant")).isNull(); + } + + @Test + void paramIsLookedUpByFullHeaderNameIgnoringCase() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build(); + + assertThat(headers.get("mcp-param-REGION")).isEqualTo(McpHeader.of("Mcp-Param-Region", "eu")); + assertThat(headers.get("Region")).isNull(); + } + + @Test + void paramNamedLikeAnotherMcpHeaderKeepsItsPrefix() { + // "Mcp-Name" is a valid x-mcp-header name, so a tool can make the client send + // Mcp-Param-Mcp-Name, which is not the Mcp-Name header. + McpHeaders headers = McpHeaders.builder().paramHeader("Mcp-Name", "echo").build(); + + assertThat(headers.get("Mcp-Param-Mcp-Name")).isEqualTo(McpHeader.of("Mcp-Param-Mcp-Name", "echo")); + assertThat(headers.get("Mcp-Name")).isNull(); + assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isNull(); + } + + @Test + void nameIsLookedUpAsMcpName() { + McpHeaders headers = McpHeaders.builder().header(McpHttp.Headers.MCP_NAME, "echo").build(); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.of("Mcp-Name", "echo")); + assertThat(headers.get("mcp-name")).isEqualTo(headers.get(McpHttp.Headers.MCP_NAME)); + assertThat(headers.param("Name")).isNull(); + } + + @Test + void nameAddedTwiceIsMalformed() { + McpHeaders headers = McpHeaders.builder() + .header(McpHttp.Headers.MCP_NAME, "echo") + .header(McpHttp.Headers.MCP_NAME, "echo") + .build(); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.malformed("Mcp-Name")); + } + + @Test + void nullNameIsMalformed() { + McpHeaders headers = McpHeaders.builder().header(McpHttp.Headers.MCP_NAME, null).build(); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue(); + } + + @Test + void absentNameIsNull() { + McpHeaders headers = McpHeaders.builder().paramHeader("Region", "eu").build(); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isNull(); + } + + @Test + void toStringOmitsValues() { + McpHeaders headers = McpHeaders.builder() + .header(McpHttp.Headers.MCP_NAME, "secret-task") + .paramHeader("Region", "eu-west1") + .paramHeader("Tenant", null) + .build(); + + assertThat(headers.toString()).contains("Mcp-Name", "Mcp-Param-Region", "Mcp-Param-Tenant") + .doesNotContain("secret-task", "eu-west1"); + assertThat(headers.param("region").toString()).doesNotContain("eu-west1"); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpParamHeadersTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpParamHeadersTests.java deleted file mode 100644 index cf97ab853..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpParamHeadersTests.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2026-2026 the original author or authors. - */ - -package io.modelcontextprotocol.modern.server; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class McpParamHeadersTests { - - @Test - void nameAddedTwiceIgnoringCaseIsMalformed() { - McpParamHeaders headers = McpParamHeaders.builder().value("Region", "eu").value("region", "eu").build(); - - assertThat(headers.isMalformed("region")).isTrue(); - assertThat(headers.value("region")).isNull(); - } - - @Test - void valueAddedAfterMalformedStaysMalformed() { - McpParamHeaders headers = McpParamHeaders.builder().malformed("Region").value("region", "eu").build(); - - assertThat(headers.isMalformed("region")).isTrue(); - assertThat(headers.value("region")).isNull(); - } - - @Test - void malformedAddedAfterValueReplacesIt() { - McpParamHeaders headers = McpParamHeaders.builder().value("Region", "eu").malformed("region").build(); - - assertThat(headers.isMalformed("region")).isTrue(); - assertThat(headers.value("region")).isNull(); - } - - @Test - void presentValueIsNotMalformed() { - McpParamHeaders headers = McpParamHeaders.builder().value("Region", "eu").build(); - - assertThat(headers.contains("region")).isTrue(); - assertThat(headers.isMalformed("region")).isFalse(); - assertThat(headers.value("region")).isEqualTo("eu"); - } - - @Test - void absentHeaderIsNeitherPresentNorMalformed() { - McpParamHeaders headers = McpParamHeaders.builder().value("Region", "eu").build(); - - assertThat(headers.contains("tenant")).isFalse(); - assertThat(headers.isMalformed("tenant")).isFalse(); - assertThat(headers.value("tenant")).isNull(); - } - - @Test - void toStringOmitsValues() { - McpParamHeaders headers = McpParamHeaders.builder().value("Region", "eu-west1").build(); - - assertThat(headers.toString()).contains("region").doesNotContain("eu-west1"); - } - -} 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..545973849 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,7 +9,9 @@ import java.util.List; import java.util.Map; -import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.modern.McpHttp; +import io.modelcontextprotocol.modern.server.McpHeader; +import io.modelcontextprotocol.modern.server.McpHeaders; import io.modelcontextprotocol.server.transport.HeaderAccessor; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -88,59 +90,54 @@ void plainValueWithCharactersOutsideVisibleAsciiSpaceAndTabIsMalformed(String ra } @Test - void paramHeadersSelectsMcpParamHeadersIgnoringThePrefixCase() { - McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Region", "eu", "MCP-PARAM-Tenant", - "acme", "mcp-param-zone", "a", "Mcp-Name", "echo", "X-Param-Other", "x")); - - assertThat(headers.value("region")).isEqualTo("eu"); - assertThat(headers.value("tenant")).isEqualTo("acme"); - assertThat(headers.value("zone")).isEqualTo("a"); - assertThat(headers.contains("name")).isFalse(); - assertThat(headers.contains("other")).isFalse(); + void paramHeadersSelectsMcpHeadersIgnoringThePrefixCase() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Region", "eu", "MCP-PARAM-Tenant", "acme", + "mcp-param-zone", "a", "Mcp-Name", "echo", "X-Param-Other", "x")); + + assertThat(headers.param("region").value()).isEqualTo("eu"); + assertThat(headers.param("tenant").value()).isEqualTo("acme"); + assertThat(headers.param("zone").value()).isEqualTo("a"); + assertThat(headers.param("name")).isNull(); + assertThat(headers.param("other")).isNull(); } @Test void paramHeaderNamesAreLookedUpIgnoringCase() { - McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Region", "eu")); + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Region", "eu")); - assertThat(headers.value("Region")).isEqualTo("eu"); - assertThat(headers.value("REGION")).isEqualTo("eu"); - assertThat(headers.contains("rEgIoN")).isTrue(); + assertThat(headers.param("Region").value()).isEqualTo("eu"); + assertThat(headers.param("REGION").value()).isEqualTo("eu"); + assertThat(headers.param("rEgIoN")).isNotNull(); } @Test void paramHeaderValuesAreDecoded() { - McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Greeting", "=?base64?SGVsbG8=?=")); + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Greeting", "=?base64?SGVsbG8=?=")); - assertThat(headers.value("greeting")).isEqualTo("Hello"); + assertThat(headers.param("greeting").value()).isEqualTo("Hello"); } @Test void malformedParamHeaderValueIsMarkedMalformed() { - McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Greeting", "=?base64?SGVsbG8?=")); + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Greeting", "=?base64?SGVsbG8?=")); - assertThat(headers.isMalformed("greeting")).isTrue(); - assertThat(headers.value("greeting")).isNull(); + assertThat(headers.param("greeting").isMalformed()).isTrue(); } @Test void paramHeaderWithSeveralValuesIsMarkedMalformed() { - McpParamHeaders headers = McpHeaderDecoder - .paramHeaders(headers("Mcp-Param-Region", "eu", "Mcp-Param-Region", "eu")); + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Region", "eu", "Mcp-Param-Region", "eu")); - assertThat(headers.isMalformed("region")).isTrue(); - assertThat(headers.value("region")).isNull(); + assertThat(headers.param("region").isMalformed()).isTrue(); } @Test void paramHeadersDifferingOnlyInNameCaseAreMarkedMalformed() { // Containers usually fold these into one name with two values, but an accessor // may also report them as two names. - McpParamHeaders headers = McpHeaderDecoder - .paramHeaders(headers("Mcp-Param-Region", "eu", "mcp-param-region", "us")); + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Region", "eu", "mcp-param-region", "us")); - assertThat(headers.isMalformed("region")).isTrue(); - assertThat(headers.value("region")).isNull(); + assertThat(headers.param("region").isMalformed()).isTrue(); } @Test @@ -160,17 +157,80 @@ public List getHeaderNames() { } }; - McpParamHeaders headers = McpHeaderDecoder.paramHeaders(caseSensitive); + McpHeaders headers = McpHeaderDecoder.headers(caseSensitive); + + assertThat(headers.param("region").isMalformed()).isTrue(); + } + + @Test + void mcpNameIsNotAParamHeader() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Name", "echo")); + + assertThat(headers.param("name")).isNull(); + } + + @Test + void mcpNameIsSelectedIgnoringCase() { + McpHeaders headers = McpHeaderDecoder.headers(headers("mCP-nAME", "echo", "X-Mcp-Name", "other")); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.of("Mcp-Name", "echo")); + } + + @Test + void mcpNameIsAbsentWhenNotSent() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Param-Region", "eu")); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME)).isNull(); + } + + @Test + void mcpNameValueIsDecoded() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Name", "=?base64?SGVsbG8=?=")); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME).value()).isEqualTo("Hello"); + } + + @Test + void undecodableMcpNameIsMarkedMalformed() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Name", "=?base64?SGVsbG8?=")); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue(); + } + + @Test + void mcpNameWithSeveralValuesIsMarkedMalformed() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Name", "echo", "mcp-name", "other")); + + assertThat(headers.get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue(); + } + + @Test + void mcpNameListedWithoutAValueIsMarkedMalformed() { + // An accessor may report a header name for which it then returns no values: + // the header was sent, but there is no single value to compare. + HeaderAccessor noValues = new HeaderAccessor() { + @Override + public List getHeader(String name) { + return List.of(); + } + + @Override + public List getHeaderNames() { + return List.of("Mcp-Name"); + } + }; + + McpHeaders headers = McpHeaderDecoder.headers(noValues); - assertThat(headers.isMalformed("region")).isTrue(); - assertThat(headers.value("region")).isNull(); + assertThat(headers.get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue(); } @Test - void paramHeadersAreEmptyWithoutMcpParamHeaders() { - McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Name", "echo")); + void paramHeaderNamedMcpNameIsKeptApartFromMcpName() { + McpHeaders headers = McpHeaderDecoder.headers(headers("Mcp-Name", "echo", "Mcp-Param-Mcp-Name", "other")); - assertThat(headers.contains("name")).isFalse(); + assertThat(headers.get(McpHttp.Headers.MCP_NAME).value()).isEqualTo("echo"); + assertThat(headers.param("Mcp-Name").value()).isEqualTo("other"); } /** diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/client/MockMcpClientTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/client/MockMcpClientTransport.java index 24f724cdf..474a7984a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/client/MockMcpClientTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/client/MockMcpClientTransport.java @@ -17,7 +17,7 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.client.transport.McpClientTransport; import io.modelcontextprotocol.modern.client.transport.McpTransportRequest; -import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.modern.server.McpHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpTransportResponse; import reactor.core.publisher.Flux; @@ -50,13 +50,13 @@ public Flux exchange(McpTransportRequest request) { return request.withRoundTimeout(Flux.deferContextual(ctx -> { this.requests.add(request); McpTransportContext context = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); - McpParamHeaders paramHeaders = McpParamHeaders.NOT_MIRRORED; + McpHeaders mcpHeaders = McpHeaders.NOT_MIRRORED; if (this.mirrorsToolParams) { - McpParamHeaders.Builder builder = McpParamHeaders.builder(); - request.paramHeaders().forEach(builder::value); - paramHeaders = builder.build(); + McpHeaders.Builder builder = McpHeaders.builder(); + request.paramHeaders().forEach(builder::paramHeader); + mcpHeaders = builder.build(); } - context = context.putAll(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, paramHeaders)); + context = context.putAll(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, mcpHeaders)); JSONRPCRequest received = (JSONRPCRequest) overTheWire(request.message()); return this.server.handle(context, received).flatMapMany(response -> { if (response instanceof McpTransportResponse.Result result) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java index 4a9275d8b..a48aedaa0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java @@ -26,7 +26,7 @@ import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpAsyncResponse; -import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.modern.server.McpHeaders; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.ModernTestFixtures; @@ -62,7 +62,7 @@ class ToolsFeatureParamHeadersTests { @Test void matchingHeaderIsAccepted() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), Map.of("region", "eu-west1")); assertAccepted(response); @@ -78,7 +78,7 @@ void missingHeaderIsRejected() { @Test void mismatchedHeaderIsRejected() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "us-east1")), + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", "us-east1")), Map.of("region", "eu-west1")); assertRejected(response, "Mcp-Param-Region header does not match argument 'region'"); @@ -86,7 +86,7 @@ void mismatchedHeaderIsRejected() { @Test void comparisonIsCaseSensitive() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "EU-West1")), + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", "EU-West1")), Map.of("region", "eu-west1")); assertRejected(response, "Mcp-Param-Region header does not match argument 'region'"); @@ -94,7 +94,7 @@ void comparisonIsCaseSensitive() { @Test void malformedHeaderIsRejected() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.malformed("Region")), + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", null)), Map.of("region", "eu-west1")); assertRejected(response, "Mcp-Param-Region header is malformed"); @@ -102,7 +102,7 @@ void malformedHeaderIsRejected() { @Test void headerForAnAbsentArgumentIsRejected() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), Map.of()); + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), Map.of()); assertRejected(response, "Mcp-Param-Region header present but argument 'region' is absent"); } @@ -112,14 +112,14 @@ void headerForANullArgumentIsRejected() { Map arguments = new HashMap<>(); arguments.put("region", null); - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), arguments); + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), arguments); assertRejected(response, "Mcp-Param-Region header present but argument 'region' is absent"); } @Test void malformedHeaderForAnAbsentArgumentIsRejected() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.malformed("Region")), Map.of()); + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("Region", null)), Map.of()); assertRejected(response, "Mcp-Param-Region header present but argument 'region' is absent"); } @@ -127,7 +127,7 @@ void malformedHeaderForAnAbsentArgumentIsRejected() { @Test void headerForMissingArgumentsIsRejected() { JSONRPCResponse response = respond(server(REGION_SCHEMA, PERMISSIVE_VALIDATOR), - headers(h -> h.value("Region", "eu-west1")), + headers(h -> h.paramHeader("Region", "eu-west1")), new JSONRPCRequest("tools/call", 1, Map.of("_meta", meta(), "name", "forecast"))); assertRejected(response, "Mcp-Param-Region header present but argument 'region' is absent"); @@ -154,8 +154,8 @@ void nullArgumentWithoutHeaderIsAccepted() { @Test void undeclaredHeadersAreIgnored() { - JSONRPCResponse response = call(REGION_SCHEMA, - headers(h -> h.value("Region", "eu-west1").value("Tenant", "acme").malformed("Zone")), + JSONRPCResponse response = call(REGION_SCHEMA, headers( + h -> h.paramHeader("Region", "eu-west1").paramHeader("Tenant", "acme").paramHeader("Zone", null)), Map.of("region", "eu-west1")); assertAccepted(response); @@ -163,7 +163,7 @@ void undeclaredHeadersAreIgnored() { @Test void headerNameIsMatchedIgnoringCase() { - JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("region", "eu-west1")), + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.paramHeader("region", "eu-west1")), Map.of("region", "eu-west1")); assertAccepted(response); @@ -181,7 +181,8 @@ void onlyTheFirstFailureInSchemaOrderIsReported() { } """; - JSONRPCResponse response = call(schema, headers(h -> h.value("Region", "us-east1").malformed("Zone")), + JSONRPCResponse response = call(schema, + headers(h -> h.paramHeader("Region", "us-east1").paramHeader("Zone", null)), Map.of("region", "eu-west1", "zone", "a")); assertRejected(response, "Mcp-Param-Zone header is malformed"); @@ -194,7 +195,7 @@ void headersAreValidatedBeforeTheInputSchema() { ? ValidationResponse.asInvalid("rejected") : ValidationResponse.asValid(null); JSONRPCResponse response = respond(server(REGION_SCHEMA, rejectingInput), - headers(h -> h.value("Region", "us-east1")), callForecast(Map.of("region", "eu-west1"))); + headers(h -> h.paramHeader("Region", "us-east1")), callForecast(Map.of("region", "eu-west1"))); assertRejected(response, "Mcp-Param-Region header does not match argument 'region'"); } @@ -202,7 +203,7 @@ void headersAreValidatedBeforeTheInputSchema() { @Test void notMirroredSkipsValidation() { McpTransportContext notMirrored = McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)); + .create(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, McpHeaders.NOT_MIRRORED)); JSONRPCResponse response = call(REGION_SCHEMA, notMirrored, Map.of("region", "eu-west1")); @@ -238,7 +239,8 @@ void toolWithoutAnnotationsIgnoresParamHeaders() { } """; - JSONRPCResponse response = call(schema, headers(h -> h.value("Region", "us-east1").malformed("Zone")), + JSONRPCResponse response = call(schema, + headers(h -> h.paramHeader("Region", "us-east1").paramHeader("Zone", null)), Map.of("region", "eu-west1")); assertAccepted(response); @@ -267,35 +269,39 @@ class IntegerAndBoolean { @Test void integerHeaderMatchesAnIntegralDoubleArgument() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42")), Map.of("level", 42.0)); + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "42")), + Map.of("level", 42.0)); assertAccepted(response); } @Test void integerHeaderMatchesAnExponentArgument() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "1000")), Map.of("level", 1e3)); + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "1000")), + Map.of("level", 1e3)); assertAccepted(response); } @Test void exponentHeaderIsRejected() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "1e3")), Map.of("level", 1000)); + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "1e3")), + Map.of("level", 1000)); assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); } @Test void decimalHeaderMatchesAnIntegerArgument() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42.0")), Map.of("level", 42)); + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "42.0")), + Map.of("level", 42)); assertAccepted(response); } @Test void longBeyondTheSafeRangeMatchesItsExactHeader() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "1152921504606846976")), + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "1152921504606846976")), Map.of("level", 1L << 60)); assertAccepted(response); @@ -303,7 +309,7 @@ void longBeyondTheSafeRangeMatchesItsExactHeader() { @Test void longBeyondTheSafeRangeIsComparedExactly() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "1152921504606846977")), + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "1152921504606846977")), Map.of("level", 1L << 60)); assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); @@ -312,7 +318,7 @@ void longBeyondTheSafeRangeIsComparedExactly() { @Test void bigIntegerMatchesItsExactHeader() { JSONRPCResponse response = call(LEVEL_SCHEMA, - headers(h -> h.value("Level", "-123456789012345678901234567890")), + headers(h -> h.paramHeader("Level", "-123456789012345678901234567890")), Map.of("level", new BigInteger("-123456789012345678901234567890"))); assertAccepted(response); @@ -320,21 +326,23 @@ void bigIntegerMatchesItsExactHeader() { @Test void mismatchedIntegerHeaderIsRejected() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "43")), Map.of("level", 42)); + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "43")), + Map.of("level", 42)); assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); } @Test void integerAnnotationComparesAStringArgumentAsAString() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42")), Map.of("level", "42")); + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "42")), + Map.of("level", "42")); assertAccepted(response); } @Test void integerAnnotationDoesNotCompareAStringArgumentNumerically() { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42")), + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "42")), Map.of("level", "42.0")); assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); @@ -343,8 +351,8 @@ void integerAnnotationDoesNotCompareAStringArgumentNumerically() { @ParameterizedTest @ValueSource(booleans = { true, false }) void booleanHeaderMatchesItsArgument(boolean verbose) { - JSONRPCResponse response = call(VERBOSE_SCHEMA, headers(h -> h.value("Verbose", String.valueOf(verbose))), - Map.of("verbose", verbose)); + JSONRPCResponse response = call(VERBOSE_SCHEMA, + headers(h -> h.paramHeader("Verbose", String.valueOf(verbose))), Map.of("verbose", verbose)); assertAccepted(response); } @@ -352,7 +360,7 @@ void booleanHeaderMatchesItsArgument(boolean verbose) { @ParameterizedTest @ValueSource(strings = { "True", "TRUE", "1" }) void otherBooleanSpellingsAreRejected(String header) { - JSONRPCResponse response = call(VERBOSE_SCHEMA, headers(h -> h.value("Verbose", header)), + JSONRPCResponse response = call(VERBOSE_SCHEMA, headers(h -> h.paramHeader("Verbose", header)), Map.of("verbose", true)); assertRejected(response, "Mcp-Param-Verbose header does not match argument 'verbose'"); @@ -361,7 +369,7 @@ void otherBooleanSpellingsAreRejected(String header) { @ParameterizedTest @MethodSource("objectAndArrayArguments") void objectOrArrayArgumentWithAHeaderIsRejected(Object argument) { - JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42")), + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.paramHeader("Level", "42")), Map.of("level", argument)); assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); @@ -401,7 +409,7 @@ class NestedPaths { @Test void nestedArgumentWithMatchingHeaderIsAccepted() { - JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), Map.of("location", Map.of("region", "eu-west1"))); assertAccepted(response); @@ -409,7 +417,7 @@ void nestedArgumentWithMatchingHeaderIsAccepted() { @Test void nestedArgumentWithMismatchedHeaderIsRejectedWithADottedPath() { - JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "us-east1")), + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.paramHeader("Region", "us-east1")), Map.of("location", Map.of("region", "eu-west1"))); assertRejected(response, "Mcp-Param-Region header does not match argument 'location.region'"); @@ -444,7 +452,7 @@ void deeplyNestedArgumentIsValidated() { } """; - JSONRPCResponse response = call(schema, headers(h -> h.value("Region", "us-east1")), + JSONRPCResponse response = call(schema, headers(h -> h.paramHeader("Region", "us-east1")), Map.of("request", Map.of("location", Map.of("region", "eu-west1")))); assertRejected(response, "Mcp-Param-Region header does not match argument 'request.location.region'"); @@ -468,8 +476,8 @@ void everyAnnotationIsValidated() { } """; - JSONRPCResponse response = call(schema, - headers(h -> h.value("Zone", "a").value("Region", "eu-west1").value("Tenant", "other")), + JSONRPCResponse response = call(schema, headers( + h -> h.paramHeader("Zone", "a").paramHeader("Region", "eu-west1").paramHeader("Tenant", "other")), Map.of("location", Map.of("zone", "a", "region", "eu-west1"), "tenant", "acme")); assertRejected(response, "Mcp-Param-Tenant header does not match argument 'tenant'"); @@ -485,7 +493,8 @@ void missingIntermediateObjectWithoutHeaderIsAccepted() { @Test void headerForAMissingIntermediateObjectIsRejected() { - JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), Map.of()); + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), + Map.of()); assertRejected(response, "Mcp-Param-Region header present but argument 'location.region' is absent"); } @@ -495,7 +504,8 @@ void headerForANullIntermediateObjectIsRejected() { Map arguments = new HashMap<>(); arguments.put("location", null); - JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), arguments); + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), + arguments); assertRejected(response, "Mcp-Param-Region header present but argument 'location.region' is absent"); } @@ -510,7 +520,7 @@ void nonObjectIntermediateValueWithoutHeaderIsAccepted() { @Test void headerForANonObjectIntermediateValueIsRejected() { - JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.paramHeader("Region", "eu-west1")), Map.of("location", List.of(Map.of("region", "eu-west1")))); assertRejected(response, "Mcp-Param-Region header present but argument 'location.region' is absent"); @@ -542,15 +552,17 @@ void invalidAnnotationIsNotEnforcedWhileValidOnesAre() { String schema = withInvalid("{ \"type\": \"number\", \"x-mcp-header\": \"Score\" }"); Map arguments = Map.of("region", "eu-west1", "bad", 1); - assertAccepted(call(schema, headers(h -> h.value("Region", "eu-west1")), arguments)); + assertAccepted(call(schema, headers(h -> h.paramHeader("Region", "eu-west1")), arguments)); ToolsFeatureParamHeadersTests.this.called.set(false); - assertAccepted(call(schema, headers(h -> h.value("Region", "eu-west1").value("Score", "2")), arguments)); + assertAccepted(call(schema, headers(h -> h.paramHeader("Region", "eu-west1").paramHeader("Score", "2")), + arguments)); ToolsFeatureParamHeadersTests.this.called.set(false); - assertAccepted(call(schema, headers(h -> h.value("Region", "eu-west1").malformed("Score")), arguments)); + assertAccepted(call(schema, headers(h -> h.paramHeader("Region", "eu-west1").paramHeader("Score", null)), + arguments)); ToolsFeatureParamHeadersTests.this.called.set(false); - assertRejected(call(schema, headers(h -> h.value("Region", "us-east1").value("Score", "1")), arguments), - "Mcp-Param-Region header does not match argument 'region'"); - assertRejected(call(schema, headers(h -> h.value("Score", "1")), arguments), + assertRejected(call(schema, headers(h -> h.paramHeader("Region", "us-east1").paramHeader("Score", "1")), + arguments), "Mcp-Param-Region header does not match argument 'region'"); + assertRejected(call(schema, headers(h -> h.paramHeader("Score", "1")), arguments), "Missing required header: Mcp-Param-Region"); } @@ -566,7 +578,7 @@ void toolWithOnlyInvalidAnnotationsIsCalled() { } """; - assertAccepted(call(schema, headers(h -> h.value("Score", "2")), Map.of("score", 1))); + assertAccepted(call(schema, headers(h -> h.paramHeader("Score", "2")), Map.of("score", 1))); } @Test @@ -603,10 +615,10 @@ private static JSONRPCRequest callForecast(Map arguments) { return new JSONRPCRequest("tools/call", 1, Map.of("_meta", meta(), "name", "forecast", "arguments", arguments)); } - private static McpTransportContext headers(Consumer headers) { - McpParamHeaders.Builder builder = McpParamHeaders.builder(); + private static McpTransportContext headers(Consumer headers) { + McpHeaders.Builder builder = McpHeaders.builder(); headers.accept(builder); - return McpTransportContext.create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, builder.build())); + return McpTransportContext.create(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, builder.build())); } private static Tool tool(String inputSchema) { 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 fe34afc92..491b27a85 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,12 +21,14 @@ 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; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpHttp; import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; @@ -38,7 +40,8 @@ import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpAsyncResponse; -import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.modern.server.McpHeader; +import io.modelcontextprotocol.modern.server.McpHeaders; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; @@ -53,6 +56,7 @@ import org.apache.catalina.startup.Tomcat; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -95,6 +99,12 @@ class HttpServletMcpTransportIntegrationTests { private static final CountDownLatch LISTEN_CANCELLED = new CountDownLatch(1); + /** + * What the last tools/list or tools/call found under + * {@link McpHeaders#TRANSPORT_CONTEXT_KEY}. + */ + private static final AtomicReference RECEIVED_HEADERS = new AtomicReference<>(); + private static CallToolResult text(String text) { return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); } @@ -104,11 +114,13 @@ static void startServer() { McpAsyncToolRepository repo = new McpAsyncToolRepository() { @Override public Mono list(McpRequestContext ctx, String cursor) { + RECEIVED_HEADERS.set(ctx.transportContext().get(McpHeaders.TRANSPORT_CONTEXT_KEY)); return Mono.just(ToolsPage.of(List.of(ECHO_TOOL))); } @Override public Mono find(McpRequestContext ctx, String name) { + RECEIVED_HEADERS.set(ctx.transportContext().get(McpHeaders.TRANSPORT_CONTEXT_KEY)); if ("region".equals(name)) { return Mono.just(McpAsyncTool.of(REGION_TOOL, this::call)); } @@ -152,8 +164,7 @@ private Mono> call(McpRequestContext ctx, Call .keepAliveInterval(Duration.ofMillis(100)) .errorStatus(QUOTA_EXCEEDED, 429) .contextExtractor(request -> request.getHeader(FORGE_NOT_MIRRORED) != null - ? McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)) + ? McpTransportContext.create(Map.of(McpHeaders.TRANSPORT_CONTEXT_KEY, McpHeaders.NOT_MIRRORED)) : McpTransportContext.EMPTY) .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") @@ -178,6 +189,11 @@ static void stopServer() throws LifecycleException { } } + @BeforeEach + void forgetReceivedHeaders() { + RECEIVED_HEADERS.set(null); + } + private static HttpRequest.Builder post(String method, Map params) throws IOException { Map body = new HashMap<>(); body.put("jsonrpc", "2.0"); @@ -571,6 +587,18 @@ void unpaddedBase64McpNameIsRejected() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void mcpNameIsCheckedAgainstTheNameOfAToolCall() 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); + assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.HEADER_MISMATCH); + assertThatJson(response.body()).inPath("$.error.message") + .isEqualTo("Mcp-Name header does not match request name/uri"); + } + @Test void mcpNameIsCheckedAgainstTheUriOfAResourceRead() throws Exception { Map params = new HashMap<>(); @@ -596,6 +624,85 @@ void missingMcpNameIsRejectedForNonAsciiName() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void mcpNameIsForwardedForAMethodTheTransportDoesNotValidate() throws Exception { + // Stands in for a method such as tasks/get, whose feature compares Mcp-Name with + // a parameter itself. + 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(receivedHeaders().get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.of("Mcp-Name", "task-123")); + } + + @Test + void base64McpNameIsForwardedDecoded() throws Exception { + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("Mcp-Name", "=?base64?SGVsbG8=?=") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(receivedHeaders().get(McpHttp.Headers.MCP_NAME).value()).isEqualTo("Hello"); + } + + @Test + void missingMcpNameIsForwardedAsAbsent() throws Exception { + 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(receivedHeaders()).isNotSameAs(McpHeaders.NOT_MIRRORED); + assertThat(receivedHeaders().get(McpHttp.Headers.MCP_NAME)).isNull(); + } + + @Test + void undecodableMcpNameIsForwardedAsMalformedForAMethodTheTransportDoesNotValidate() throws Exception { + 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(receivedHeaders().get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue(); + } + + @Test + void repeatedMcpNameIsForwardedAsMalformedForAMethodTheTransportDoesNotValidate() throws Exception { + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("Mcp-Name", "task-123") + .header("mcp-name", "task-456") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(receivedHeaders().get(McpHttp.Headers.MCP_NAME).isMalformed()).isTrue(); + } + + @Test + void validatedMcpNameIsForwardedToTheFeature() throws Exception { + HttpRequest request = post("tools/call", Map.of("_meta", meta(), "name", "echo")).header("Mcp-Name", "echo") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(receivedHeaders().get(McpHttp.Headers.MCP_NAME)).isEqualTo(McpHeader.of("Mcp-Name", "echo")); + } + + @Test + void contextExtractorCannotReplaceTheForwardedHeaders() throws Exception { + 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(receivedHeaders()).isNotSameAs(McpHeaders.NOT_MIRRORED); + assertThat(receivedHeaders().get(McpHttp.Headers.MCP_NAME).value()).isEqualTo("task-123"); + } + + private static McpHeaders receivedHeaders() { + assertThat(RECEIVED_HEADERS.get()).isInstanceOf(McpHeaders.class); + return (McpHeaders) RECEIVED_HEADERS.get(); + } + private static HttpRequest.Builder callRegion(String region) throws IOException { return post("tools/call", Map.of("_meta", meta(), "name", "region", "arguments", Map.of("region", region))) .header("Mcp-Name", "region"); 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 4e8a23862..3f306d7ed 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,7 +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.McpParamHeaders; +import io.modelcontextprotocol.modern.server.McpHeaders; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpServer; @@ -590,10 +590,8 @@ public Mono handleNotification(McpTransportContext transportContext, send("tools/call", 1, Map.of("_meta", meta(), "name", "region")); assertThatJson(readLineWithTimeout()).isEqualTo(emptyResult(1)); - assertThat(requestContext.get().get(McpParamHeaders.TRANSPORT_CONTEXT_KEY)) - .isSameAs(McpParamHeaders.NOT_MIRRORED); - assertThat(notificationContext.get().get(McpParamHeaders.TRANSPORT_CONTEXT_KEY)) - .isSameAs(McpParamHeaders.NOT_MIRRORED); + assertThat(requestContext.get().get(McpHeaders.TRANSPORT_CONTEXT_KEY)).isSameAs(McpHeaders.NOT_MIRRORED); + assertThat(notificationContext.get().get(McpHeaders.TRANSPORT_CONTEXT_KEY)).isSameAs(McpHeaders.NOT_MIRRORED); } @Test