From da99f3e58319abd2cf78ce07b527282a1bf8bbfd Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Sun, 25 Oct 2026 17:38:10 +0100 Subject: [PATCH] Validate SEP-2243 Mcp-Param headers in the modern server - Tools can mark inputSchema arguments with x-mcp-header, so clients mirror them into Mcp-Param-{Name} headers. Servers MUST reject a request whose headers and body disagree, with 400 / -32020. - Since only tools/call use those header, they are not part of the McpRequestHandler interface, and rather are carried through the McpTransportContext. - The HTTP transport extracts and decodes Mcp-Param-* headers and puts them in the context. STDIO puts a marker "not mirrored" value instead. - Validation happens in ToolsFeature, where we have both the tool definition (including x-mcp-header annotations in the schema) and the values for the tool call. - McpHeaderDecoder and McpParamHeaders are public, so other HTTP transports can reuse them. - Mcp-Name now uses the same strict decoder. Signed-off-by: Daniel Garnier-Moiroux --- conformance-tests/VALIDATION_RESULTS.md | 24 +- .../server-servlet-modern/README.md | 5 +- .../modern/ModernConformanceServlet.java | 19 + .../modern/ToolParamHeaderAnnotations.java | 275 ++++++ .../modern/server/McpParamHeaders.java | 154 ++++ .../modern/server/McpRequestManager.java | 4 + .../server/feature/ParamHeaderValidator.java | 162 ++++ .../modern/server/feature/ToolsFeature.java | 45 +- .../transport/HttpServletMcpTransport.java | 26 +- .../server/transport/McpHeaderDecoder.java | 135 +++ .../server/transport/StdioMcpTransport.java | 10 +- .../modern/server/McpParamHeadersTests.java | 62 ++ .../transport/McpHeaderDecoderTests.java | 202 +++++ .../ToolParamHeaderAnnotationsTests.java | 842 ++++++++++++++++++ .../modern/server/ModernTestFixtures.java | 11 +- .../ToolsFeatureParamHeadersTests.java | 662 ++++++++++++++ ...tpServletMcpTransportIntegrationTests.java | 164 ++++ .../transport/StdioMcpTransportTests.java | 84 ++ 18 files changed, 2841 insertions(+), 45 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpParamHeaders.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpParamHeadersTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotationsTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index e4cbda9b2..21138fe2f 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -40,9 +40,8 @@ These run but don't count: the frozen requirement set marks extensions and anyth in the anchor release (`0.2.0-alpha.10`) as `not_scored`. Pending scenarios are still spec requirements. - **Passing:** `json-schema-2020-12` (8/8 checks, including the SEP-2106 `allOf`/`anyOf`, - `if`/`then`/`else` and `$anchor` checks), `http-header-validation` (14/14) -- **Failing — `http-custom-header-server-validation` (SEP-2243 custom headers):** not implemented, - see [Known Limitations](#known-limitations) + `if`/`then`/`else` and `$anchor` checks), `http-header-validation` (14/14), + `http-custom-header-server-validation` (10/10, SEP-2243 custom headers, against the `test_x_mcp_header` tool) - **Failing — tasks extension (`tasks-*`, SEP-2663):** not implemented ## Server Test Results @@ -97,25 +96,6 @@ of the 0.2.0-alpha auth suite. 1. **Client SSE Retry:** client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send the `Last-Event-ID` header -2. **Modern server: SEP-2243 custom headers (`Mcp-Param-{Name}`) are not supported.** SEP-2243 is part - of 2026-07-28 and the server-side requirements are MUSTs; only the scenario's pending status keeps - it out of the score. Today the five custom-header checks report "not testable" because no tool - carries `x-mcp-header`. Adding such a tool would turn them into real failures, because nothing - validates the headers yet. Supporting it needs SDK work: - - **Tool definitions:** `Tool.inputSchema` is a free-form map, so `x-mcp-header` can already be - written, but nothing enforces the definition rules: value non-empty, ASCII without space or `:`, - case-insensitively unique per tool, only on `integer`/`string`/`boolean` parameters (not `number`). - - **Request validation on `tools/call`:** for each designated parameter, Base64-decode - `=?base64?…?=` values, check the header matches the body value (integers as decimal strings, - booleans as `true`/`false`), reject headers with invalid characters, don't expect a header when - the value is null or omitted, and reject a missing required parameter. Failures are answered with - `400` and `-32020`. - - **Where it lives:** the check needs the called tool's `inputSchema`, which - `HttpServletMcpTransport` doesn't have. `ToolsFeature` already looks the `Tool` up through - `McpSyncToolRepository#find` / `McpAsyncToolRepository#find` and validates the arguments against - `inputSchema` before calling the tool, so the header check fits there. What's missing is a way to - get the raw `Mcp-Param-*` headers from the transport to the feature (e.g. through the - `McpTransportContext` on `McpRequestContext`). ## Running Tests diff --git a/conformance-tests/server-servlet-modern/README.md b/conformance-tests/server-servlet-modern/README.md index 2279a9714..a9711023e 100644 --- a/conformance-tests/server-servlet-modern/README.md +++ b/conformance-tests/server-servlet-modern/README.md @@ -29,6 +29,5 @@ Besides the standard `test_*` tools, resources and prompts, the server exposes t `test_trigger_tool_change`, `test_trigger_prompt_change` - **SEP-2322 MRTR:** `test_input_required_result_*` tools and the `test_input_required_result_prompt` prompt - **SEP-1613 / SEP-2106:** `json_schema_2020_12_tool` - -SEP-2243 custom headers (`x-mcp-header` / `Mcp-Param-{Name}`) are not supported yet; see -[Known Limitations](../VALIDATION_RESULTS.md#known-limitations). +- **SEP-2243 custom headers:** `test_x_mcp_header`, whose `region` argument is mirrored into `Mcp-Param-Region`. + The scenario exercises the first tool carrying `x-mcp-header`, so it must stay the only annotated tool. diff --git a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java index b5997834e..c230d590a 100644 --- a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java +++ b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java @@ -116,6 +116,16 @@ public class ModernConformanceServlet { } """; + private static final String X_MCP_HEADER_INPUT = """ + { + "type": "object", + "properties": { + "region": { "type": "string", "description": "mirrored into Mcp-Param-Region", "x-mcp-header": "Region" }, + "level": { "type": "integer", "description": "non-mirrored argument" } + } + } + """; + private static final Pattern TEMPLATE_URI = Pattern.compile("test://template/(.+)/data"); private static final McpJsonMapper JSON = McpJsonDefaults.getMapper(); @@ -317,6 +327,15 @@ private static final class ConformanceTools implements McpSyncToolRepository { .description("Tool with JSON Schema 2020-12 features (SEP-1613, SEP-2106)") .build(), respond((ctx, req) -> text("ok"))); + // SEP-2243 custom headers: the scenario exercises the first tool with an + // x-mcp-header annotation, so this must stay the only annotated tool. + add(Tool.builder("test_x_mcp_header", JSON, X_MCP_HEADER_INPUT) + .description("Tests SEP-2243 Mcp-Param-* server-side validation") + .build(), respond((ctx, req) -> { + Object region = req.arguments() != null ? req.arguments().get("region") : null; + return text("region=" + (region != null ? region : "")); + })); + // SEP-2575 diagnostic tools add("test_missing_capability", "Test tool requiring sampling", (ctx, req) -> { if (!ctx.clientCapabilities().supportsSampling()) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java new file mode 100644 index 000000000..537520998 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotations.java @@ -0,0 +1,275 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +import io.modelcontextprotocol.modern.McpSchema.Tool; + +/** + * Reads the {@code x-mcp-header} annotations of a tool's {@code inputSchema}, which ask + * clients to mirror an argument into an {@code Mcp-Param-{Name}} header. + * + * @author Daniel Garnier-Moiroux + * @see SEP-2243: + * HTTP Standardization + */ +public final class ToolParamHeaderAnnotations { + + /** + * The JSON Schema keyword that annotates an argument with its header name. + */ + public static final String KEYWORD = "x-mcp-header"; + + // RFC 9110 token: 1*tchar + private static final Pattern TOKEN = Pattern.compile("[!#$%&'*+\\-.^_`|~0-9A-Za-z]+"); + + private final List valid; + + private final List invalid; + + private ToolParamHeaderAnnotations(List valid, List invalid) { + this.valid = List.copyOf(valid); + this.invalid = List.copyOf(invalid); + } + + /** + * An argument mirrored into a header. + * + * @param name the {@code {Name}} of {@code Mcp-Param-{Name}}, as declared + * @param path the {@code properties} keys leading from the schema root to the + * argument + * @param type the declared type of the argument + */ + public record Annotation(String name, List path, Type type) { + + public Annotation { + path = List.copyOf(path); + } + + /** + * The argument's path joined with dots, for messages. + * @return the dotted path, such as {@code location.region} + */ + public String argument() { + return String.join(".", this.path); + } + + } + + /** + * An annotation that can't be enforced, and the reasons why. + * + * @param name the value of the {@code x-mcp-header} keyword, which may not be a + * string + * @param path the {@code properties} keys leading from the schema root to the + * argument + * @param problems why the annotation is invalid, in human-readable form, with + * characters outside printable ASCII written as Unicode escapes + */ + public record InvalidAnnotation(Object name, List path, List problems) { + + public InvalidAnnotation { + path = List.copyOf(path); + problems = List.copyOf(problems); + } + + /** + * The argument's path joined with dots, for messages. + * @return the dotted path, such as {@code location.region} + */ + public String argument() { + return String.join(".", this.path); + } + + /** + * Describes the annotation and its problems, with characters outside printable + * ASCII written as Unicode escapes, so the description can safely be logged. + * @return a description such as + * {@code 'Score' on argument 'score' (unsupported type number)} + */ + public String describe() { + return ToolParamHeaderAnnotations.describe(this.name, this.path) + " (" + String.join(", ", this.problems) + + ")"; + } + + } + + /** The argument types an annotation may declare. */ + public enum Type { + + STRING, INTEGER, BOOLEAN + + } + + /** + * Parses the {@code x-mcp-header} annotations of the tool's input schema. + *

+ * Only chains of {@code properties} keys are followed from the schema root, so + * annotations under {@code items}, {@code allOf}, {@code $ref} or any other keyword + * are ignored. An annotation is invalid if: + *

    + *
  • its name is not a string, is empty, or isn't an RFC 9110 token;
  • + *
  • its name equals another annotation's ignoring case, in which case both are + * invalid;
  • + *
  • its {@code type} is missing or isn't one of the strings {@code string}, + * {@code integer} or {@code boolean}; a type array, even {@code ["string", "null"]}, + * is invalid.
  • + *
+ * @param tool the tool + * @return the tool's valid and invalid annotations + */ + public static ToolParamHeaderAnnotations parse(Tool tool) { + List candidates = new ArrayList<>(); + collect(tool.inputSchema(), List.of(), candidates); + flagCollisions(candidates); + List valid = new ArrayList<>(); + List invalid = new ArrayList<>(); + for (Candidate candidate : candidates) { + if (candidate.problems().isEmpty()) { + valid.add(new Annotation((String) candidate.name(), candidate.path(), candidate.type())); + } + else { + invalid.add(new InvalidAnnotation(candidate.name(), candidate.path(), candidate.problems())); + } + } + return new ToolParamHeaderAnnotations(valid, invalid); + } + + /** + * The valid annotations, in schema order: depth-first, a parent before its children. + * @return the valid annotations, empty if there are none + */ + public List valid() { + return this.valid; + } + + /** + * The invalid annotations, in schema order: depth-first, a parent before its + * children. + * @return the invalid annotations, empty if there are none + */ + public List invalid() { + return this.invalid; + } + + /** + * Whether every annotation is valid. A compliant client drops a tool with invalid + * annotations from {@code tools/list}. + * @return {@code true} if there are no invalid annotations, including when the tool + * declares none + */ + public boolean isValid() { + return this.invalid.isEmpty(); + } + + // Depth-first in schema order, following only properties keys. + private static void collect(Map schema, List parent, List candidates) { + if (!(schema.get("properties") instanceof Map properties)) { + return; + } + for (Map.Entry entry : properties.entrySet()) { + if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof Map property)) { + continue; + } + List path = new ArrayList<>(parent); + path.add(key); + if (property.containsKey(KEYWORD)) { + candidates.add(candidate(List.copyOf(path), property)); + } + collect(property, path, candidates); + } + } + + private static Candidate candidate(List path, Map property) { + Object name = property.get(KEYWORD); + List problems = new ArrayList<>(); + if (!(name instanceof String string)) { + problems.add("name is not a string"); + } + else if (string.isEmpty()) { + problems.add("empty name"); + } + else if (!TOKEN.matcher(string).matches()) { + problems.add("name is not an RFC 9110 token"); + } + Object typeKeyword = property.get("type"); + Type type = type(typeKeyword); + if (type == null) { + problems.add(typeKeyword == null ? "missing type" : "unsupported type " + escape(typeKeyword)); + } + return new Candidate(name, path, type, problems); + } + + private static void flagCollisions(List candidates) { + Map> byName = new LinkedHashMap<>(); + for (Candidate candidate : candidates) { + if (candidate.name() instanceof String name && !name.isEmpty()) { + byName.computeIfAbsent(name.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(candidate); + } + } + for (List collision : byName.values()) { + if (collision.size() < 2) { + continue; + } + for (Candidate candidate : collision) { + List others = new ArrayList<>(); + for (Candidate other : collision) { + if (other != candidate) { + others.add(other.describe()); + } + } + candidate.problems().add("name collides ignoring case with " + String.join(" and ", others)); + } + } + } + + // Names come from user schemas: escape them so they can't break log lines. + private static String escape(Object value) { + String string = String.valueOf(value); + StringBuilder escaped = new StringBuilder(string.length()); + for (char c : string.toCharArray()) { + if (c >= 0x20 && c < 0x7F) { + escaped.append(c); + } + else { + escaped.append(String.format("\\u%04x", (int) c)); + } + } + return escaped.toString(); + } + + private record Candidate(Object name, List path, Type type, List problems) { + + String describe() { + return ToolParamHeaderAnnotations.describe(this.name, this.path); + } + + } + + private static String describe(Object name, List path) { + return "'" + escape(name) + "' on argument '" + escape(String.join(".", path)) + "'"; + } + + // null if the type can't be mirrored. The spec only admits a single primitive type, + // so type arrays such as ["string", "null"] are rejected, as the TypeScript SDK does. + private static Type type(Object type) { + if (!(type instanceof String name)) { + return null; + } + return switch (name) { + case "string" -> Type.STRING; + case "integer" -> Type.INTEGER; + case "boolean" -> Type.BOOLEAN; + default -> null; + }; + } + +} 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 new file mode 100644 index 000000000..240cf9804 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpParamHeaders.java @@ -0,0 +1,154 @@ +/* + * 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.util.Assert; +import reactor.util.annotation.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 = "Mcp-Param-"; + + /** + * 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 cf2b2952c..8c9375717 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 @@ -13,6 +13,10 @@ * The transport-facing entry point that answers each request with a * {@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. * * @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 new file mode 100644 index 000000000..1fbed134b --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ParamHeaderValidator.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Annotation; +import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Type; +import io.modelcontextprotocol.modern.server.McpParamHeaders; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Checks the {@code Mcp-Param-{Name}} headers of a {@code tools/call} against the + * arguments the tool annotates with {@code x-mcp-header}. + * + * @author Daniel Garnier-Moiroux + * @see SEP-2243: + * HTTP Standardization + */ +final class ParamHeaderValidator { + + private static final Logger logger = LoggerFactory.getLogger(ParamHeaderValidator.class); + + private static final Pattern DECIMAL = Pattern.compile("-?[0-9]+(\\.[0-9]+)?"); + + private static final Valid VALID = new Valid(); + + private ParamHeaderValidator() { + } + + /** + * Validates every annotation, in order, and reports the first failure. + *

+ * Headers that no annotation declares are ignored, even when malformed. Error + * messages name the header and the argument but never their values, which are logged + * at {@code DEBUG} instead. + * @param annotations the tool's annotations + * @param callToolArguments the arguments for tools/call, empty if the call has none + * @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) { + for (Annotation annotation : annotations) { + Object argument = resolve(callToolArguments, annotation.path()); + String problem = check(annotation, argument, headers); + if (problem != null) { + logger.debug("Rejecting tools/call: {} (header: {}, argument: {})", problem, + describe(headers, annotation.name()), argument); + return new Invalid(problem); + } + } + return VALID; + } + + // null if the header agrees with the argument. + private static String check(Annotation annotation, Object argument, McpParamHeaders headers) { + boolean present = headers.contains(annotation.name()); + String mismatch = header(annotation) + " header does not match argument '" + annotation.argument() + "'"; + if (argument == null) { + return present + ? header(annotation) + " header present but argument '" + annotation.argument() + "' is absent" + : null; + } + if (argument instanceof Map || argument instanceof List) { + // Can't be mirrored: a header is a mismatch, and without one the input + // schema validation reports the wrong type. + return present ? mismatch : null; + } + if (!present) { + return "Missing required header: " + header(annotation); + } + if (headers.isMalformed(annotation.name())) { + return header(annotation) + " header is malformed"; + } + return matches(annotation.type(), argument, headers.value(annotation.name())) ? null : mismatch; + } + + private static Object resolve(Map arguments, List path) { + Object value = arguments; + for (String key : path) { + if (!(value instanceof Map object)) { + return null; + } + value = object.get(key); + } + return value; + } + + // Integers are compared numerically, so 42, 42.0 and 4.2e1 are equal whatever form + // the client and the JSON parser used. + // + // The spec asks servers to reject integers beyond +/-(2^53-1). That rule exists + // because JavaScript can't represent larger integers exactly. Java's Long and + // BigInteger can, so large values are compared exactly instead; tool authors who + // want a bound can declare minimum and maximum. + private static boolean matches(Type type, Object argument, String header) { + if (type == Type.INTEGER && argument instanceof Number number) { + BigDecimal value = decimal(number); + return value != null && DECIMAL.matcher(header).matches() && value.compareTo(new BigDecimal(header)) == 0; + } + return String.valueOf(argument).equals(header); + } + + // null for NaN and infinities. + private static BigDecimal decimal(Number number) { + if (number instanceof BigDecimal decimal) { + return decimal; + } + if (number instanceof BigInteger integer) { + return new BigDecimal(integer); + } + if (number instanceof Double || number instanceof Float) { + double value = number.doubleValue(); + return Double.isFinite(value) ? BigDecimal.valueOf(value) : null; + } + return BigDecimal.valueOf(number.longValue()); + } + + private static String header(Annotation annotation) { + return McpParamHeaders.HEADER_PREFIX + annotation.name(); + } + + private static String describe(McpParamHeaders headers, String name) { + if (headers.isMalformed(name)) { + return ""; + } + String value = headers.value(name); + return (value != null) ? value : ""; + } + + /** + * The outcome of {@link #validate}. + */ + sealed interface Result { + + } + + /** + * Every annotated header agrees with its argument. + */ + record Valid() implements Result { + + } + + /** + * A header disagrees with its argument. + * + * @param problem names the header and the argument, never their values + */ + record Invalid(String problem) implements Result { + + } + +} 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 6af2d33a9..ea6776edb 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 @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; @@ -20,16 +21,22 @@ import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.ListToolsResult; import io.modelcontextprotocol.modern.McpSchema.PaginatedRequest; import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations; +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.McpRequestContext; -import io.modelcontextprotocol.modern.server.McpAsyncResponse; import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; /** @@ -39,6 +46,8 @@ */ public final class ToolsFeature implements McpFeature { + private static final Logger logger = LoggerFactory.getLogger(ToolsFeature.class); + private final McpAsyncToolRepository repository; private final McpJsonMapper jsonMapper; @@ -105,6 +114,9 @@ public Mono> handle(McpRequestConte private Mono> call(McpRequestContext ctx, Tool tool, CallToolRequest request) { Map arguments = request.arguments() != null ? request.arguments() : Map.of(); + // Before input-schema validation, so a mismatch is a protocol error rather than a + // tool execution error the model would try to fix. + validateParamHeaders(ctx, tool, arguments); ValidationResponse input = this.jsonSchemaValidator.validate(tool.inputSchema(), arguments); if (!input.valid()) { // A tool execution error, so the model can fix the call. @@ -116,6 +128,37 @@ private Mono> call(McpRequestContext ctx, Tool return this.repository.call(ctx, request).map(response -> checkOutput(tool, response)); } + private static void validateParamHeaders(McpRequestContext ctx, Tool tool, Map arguments) { + Object headers = ctx.transportContext().get(McpParamHeaders.TRANSPORT_CONTEXT_KEY); + if (headers == McpParamHeaders.NOT_MIRRORED) { + return; + } + // Parsed on every call, without caching, because repositories are user code and + // may return a different tool each time. + ToolParamHeaderAnnotations annotations = ToolParamHeaderAnnotations.parse(tool); + if (!annotations.isValid()) { + // Compliant clients drop the tool from tools/list: this is how its author + // finds out why. + logger.warn("Tool '{}' has invalid {} annotations, which are not enforced: {}", tool.name(), + ToolParamHeaderAnnotations.KEYWORD, + annotations.invalid().stream().map(InvalidAnnotation::describe).collect(Collectors.joining("; "))); + } + if (annotations.valid().isEmpty()) { + return; + } + // Failing rather than skipping, so a transport that forgets the headers can't + // silently disable validation. + if (!(headers instanceof McpParamHeaders paramHeaders)) { + logger.warn("Transport did not provide Mcp-Param headers, required to validate tool '{}'", tool.name()); + throw new McpException(ErrorCodes.INTERNAL_ERROR, "Internal error"); + } + ParamHeaderValidator.Result result = ParamHeaderValidator.validate(annotations.valid(), arguments, + paramHeaders); + if (result instanceof ParamHeaderValidator.Invalid invalid) { + throw new McpException(ErrorCodes.HEADER_MISMATCH, invalid.problem()); + } + } + private McpAsyncResponse checkOutput(Tool tool, McpAsyncResponse response) { if (response instanceof McpAsyncResponse.Result result) { return McpAsyncResponse.result(checkOutput(tool, result.result())); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java index ee0af4fbc..a0c3f6240 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java @@ -11,7 +11,6 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -29,6 +28,7 @@ import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.McpTransportResponse; @@ -240,6 +240,13 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + if (McpSchema.METHOD_TOOLS_CALL.equals(jsonRpcRequest.method())) { + // Added after the user's extractor ran, so this entry always wins. + var mcpParamHeaders = McpHeaderDecoder.paramHeaders(headerAccessor(request)); + transportContext = transportContext + .putAllMap(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, mcpParamHeaders)); + } + boolean listen = McpSchema.METHOD_SUBSCRIPTIONS_LISTEN.equals(jsonRpcRequest.method()); McpTransportResponse transportResponse = (listen ? this.requestManager.handle(transportContext, jsonRpcRequest) : this.requestManager.handleBlocking(transportContext, jsonRpcRequest)) @@ -425,9 +432,9 @@ private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRp if (nameHeader == null) { return "Missing required header: Mcp-Name"; } - String decoded = decodeMcpNameHeader(nameHeader); + String decoded = McpHeaderDecoder.decodeValue(nameHeader); if (decoded == null) { - return "Mcp-Name header has a malformed Base64 value"; + return "Mcp-Name header has a malformed value"; } if (!expectedName.equals(decoded)) { return "Mcp-Name header does not match request name/uri"; @@ -457,19 +464,6 @@ public List getHeaderNames() { }; } - private static String decodeMcpNameHeader(String value) { - if (value.startsWith("=?base64?") && value.endsWith("?=")) { - String base64 = value.substring("=?base64?".length(), value.length() - "?=".length()); - try { - return new String(Base64.getDecoder().decode(base64), StandardCharsets.UTF_8); - } - catch (IllegalArgumentException ex) { - return null; - } - } - return value; - } - private static String readBody(HttpServletRequest request, int maxSize) throws IOException, BodyTooLargeException { InputStream inputStream = request.getInputStream(); ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java new file mode 100644 index 000000000..818e78d06 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.server.transport.HeaderAccessor; +import reactor.util.annotation.Nullable; + +/** + * Strict decoder for MCP header values such as {@code Mcp-Name} and + * {@code Mcp-Param-{Name}}, shared by HTTP transports so they all accept and reject + * exactly the same values. + *

+ * A value wrapped in the lowercase {@code =?base64?} prefix and {@code ?=} suffix is + * Base64-encoded UTF-8. Any other value, including one that carries only the prefix or + * only the suffix, is plain. + * + * @author Daniel Garnier-Moiroux + */ +public final class McpHeaderDecoder { + + private static final String BASE64_PREFIX = "=?base64?"; + + private static final String BASE64_SUFFIX = "?="; + + private McpHeaderDecoder() { + } + + /** + * Decodes a single header value. + *

+ * A Base64 value must use the standard alphabet, with padding and zero trailing bits, + * and must decode to valid UTF-8. A plain value may contain only visible ASCII + * ({@code 0x21}-{@code 0x7E}), space and horizontal tab. + *

+ * The value must already have its leading and trailing whitespace removed, as RFC + * 9110 requires and servlet containers do. + * @param raw the header value as received + * @return the decoded value, or {@code null} if the value is malformed + */ + @Nullable + public static String decodeValue(String raw) { + if (raw.length() >= BASE64_PREFIX.length() + BASE64_SUFFIX.length() && raw.startsWith(BASE64_PREFIX) + && raw.endsWith(BASE64_SUFFIX)) { + String encoded = raw.substring(BASE64_PREFIX.length(), raw.length() - BASE64_SUFFIX.length()); + byte[] bytes; + try { + bytes = Base64.getDecoder().decode(encoded); + } + catch (IllegalArgumentException ex) { + return null; + } + // Java's decoder accepts missing padding and non-zero trailing bits; + // re-encoding exposes both. + if (!Base64.getEncoder().encodeToString(bytes).equals(encoded)) { + return null; + } + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } + catch (CharacterCodingException ex) { + return null; + } + } + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if ((c < 0x21 || c > 0x7E) && c != ' ' && c != '\t') { + return null; + } + } + return raw; + } + + /** + * Collects the {@code Mcp-Param-{Name}} headers of a request. + *

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

+ * As with {@link #decodeValue(String)}, the accessor must return values with their + * leading and trailing whitespace already removed. + * @param headers the request headers + * @return the decoded headers + */ + public static McpParamHeaders paramHeaders(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())) { + names.computeIfAbsent(name.toLowerCase(Locale.ROOT), k -> new LinkedHashSet<>()).add(name); + } + } + McpParamHeaders.Builder builder = McpParamHeaders.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); + } + else { + builder.malformed(paramName); + } + } + 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 284e2948c..e908992b0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java @@ -24,6 +24,7 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpTransportResponse; import io.modelcontextprotocol.util.Assert; @@ -50,6 +51,11 @@ public class StdioMcpTransport { private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; + // Stdio has no headers, so Mcp-Param-* headers are never mirrored and the tools + // feature skips their validation. + private static final McpTransportContext TRANSPORT_CONTEXT = McpTransportContext + .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)); + private final McpRequestManager requestManager; private final McpJsonMapper jsonMapper; @@ -226,7 +232,7 @@ private void handleLine(String line) { handleCancel(notification); } else { - this.requestManager.handleNotification(McpTransportContext.EMPTY, notification).subscribe(v -> { + this.requestManager.handleNotification(TRANSPORT_CONTEXT, notification).subscribe(v -> { }, err -> logger.warn("Failed to handle notification", err)); } return; @@ -272,7 +278,7 @@ private void dispatch(JSONRPCRequest request) { JSONRPCResponse internalError = JSONRPCResponse.error(request.id(), new JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, "Internal error")); - Flux flux = Mono.defer(() -> this.requestManager.handle(McpTransportContext.EMPTY, request)) + Flux flux = Mono.defer(() -> this.requestManager.handle(TRANSPORT_CONTEXT, request)) // Async handlers run on the subscribing thread; keep them off the reader so // it can always read the next request or cancellation. .subscribeOn(Schedulers.boundedElastic()) 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 new file mode 100644 index 000000000..cf97ab853 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/McpParamHeadersTests.java @@ -0,0 +1,62 @@ +/* + * 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 new file mode 100644 index 000000000..126753d5f --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java @@ -0,0 +1,202 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.transport; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.server.McpParamHeaders; +import io.modelcontextprotocol.server.transport.HeaderAccessor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpHeaderDecoderTests { + + @Test + void base64ValueIsDecoded() { + assertThat(McpHeaderDecoder.decodeValue("=?base64?SGVsbG8=?=")).isEqualTo("Hello"); + } + + @Test + void base64ValueIsDecodedAsUtf8() { + // "café 日本" in UTF-8 + assertThat(McpHeaderDecoder.decodeValue("=?base64?Y2Fmw6kg5pel5pys?=")).isEqualTo("café 日本"); + } + + @Test + void base64ValueWithoutPaddingIsMalformed() { + assertThat(McpHeaderDecoder.decodeValue("=?base64?SGVsbG8?=")).isNull(); + } + + @Test + void base64ValueWithNonZeroTrailingBitsIsMalformed() { + // "SGVsbG9=" decodes to "Hello" leniently, but the last 2 bits of '9' are set + assertThat(McpHeaderDecoder.decodeValue("=?base64?SGVsbG9=?=")).isNull(); + } + + @Test + void base64ValueWithNonAlphabetCharactersIsMalformed() { + assertThat(McpHeaderDecoder.decodeValue("=?base64?SGVs*bG8=?=")).isNull(); + // URL-safe alphabet is not the standard alphabet + assertThat(McpHeaderDecoder.decodeValue("=?base64?-_8=?=")).isNull(); + } + + @Test + void base64ValueWithInvalidUtf8IsMalformed() { + // 0xFF 0xFE is never valid UTF-8 + assertThat(McpHeaderDecoder.decodeValue("=?base64?//4=?=")).isNull(); + // 0xC3 alone is a truncated two-byte sequence + assertThat(McpHeaderDecoder.decodeValue("=?base64?ww==?=")).isNull(); + } + + @Test + void valueWithOnlyThePrefixIsPlain() { + assertThat(McpHeaderDecoder.decodeValue("=?base64?SGVsbG8=")).isEqualTo("=?base64?SGVsbG8="); + } + + @Test + void valueWithOnlyTheSuffixIsPlain() { + assertThat(McpHeaderDecoder.decodeValue("SGVsbG8=?=")).isEqualTo("SGVsbG8=?="); + } + + @Test + void valueWhereThePrefixAndSuffixOverlapIsPlain() { + assertThat(McpHeaderDecoder.decodeValue("=?base64?=")).isEqualTo("=?base64?="); + } + + @Test + void uppercasePrefixIsPlain() { + assertThat(McpHeaderDecoder.decodeValue("=?BASE64?SGVsbG8=?=")).isEqualTo("=?BASE64?SGVsbG8=?="); + } + + @Test + void plainValueWithVisibleAsciiSpaceAndTabIsReturnedAsIs() { + assertThat(McpHeaderDecoder.decodeValue("!~ a\tb")).isEqualTo("!~ a\tb"); + } + + @ParameterizedTest + @ValueSource(strings = { "café", "a\nb", "a\rb", "a\u0000b", "a\u007fb", "日本" }) + void plainValueWithCharactersOutsideVisibleAsciiSpaceAndTabIsMalformed(String raw) { + assertThat(McpHeaderDecoder.decodeValue(raw)).isNull(); + } + + @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(); + } + + @Test + void paramHeaderNamesAreLookedUpIgnoringCase() { + McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Region", "eu")); + + assertThat(headers.value("Region")).isEqualTo("eu"); + assertThat(headers.value("REGION")).isEqualTo("eu"); + assertThat(headers.contains("rEgIoN")).isTrue(); + } + + @Test + void paramHeaderValuesAreDecoded() { + McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Greeting", "=?base64?SGVsbG8=?=")); + + assertThat(headers.value("greeting")).isEqualTo("Hello"); + } + + @Test + void malformedParamHeaderValueIsMarkedMalformed() { + McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Greeting", "=?base64?SGVsbG8?=")); + + assertThat(headers.isMalformed("greeting")).isTrue(); + assertThat(headers.value("greeting")).isNull(); + } + + @Test + void paramHeaderWithSeveralValuesIsMarkedMalformed() { + McpParamHeaders headers = McpHeaderDecoder + .paramHeaders(headers("Mcp-Param-Region", "eu", "Mcp-Param-Region", "eu")); + + assertThat(headers.isMalformed("region")).isTrue(); + assertThat(headers.value("region")).isNull(); + } + + @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")); + + assertThat(headers.isMalformed("region")).isTrue(); + assertThat(headers.value("region")).isNull(); + } + + @Test + void paramHeadersDifferingOnlyInNameCaseAreMarkedMalformedWithACaseSensitiveAccessor() { + Map> values = new LinkedHashMap<>(); + values.put("Mcp-Param-Region", List.of("eu")); + values.put("mcp-param-region", List.of("us")); + HeaderAccessor caseSensitive = new HeaderAccessor() { + @Override + public List getHeader(String name) { + return values.getOrDefault(name, List.of()); + } + + @Override + public List getHeaderNames() { + return List.copyOf(values.keySet()); + } + }; + + McpParamHeaders headers = McpHeaderDecoder.paramHeaders(caseSensitive); + + assertThat(headers.isMalformed("region")).isTrue(); + assertThat(headers.value("region")).isNull(); + } + + @Test + void paramHeadersAreEmptyWithoutMcpParamHeaders() { + McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Name", "echo")); + + assertThat(headers.contains("name")).isFalse(); + } + + /** + * An accessor over the given name/value pairs that, unlike a servlet container, + * reports names differing only in case separately. + */ + private static HeaderAccessor headers(String... namesAndValues) { + Map> headers = new LinkedHashMap<>(); + for (int i = 0; i < namesAndValues.length; i += 2) { + headers.computeIfAbsent(namesAndValues[i], k -> new ArrayList<>()).add(namesAndValues[i + 1]); + } + return new HeaderAccessor() { + @Override + public List getHeader(String name) { + return headers.entrySet() + .stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .flatMap(entry -> entry.getValue().stream()) + .toList(); + } + + @Override + public List getHeaderNames() { + return List.copyOf(headers.keySet()); + } + }; + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotationsTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotationsTests.java new file mode 100644 index 000000000..b4c61c9aa --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/ToolParamHeaderAnnotationsTests.java @@ -0,0 +1,842 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Annotation; +import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.InvalidAnnotation; +import io.modelcontextprotocol.modern.ToolParamHeaderAnnotations.Type; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Parsing of the {@code x-mcp-header} annotations of a tool's {@code inputSchema}. + */ +class ToolParamHeaderAnnotationsTests { + + @ParameterizedTest + @ValueSource(strings = { "{}", "{ \"type\": \"object\" }", """ + { + "type": "object", + "properties": { + "region": { "type": "string" }, + "location": { + "type": "object", + "properties": { "zone": { "type": "string" } } + } + } + } + """ }) + void schemaWithoutAnnotationsHasNone(String schema) { + ToolParamHeaderAnnotations annotations = parse(schema); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()).isEmpty(); + assertThat(annotations.isValid()).isTrue(); + } + + @Test + void validAnnotationHasItsNamePathAndType() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """); + + assertThat(annotations.valid()).containsExactly(new Annotation("Region", List.of("region"), Type.STRING)); + assertThat(annotations.valid().get(0).argument()).isEqualTo("region"); + assertThat(annotations.invalid()).isEmpty(); + assertThat(annotations.isValid()).isTrue(); + } + + @Test + void invalidAnnotationHasItsNamePathAndProblems() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "score": { "type": "number", "x-mcp-header": "Score" } + } + } + """); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()) + .containsExactly(new InvalidAnnotation("Score", List.of("score"), List.of("unsupported type number"))); + assertThat(annotations.invalid().get(0).argument()).isEqualTo("score"); + assertThat(annotations.isValid()).isFalse(); + } + + @Test + void validAndInvalidAnnotationsAreSeparated() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "score": { "type": "number", "x-mcp-header": "Score" }, + "verbose": { "type": "boolean", "x-mcp-header": "Verbose" }, + "untyped": { "x-mcp-header": "Untyped" } + } + } + """); + + assertThat(annotations.valid()).extracting(Annotation::name).containsExactly("Region", "Verbose"); + assertThat(annotations.invalid()).extracting(InvalidAnnotation::name).containsExactly("Score", "Untyped"); + assertThat(annotations.isValid()).isFalse(); + } + + @Test + void resultIsImmutable() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "score": { "type": "number", "x-mcp-header": "Score" } + } + } + """); + + assertThatThrownBy(() -> annotations.valid().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> annotations.invalid().clear()).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> annotations.valid().get(0).path().clear()) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> annotations.invalid().get(0).path().clear()) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> annotations.invalid().get(0).problems().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void recordsCopyTheirLists() { + List path = new ArrayList<>(List.of("region")); + List problems = new ArrayList<>(List.of("missing type")); + Annotation annotation = new Annotation("Region", path, Type.STRING); + InvalidAnnotation invalid = new InvalidAnnotation("Region", path, problems); + + path.add("other"); + problems.add("other"); + + assertThat(annotation.path()).containsExactly("region"); + assertThat(invalid.path()).containsExactly("region"); + assertThat(invalid.problems()).containsExactly("missing type"); + } + + @Nested + class Paths { + + @Test + void nestedArgumentHasThePathOfItsPropertiesKeys() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "request": { + "type": "object", + "properties": { + "location": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "score": { "type": "number", "x-mcp-header": "Score" } + } + } + } + } + } + } + """); + + assertThat(annotations.valid()).singleElement().satisfies(annotation -> { + assertThat(annotation.path()).containsExactly("request", "location", "region"); + assertThat(annotation.argument()).isEqualTo("request.location.region"); + }); + assertThat(annotations.invalid()).singleElement().satisfies(invalid -> { + assertThat(invalid.path()).containsExactly("request", "location", "score"); + assertThat(invalid.argument()).isEqualTo("request.location.score"); + }); + } + + @Test + void annotationsAreInSchemaOrderDepthFirst() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "tenant": { "type": "string", "x-mcp-header": "Tenant" }, + "count": { "type": "number", "x-mcp-header": "Count" }, + "location": { + "type": "object", + "properties": { + "zone": { "type": "string", "x-mcp-header": "Zone" }, + "score": { "type": "number", "x-mcp-header": "Score" }, + "region": { "type": "string", "x-mcp-header": "Region" } + } + }, + "verbose": { "type": "boolean", "x-mcp-header": "Verbose" }, + "ratio": { "type": "number", "x-mcp-header": "Ratio" } + } + } + """); + + assertThat(annotations.valid()).extracting(Annotation::argument) + .containsExactly("tenant", "location.zone", "location.region", "verbose"); + assertThat(annotations.invalid()).extracting(InvalidAnnotation::argument) + .containsExactly("count", "location.score", "ratio"); + } + + @Test + void keysContainingDotsAreNotSplit() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "location.region": { "type": "string", "x-mcp-header": "Region" } + } + } + """); + + assertThat(annotations.valid()).singleElement().satisfies(annotation -> { + assertThat(annotation.path()).containsExactly("location.region"); + assertThat(annotation.argument()).isEqualTo("location.region"); + }); + } + + @Test + void argumentNamedPropertiesIsFollowed() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + } + } + """); + + assertThat(annotations.valid()).extracting(Annotation::path) + .containsExactly(List.of("properties", "region")); + } + + @Test + void childrenOfAnAnnotatedObjectAreStillParsed() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "location": { + "type": "object", + "x-mcp-header": "Location", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + } + } + """); + + assertThat(annotations.valid()) + .containsExactly(new Annotation("Region", List.of("location", "region"), Type.STRING)); + assertThat(annotations.invalid()).containsExactly( + new InvalidAnnotation("Location", List.of("location"), List.of("unsupported type object"))); + } + + @ParameterizedTest + @ValueSource(strings = { """ + { "type": "string", "x-mcp-header": "Root" } + """, """ + { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { "type": "string", "x-mcp-header": "Region" } + } + } + } + """, """ + { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "object", + "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } + } + } + } + } + """, """ + { + "type": "object", + "properties": { + "pair": { + "type": "array", + "prefixItems": [{ "type": "string", "x-mcp-header": "Region" }] + } + } + } + """, """ + { + "type": "object", + "allOf": [ + { "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } + ] + } + """, """ + { + "type": "object", + "anyOf": [ + { "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } + ] + } + """, """ + { + "type": "object", + "oneOf": [ + { "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } + ] + } + """, """ + { + "type": "object", + "if": { "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } }, + "then": { "properties": { "zone": { "type": "string", "x-mcp-header": "Zone" } } }, + "else": { "properties": { "tenant": { "type": "string", "x-mcp-header": "Tenant" } } } + } + """, """ + { + "type": "object", + "properties": { + "region": { "$ref": "#/$defs/region" } + }, + "$defs": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """, """ + { + "type": "object", + "additionalProperties": { "type": "string", "x-mcp-header": "Region" } + } + """, """ + { + "type": "object", + "patternProperties": { + "^region$": { "type": "string", "x-mcp-header": "Region" } + } + } + """, """ + { + "type": "object", + "dependentSchemas": { + "zone": { "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } + } + } + """, """ + { + "type": "object", + "properties": { + "location": { + "type": "object", + "additionalProperties": { "type": "number", "x-mcp-header": "Score" } + } + } + } + """ }) + void annotationsOutsidePropertiesChainsAreIgnored(String schema) { + ToolParamHeaderAnnotations annotations = parse(schema); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()).isEmpty(); + } + + @Test + void propertyThatIsNotAnObjectIsSkipped() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "anything": true, + "nothing": false, + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """); + + assertThat(annotations.valid()).extracting(Annotation::argument).containsExactly("region"); + assertThat(annotations.invalid()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { "[]", "true", "\"region\"", "null" }) + void propertiesThatIsNotAnObjectIsSkipped(String properties) { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "location": { "type": "object", "properties": %s }, + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """.formatted(properties)); + + assertThat(annotations.valid()).extracting(Annotation::argument).containsExactly("region"); + assertThat(annotations.invalid()).isEmpty(); + } + + @Test + void propertyWithANonStringKeyIsSkipped() { + Map properties = new LinkedHashMap<>(); + properties.put(1, Map.of("type", "string", "x-mcp-header", "One")); + properties.put("region", Map.of("type", "string", "x-mcp-header", "Region")); + + ToolParamHeaderAnnotations annotations = parse(Map.of("type", "object", "properties", properties)); + + assertThat(annotations.valid()).extracting(Annotation::argument).containsExactly("region"); + assertThat(annotations.invalid()).isEmpty(); + } + + } + + @Nested + class Types { + + static Stream supportedTypes() { + return Stream.of(arguments("\"string\"", Type.STRING), arguments("\"integer\"", Type.INTEGER), + arguments("\"boolean\"", Type.BOOLEAN)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("supportedTypes") + void supportedTypeIsParsed(String type, Type expected) { + ToolParamHeaderAnnotations annotations = parse( + withArgument("{ \"type\": %s, \"x-mcp-header\": \"Arg\" }".formatted(type))); + + assertThat(annotations.valid()).containsExactly(new Annotation("Arg", List.of("arg"), expected)); + assertThat(annotations.invalid()).isEmpty(); + } + + static Stream unsupportedTypes() { + return Stream.of(arguments("\"number\"", "unsupported type number"), + arguments("\"object\"", "unsupported type object"), + arguments("\"array\"", "unsupported type array"), arguments("\"null\"", "unsupported type null"), + arguments("\"String\"", "unsupported type String"), + arguments("\"str\\u0007\"", "unsupported type str\\u0007"), arguments("42", "unsupported type 42"), + arguments("true", "unsupported type true"), arguments("{}", "unsupported type {}"), + arguments("[]", "unsupported type []"), arguments("[\"string\"]", "unsupported type [string]"), + arguments("[\"null\"]", "unsupported type [null]"), + arguments("[\"string\", \"null\"]", "unsupported type [string, null]"), + arguments("[\"null\", \"string\"]", "unsupported type [null, string]"), + arguments("[\"integer\", \"null\"]", "unsupported type [integer, null]"), + arguments("[\"boolean\", \"null\"]", "unsupported type [boolean, null]"), + arguments("[\"null\", \"null\"]", "unsupported type [null, null]"), + arguments("[\"null\", 42]", "unsupported type [null, 42]"), + arguments("[\"null\", \"number\"]", "unsupported type [null, number]"), + arguments("[\"string\", \"integer\"]", "unsupported type [string, integer]"), + arguments("[\"string\", \"string\"]", "unsupported type [string, string]"), + arguments("[\"string\", \"integer\", \"null\"]", "unsupported type [string, integer, null]"), + arguments("null", "missing type")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("unsupportedTypes") + void unsupportedTypeIsInvalid(String type, String problem) { + ToolParamHeaderAnnotations annotations = parse( + withArgument("{ \"type\": %s, \"x-mcp-header\": \"Arg\" }".formatted(type))); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()) + .containsExactly(new InvalidAnnotation("Arg", List.of("arg"), List.of(problem))); + } + + @Test + void missingTypeIsInvalid() { + ToolParamHeaderAnnotations annotations = parse(withArgument("{ \"x-mcp-header\": \"Arg\" }")); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()) + .containsExactly(new InvalidAnnotation("Arg", List.of("arg"), List.of("missing type"))); + } + + } + + @Nested + class Names { + + // RFC 9110 tchar, minus ALPHA and DIGIT + private static final String TCHAR_SYMBOLS = "!#$%&'*+-.^_`|~"; + + // RFC 9110 delimiters, which a token can't contain + private static final String DELIMITERS = "\"(),/:;<=>?@[\\]{}"; + + static Stream validNames() { + Stream symbols = TCHAR_SYMBOLS.chars().mapToObj(c -> "A" + (char) c + "z"); + return Stream.concat(Stream.of("Region", "region", "REGION", "X", "7", "Tenant-Id", TCHAR_SYMBOLS, + "AZaz09" + TCHAR_SYMBOLS), symbols); + } + + @ParameterizedTest + @MethodSource("validNames") + void validNameIsKeptAsDeclared(String name) { + ToolParamHeaderAnnotations annotations = parse(property(name, "string")); + + assertThat(annotations.valid()).containsExactly(new Annotation(name, List.of("arg"), Type.STRING)); + assertThat(annotations.invalid()).isEmpty(); + } + + static Stream invalidNames() { + Stream delimiters = DELIMITERS.chars() + .mapToObj(c -> arguments("Bad" + (char) c + "Name", "name is not an RFC 9110 token")); + return Stream.concat(Stream.of(arguments("", "empty name"), arguments(" ", "name is not an RFC 9110 token"), + arguments("Bad Name", "name is not an RFC 9110 token"), + arguments(" Region", "name is not an RFC 9110 token"), + arguments("Region ", "name is not an RFC 9110 token"), + arguments("Bad\tName", "name is not an RFC 9110 token"), + arguments("Bad\nName", "name is not an RFC 9110 token"), + arguments("Bad\u0001Name", "name is not an RFC 9110 token"), + arguments("Bad\u007fName", "name is not an RFC 9110 token"), + arguments("Café", "name is not an RFC 9110 token"), + arguments("😀", "name is not an RFC 9110 token"), arguments(42, "name is not a string"), + arguments(true, "name is not a string"), arguments(null, "name is not a string"), + arguments(List.of("Region"), "name is not a string"), + arguments(Map.of("name", "Region"), "name is not a string")), delimiters); + } + + @ParameterizedTest + @MethodSource("invalidNames") + void invalidNameIsInvalid(Object name, String problem) { + ToolParamHeaderAnnotations annotations = parse(property(name, "string")); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()) + .containsExactly(new InvalidAnnotation(name, List.of("arg"), List.of(problem))); + } + + @Test + void everyProblemOfAnAnnotationIsListed() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "untyped": { "x-mcp-header": 42 }, + "score": { "type": "number", "x-mcp-header": "" }, + "spaced": { "type": "array", "x-mcp-header": "Bad Name" } + } + } + """); + + assertThat(annotations.invalid()).containsExactly( + new InvalidAnnotation(42, List.of("untyped"), List.of("name is not a string", "missing type")), + new InvalidAnnotation("", List.of("score"), List.of("empty name", "unsupported type number")), + new InvalidAnnotation("Bad Name", List.of("spaced"), + List.of("name is not an RFC 9110 token", "unsupported type array"))); + } + + } + + @Nested + class Collisions { + + @Test + void bothMembersOfACaseInsensitiveCollisionAreInvalid() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "location": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "region" } + } + }, + "tenant": { "type": "string", "x-mcp-header": "Tenant" } + } + } + """); + + assertThat(annotations.valid()).extracting(Annotation::name).containsExactly("Tenant"); + assertThat(annotations.invalid()).containsExactly( + new InvalidAnnotation("Region", List.of("region"), + List.of("name collides ignoring case with 'region' on argument 'location.region'")), + new InvalidAnnotation("region", List.of("location", "region"), + List.of("name collides ignoring case with 'Region' on argument 'region'"))); + } + + @Test + void identicalNamesCollide() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "zone": { "type": "string", "x-mcp-header": "Region" } + } + } + """); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()).containsExactly( + new InvalidAnnotation("Region", List.of("region"), + List.of("name collides ignoring case with 'Region' on argument 'zone'")), + new InvalidAnnotation("Region", List.of("zone"), + List.of("name collides ignoring case with 'Region' on argument 'region'"))); + } + + @Test + void everyOtherMemberOfACollisionIsNamed() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": "Zone" }, + "b": { "type": "string", "x-mcp-header": "zone" }, + "c": { "type": "string", "x-mcp-header": "ZONE" } + } + } + """); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()).extracting(InvalidAnnotation::problems) + .containsExactly( + List.of("name collides ignoring case with 'zone' on argument 'b' and 'ZONE' on argument 'c'"), + List.of("name collides ignoring case with 'Zone' on argument 'a' and 'ZONE' on argument 'c'"), + List.of("name collides ignoring case with 'Zone' on argument 'a' and 'zone' on argument 'b'")); + } + + @Test + void separateCollisionsAreReportedSeparately() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": "Zone" }, + "b": { "type": "string", "x-mcp-header": "Region" }, + "c": { "type": "string", "x-mcp-header": "zone" }, + "d": { "type": "string", "x-mcp-header": "region" } + } + } + """); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()).extracting(InvalidAnnotation::problems) + .containsExactly(List.of("name collides ignoring case with 'zone' on argument 'c'"), + List.of("name collides ignoring case with 'region' on argument 'd'"), + List.of("name collides ignoring case with 'Zone' on argument 'a'"), + List.of("name collides ignoring case with 'Region' on argument 'b'")); + } + + @Test + void annotationWithAnUnsupportedTypeStillCollides() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "score": { "type": "number", "x-mcp-header": "region" } + } + } + """); + + assertThat(annotations.valid()).isEmpty(); + assertThat(annotations.invalid()).containsExactly( + new InvalidAnnotation("Region", List.of("region"), + List.of("name collides ignoring case with 'region' on argument 'score'")), + new InvalidAnnotation("region", List.of("score"), List.of("unsupported type number", + "name collides ignoring case with 'Region' on argument 'region'"))); + } + + @Test + void annotationWithANonTokenNameStillCollides() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": "Bad Name" }, + "b": { "type": "string", "x-mcp-header": "bad name" } + } + } + """); + + assertThat(annotations.invalid()).extracting(InvalidAnnotation::problems) + .containsExactly( + List.of("name is not an RFC 9110 token", + "name collides ignoring case with 'bad name' on argument 'b'"), + List.of("name is not an RFC 9110 token", + "name collides ignoring case with 'Bad Name' on argument 'a'")); + } + + @Test + void emptyNamesDoNotCollide() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": "" }, + "b": { "type": "string", "x-mcp-header": "" } + } + } + """); + + assertThat(annotations.invalid()).extracting(InvalidAnnotation::problems) + .containsExactly(List.of("empty name"), List.of("empty name")); + } + + @Test + void nonStringNamesDoNotCollide() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": 42 }, + "b": { "type": "string", "x-mcp-header": 42 } + } + } + """); + + assertThat(annotations.invalid()).extracting(InvalidAnnotation::problems) + .containsExactly(List.of("name is not a string"), List.of("name is not a string")); + } + + @Test + void stringNameDoesNotCollideWithTheSameNonStringName() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": "42" }, + "b": { "type": "string", "x-mcp-header": 42 } + } + } + """); + + assertThat(annotations.valid()).containsExactly(new Annotation("42", List.of("a"), Type.STRING)); + assertThat(annotations.invalid()) + .containsExactly(new InvalidAnnotation(42, List.of("b"), List.of("name is not a string"))); + } + + } + + @Nested + class Descriptions { + + @Test + void descriptionNamesTheAnnotationTheArgumentAndEveryProblem() { + InvalidAnnotation invalid = new InvalidAnnotation("Score", List.of("location", "score"), + List.of("empty name", "unsupported type number")); + + assertThat(invalid.describe()) + .isEqualTo("'Score' on argument 'location.score' (empty name, unsupported type number)"); + } + + @Test + void descriptionOfANonStringNameUsesItsValue() { + assertThat(new InvalidAnnotation(42, List.of("arg"), List.of("name is not a string")).describe()) + .isEqualTo("'42' on argument 'arg' (name is not a string)"); + assertThat(new InvalidAnnotation(null, List.of("arg"), List.of("name is not a string")).describe()) + .isEqualTo("'null' on argument 'arg' (name is not a string)"); + } + + @Test + void descriptionEscapesTheNameAndThePath() { + InvalidAnnotation invalid = new InvalidAnnotation("Bad\u0001Nameé", List.of("lieué", "r\ngion😀"), + List.of("name is not an RFC 9110 token")); + + assertThat(invalid.describe()).isEqualTo("'Bad\\u0001Name\\u00e9' on argument " + + "'lieu\\u00e9.r\\u000agion\\ud83d\\ude00' (name is not an RFC 9110 token)"); + } + + @Test + void parsedValuesAreKeptUnescaped() { + ToolParamHeaderAnnotations annotations = parse(Map.of("type", "object", "properties", + Map.of("région", Map.of("type", "string", "x-mcp-header", "Region"), "lieu", + Map.of("type", "string", "x-mcp-header", "Bad\u0001Name")))); + + assertThat(annotations.valid()).containsExactly(new Annotation("Region", List.of("région"), Type.STRING)); + assertThat(annotations.invalid()).singleElement() + .satisfies(invalid -> assertThat(invalid.name()).isEqualTo("Bad\u0001Name")); + } + + @Test + void problemsEscapeTheValuesTheyQuote() { + ToolParamHeaderAnnotations annotations = parse(""" + { + "type": "object", + "properties": { + "score": { "type": "num\\u00e9", "x-mcp-header": "Score" }, + "r\\u00e9gion": { "type": "string", "x-mcp-header": "Zone\\u0001" }, + "zone": { "type": "string", "x-mcp-header": "zone\\u0001" } + } + } + """); + + assertThat(annotations.invalid()).extracting(InvalidAnnotation::problems) + .containsExactly(List.of("unsupported type num\\u00e9"), + List.of("name is not an RFC 9110 token", + "name collides ignoring case with 'zone\\u0001' on argument 'zone'"), + List.of("name is not an RFC 9110 token", + "name collides ignoring case with 'Zone\\u0001' on argument 'r\\u00e9gion'")); + } + + } + + private static ToolParamHeaderAnnotations parse(String inputSchema) { + return ToolParamHeaderAnnotations + .parse(Tool.builder("forecast", McpJsonDefaults.getMapper(), inputSchema).build()); + } + + private static ToolParamHeaderAnnotations parse(Map inputSchema) { + return ToolParamHeaderAnnotations.parse(Tool.builder("forecast", inputSchema).build()); + } + + /** A schema whose single property, {@code arg}, has the given JSON schema. */ + private static String withArgument(String argument) { + return """ + { + "type": "object", + "properties": { + "arg": %s + } + } + """.formatted(argument); + } + + /** + * A schema whose single property, {@code arg}, is annotated with {@code name}, built + * in Java so names don't need JSON escaping. + */ + private static Map property(Object name, String type) { + Map property = new LinkedHashMap<>(); + property.put("type", type); + property.put(ToolParamHeaderAnnotations.KEYWORD, name); + return Map.of("type", "object", "properties", Map.of("arg", property)); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java index d4d8547ec..88000b728 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java @@ -61,7 +61,16 @@ public static Mono invoke(McpRequestManager manager, JSONR /** Answers the request without blocking and returns its non-streaming response. */ public static Mono respond(McpRequestManager manager, JSONRPCRequest request) { - return invoke(manager, request).map(response -> { + return respond(manager, McpTransportContext.EMPTY, request); + } + + /** + * Answers the request with the given transport context without blocking and returns + * its non-streaming response. + */ + public static Mono respond(McpRequestManager manager, McpTransportContext transportContext, + JSONRPCRequest request) { + return manager.handle(transportContext, request).map(response -> { if (response instanceof McpTransportResponse.Result result) { return result.response(); } 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 new file mode 100644 index 000000000..d2f513e2b --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java @@ -0,0 +1,662 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +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.McpRequestContext; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.ModernTestFixtures; +import io.modelcontextprotocol.util.ToolsUtils; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Validation of {@code Mcp-Param-{Name}} headers against the arguments a tool annotates + * with {@code x-mcp-header}, through the {@link McpServer} and tools feature. + */ +class ToolsFeatureParamHeadersTests { + + private static final String REGION_SCHEMA = """ + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """; + + private final AtomicBoolean called = new AtomicBoolean(); + + @Test + void matchingHeaderIsAccepted() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), + Map.of("region", "eu-west1")); + + assertAccepted(response); + } + + @Test + void missingHeaderIsRejected() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> { + }), Map.of("region", "eu-west1")); + + assertRejected(response, "Missing required header: Mcp-Param-Region"); + } + + @Test + void mismatchedHeaderIsRejected() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "us-east1")), + Map.of("region", "eu-west1")); + + assertRejected(response, "Mcp-Param-Region header does not match argument 'region'"); + } + + @Test + void comparisonIsCaseSensitive() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "EU-West1")), + Map.of("region", "eu-west1")); + + assertRejected(response, "Mcp-Param-Region header does not match argument 'region'"); + } + + @Test + void malformedHeaderIsRejected() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.malformed("Region")), + Map.of("region", "eu-west1")); + + assertRejected(response, "Mcp-Param-Region header is malformed"); + } + + @Test + void headerForAnAbsentArgumentIsRejected() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), Map.of()); + + assertRejected(response, "Mcp-Param-Region header present but argument 'region' is absent"); + } + + @Test + void headerForANullArgumentIsRejected() { + Map arguments = new HashMap<>(); + arguments.put("region", null); + + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("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()); + + assertRejected(response, "Mcp-Param-Region header present but argument 'region' is absent"); + } + + @Test + void headerForMissingArgumentsIsRejected() { + JSONRPCResponse response = respond(server(REGION_SCHEMA, PERMISSIVE_VALIDATOR), + headers(h -> h.value("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"); + } + + @Test + void absentArgumentWithoutHeaderIsAccepted() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> { + }), Map.of()); + + assertAccepted(response); + } + + @Test + void nullArgumentWithoutHeaderIsAccepted() { + Map arguments = new HashMap<>(); + arguments.put("region", null); + + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> { + }), arguments); + + assertAccepted(response); + } + + @Test + void undeclaredHeadersAreIgnored() { + JSONRPCResponse response = call(REGION_SCHEMA, + headers(h -> h.value("Region", "eu-west1").value("Tenant", "acme").malformed("Zone")), + Map.of("region", "eu-west1")); + + assertAccepted(response); + } + + @Test + void headerNameIsMatchedIgnoringCase() { + JSONRPCResponse response = call(REGION_SCHEMA, headers(h -> h.value("region", "eu-west1")), + Map.of("region", "eu-west1")); + + assertAccepted(response); + } + + @Test + void onlyTheFirstFailureInSchemaOrderIsReported() { + String schema = """ + { + "type": "object", + "properties": { + "zone": { "type": "string", "x-mcp-header": "Zone" }, + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """; + + JSONRPCResponse response = call(schema, headers(h -> h.value("Region", "us-east1").malformed("Zone")), + Map.of("region", "eu-west1", "zone", "a")); + + assertRejected(response, "Mcp-Param-Zone header is malformed"); + } + + @Test + void headersAreValidatedBeforeTheInputSchema() { + Tool tool = tool(REGION_SCHEMA); + JsonSchemaValidator rejectingInput = (schema, content) -> schema.equals(tool.inputSchema()) + ? 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"))); + + assertRejected(response, "Mcp-Param-Region header does not match argument 'region'"); + } + + @Test + void notMirroredSkipsValidation() { + McpTransportContext notMirrored = McpTransportContext + .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)); + + JSONRPCResponse response = call(REGION_SCHEMA, notMirrored, Map.of("region", "eu-west1")); + + assertAccepted(response); + } + + @Test + void missingParamHeadersAreAnInternalErrorForAnAnnotatedTool() { + JSONRPCResponse response = call(REGION_SCHEMA, McpTransportContext.EMPTY, Map.of("region", "eu-west1")); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).isEqualTo("Internal error"); + assertThat(response.error().data()).isNull(); + assertThat(this.called).isFalse(); + } + + @Test + void missingParamHeadersHaveNoEffectOnAToolWithoutAnnotations() { + JSONRPCResponse response = respond(server(tool(ToolsUtils.EMPTY_JSON_SCHEMA), PERMISSIVE_VALIDATOR), + McpTransportContext.EMPTY, callForecast(Map.of("region", "eu-west1"))); + + assertAccepted(response); + } + + @Test + void toolWithoutAnnotationsIgnoresParamHeaders() { + String schema = """ + { + "type": "object", + "properties": { + "region": { "type": "string" } + } + } + """; + + JSONRPCResponse response = call(schema, headers(h -> h.value("Region", "us-east1").malformed("Zone")), + Map.of("region", "eu-west1")); + + assertAccepted(response); + } + + @Nested + class IntegerAndBoolean { + + private static final String LEVEL_SCHEMA = """ + { + "type": "object", + "properties": { + "level": { "type": "integer", "x-mcp-header": "Level" } + } + } + """; + + private static final String VERBOSE_SCHEMA = """ + { + "type": "object", + "properties": { + "verbose": { "type": "boolean", "x-mcp-header": "Verbose" } + } + } + """; + + @Test + void integerHeaderMatchesAnIntegralDoubleArgument() { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("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)); + + assertAccepted(response); + } + + @Test + void exponentHeaderIsRejected() { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("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)); + + assertAccepted(response); + } + + @Test + void longBeyondTheSafeRangeMatchesItsExactHeader() { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "1152921504606846976")), + Map.of("level", 1L << 60)); + + assertAccepted(response); + } + + @Test + void longBeyondTheSafeRangeIsComparedExactly() { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "1152921504606846977")), + Map.of("level", 1L << 60)); + + assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); + } + + @Test + void bigIntegerMatchesItsExactHeader() { + JSONRPCResponse response = call(LEVEL_SCHEMA, + headers(h -> h.value("Level", "-123456789012345678901234567890")), + Map.of("level", new BigInteger("-123456789012345678901234567890"))); + + assertAccepted(response); + } + + @Test + void mismatchedIntegerHeaderIsRejected() { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("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")); + + assertAccepted(response); + } + + @Test + void integerAnnotationDoesNotCompareAStringArgumentNumerically() { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42")), + Map.of("level", "42.0")); + + assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); + } + + @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)); + + assertAccepted(response); + } + + @ParameterizedTest + @ValueSource(strings = { "True", "TRUE", "1" }) + void otherBooleanSpellingsAreRejected(String header) { + JSONRPCResponse response = call(VERBOSE_SCHEMA, headers(h -> h.value("Verbose", header)), + Map.of("verbose", true)); + + assertRejected(response, "Mcp-Param-Verbose header does not match argument 'verbose'"); + } + + @ParameterizedTest + @MethodSource("objectAndArrayArguments") + void objectOrArrayArgumentWithAHeaderIsRejected(Object argument) { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> h.value("Level", "42")), + Map.of("level", argument)); + + assertRejected(response, "Mcp-Param-Level header does not match argument 'level'"); + } + + @ParameterizedTest + @MethodSource("objectAndArrayArguments") + void objectOrArrayArgumentWithoutAHeaderIsAccepted(Object argument) { + JSONRPCResponse response = call(LEVEL_SCHEMA, headers(h -> { + }), Map.of("level", argument)); + + assertAccepted(response); + } + + static Stream objectAndArrayArguments() { + return Stream.of(Map.of("value", 42), List.of(42)); + } + + } + + @Nested + class NestedPaths { + + private static final String LOCATION_SCHEMA = """ + { + "type": "object", + "properties": { + "location": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + } + } + """; + + @Test + void nestedArgumentWithMatchingHeaderIsAccepted() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), + Map.of("location", Map.of("region", "eu-west1"))); + + assertAccepted(response); + } + + @Test + void nestedArgumentWithMismatchedHeaderIsRejectedWithADottedPath() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "us-east1")), + Map.of("location", Map.of("region", "eu-west1"))); + + assertRejected(response, "Mcp-Param-Region header does not match argument 'location.region'"); + } + + @Test + void nestedArgumentWithoutHeaderIsRejected() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> { + }), Map.of("location", Map.of("region", "eu-west1"))); + + assertRejected(response, "Missing required header: Mcp-Param-Region"); + } + + @Test + void deeplyNestedArgumentIsValidated() { + String schema = """ + { + "type": "object", + "properties": { + "request": { + "type": "object", + "properties": { + "location": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + } + } + } + } + """; + + JSONRPCResponse response = call(schema, headers(h -> h.value("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'"); + } + + @Test + void everyAnnotationIsValidated() { + String schema = """ + { + "type": "object", + "properties": { + "location": { + "type": "object", + "properties": { + "zone": { "type": "string", "x-mcp-header": "Zone" }, + "region": { "type": "string", "x-mcp-header": "Region" } + } + }, + "tenant": { "type": "string", "x-mcp-header": "Tenant" } + } + } + """; + + JSONRPCResponse response = call(schema, + headers(h -> h.value("Zone", "a").value("Region", "eu-west1").value("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'"); + } + + @Test + void missingIntermediateObjectWithoutHeaderIsAccepted() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> { + }), Map.of()); + + assertAccepted(response); + } + + @Test + void headerForAMissingIntermediateObjectIsRejected() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), Map.of()); + + assertRejected(response, "Mcp-Param-Region header present but argument 'location.region' is absent"); + } + + @Test + void headerForANullIntermediateObjectIsRejected() { + Map arguments = new HashMap<>(); + arguments.put("location", null); + + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("Region", "eu-west1")), arguments); + + assertRejected(response, "Mcp-Param-Region header present but argument 'location.region' is absent"); + } + + @Test + void nonObjectIntermediateValueWithoutHeaderIsAccepted() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> { + }), Map.of("location", "eu-west1")); + + assertAccepted(response); + } + + @Test + void headerForANonObjectIntermediateValueIsRejected() { + JSONRPCResponse response = call(LOCATION_SCHEMA, headers(h -> h.value("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"); + } + + } + + @Nested + class InvalidAnnotations { + + /** + * A schema with a valid {@code Region} annotation and an invalid annotation on + * {@code bad}. + */ + private static String withInvalid(String bad) { + return """ + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "bad": %s + } + } + """.formatted(bad); + } + + @Test + 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)); + ToolsFeatureParamHeadersTests.this.called.set(false); + assertAccepted(call(schema, headers(h -> h.value("Region", "eu-west1").value("Score", "2")), arguments)); + ToolsFeatureParamHeadersTests.this.called.set(false); + assertAccepted(call(schema, headers(h -> h.value("Region", "eu-west1").malformed("Score")), 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), + "Missing required header: Mcp-Param-Region"); + } + + @Test + void toolWithOnlyInvalidAnnotationsIsCalled() { + String schema = """ + { + "type": "object", + "properties": { + "score": { "type": "number", "x-mcp-header": "Score" }, + "untyped": { "x-mcp-header": "Untyped" } + } + } + """; + + assertAccepted(call(schema, headers(h -> h.value("Score", "2")), Map.of("score", 1))); + } + + @Test + @SuppressWarnings("unchecked") + void toolsListIsUnchanged() { + Tool tool = tool(withInvalid("{ \"type\": \"number\", \"x-mcp-header\": \"Score\" }")); + McpServer server = server(tool, PERMISSIVE_VALIDATOR); + + JSONRPCResponse response = respond(server, headers(h -> { + }), new JSONRPCRequest("tools/list", 1, Map.of("_meta", meta()))); + + assertThat(response.error()).isNull(); + List> tools = (List>) ((Map) response.result()) + .get("tools"); + assertThat(tools).singleElement().satisfies(listed -> { + assertThat(listed).containsEntry("name", "forecast"); + assertThat(listed).containsEntry("inputSchema", tool.inputSchema()); + }); + } + + } + + private JSONRPCResponse call(String inputSchema, McpTransportContext transportContext, + Map arguments) { + return respond(server(inputSchema, PERMISSIVE_VALIDATOR), transportContext, callForecast(arguments)); + } + + private static JSONRPCResponse respond(McpServer server, McpTransportContext transportContext, + JSONRPCRequest request) { + return ModernTestFixtures.respond(server, transportContext, request).block(); + } + + 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(); + headers.accept(builder); + return McpTransportContext.create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, builder.build())); + } + + private static Tool tool(String inputSchema) { + return Tool.builder("forecast", McpJsonDefaults.getMapper(), inputSchema).build(); + } + + private static Tool tool(Map inputSchema) { + return Tool.builder("forecast", inputSchema).build(); + } + + private McpServer server(String inputSchema, JsonSchemaValidator validator) { + return server(tool(inputSchema), validator); + } + + private McpServer server(Tool tool, JsonSchemaValidator validator) { + McpAsyncToolRepository repository = new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of(tool))); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.just(tool); + } + + @Override + public Mono> call(McpRequestContext ctx, CallToolRequest request) { + ToolsFeatureParamHeadersTests.this.called.set(true); + return Mono.just(McpAsyncResponse + .result(CallToolResult.builder().addContent(TextContent.builder("ok").build()).build())); + } + }; + return McpServer.builder() + .serverInfo(SERVER_INFO) + .feature(ToolsFeature.ofAsync(repository, McpJsonDefaults.getMapper(), validator, 0L, CacheScope.PRIVATE)) + .build(); + } + + private void assertAccepted(JSONRPCResponse response) { + assertThat(response.error()).isNull(); + assertThat(this.called).isTrue(); + } + + private void assertRejected(JSONRPCResponse response, String message) { + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(ErrorCodes.HEADER_MISMATCH); + assertThat(response.error().message()).isEqualTo(message); + assertThat(response.error().data()).isNull(); + assertThat(this.called).isFalse(); + } + +} 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 5d92df2a8..e4654ab43 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 @@ -5,6 +5,7 @@ package io.modelcontextprotocol.modern.server.transport; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; @@ -21,6 +22,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; @@ -36,6 +38,7 @@ 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.McpServer; import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; @@ -56,6 +59,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; class HttpServletMcpTransportIntegrationTests { @@ -70,6 +74,21 @@ class HttpServletMcpTransportIntegrationTests { private static final Tool ECHO_TOOL = Tool.builder("echo", ToolsUtils.EMPTY_JSON_SCHEMA).build(); + private static final Tool REGION_TOOL = Tool.builder("region", JSON_MAPPER, """ + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """).build(); + + /** + * A request carrying this header makes the context extractor claim headers aren't + * mirrored. + */ + private static final String FORGE_NOT_MIRRORED = "X-Forge-Not-Mirrored"; + private static final int QUOTA_EXCEEDED = -32000; private static final CountDownLatch LISTEN_CANCELLED = new CountDownLatch(1); @@ -88,6 +107,9 @@ public Mono list(McpRequestContext ctx, String cursor) { @Override public Mono find(McpRequestContext ctx, String name) { + if ("region".equals(name)) { + return Mono.just(REGION_TOOL); + } return "does-not-exist".equals(name) ? Mono.empty() : Mono.just(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build()); } @@ -96,6 +118,7 @@ public Mono find(McpRequestContext ctx, String name) { public Mono> call(McpRequestContext ctx, CallToolRequest request) { McpAsyncResponse response = switch (request.name()) { case "echo" -> McpAsyncResponse.result(text("echo:" + request.name())); + case "region" -> McpAsyncResponse.result(text("region:" + request.arguments().get("region"))); case "streamer" -> McpAsyncResponse .streaming(notifier -> notifier.progress(1.0, 1.0, "done").thenReturn(text("streamed"))); case "slow-streamer" -> McpAsyncResponse @@ -127,6 +150,10 @@ public Mono> call(McpRequestContext ctx, CallT .endpoint(ENDPOINT) .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.EMPTY) .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") .allowedHost("localhost:*") @@ -415,6 +442,29 @@ void malformedBase64McpNameIsRejected() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void base64McpNameIsAccepted() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + HttpRequest request = post("tools/call", params).header("Mcp-Name", "=?base64?ZWNobw==?=").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void unpaddedBase64McpNameIsRejected() throws Exception { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("name", "echo"); + HttpRequest request = post("tools/call", params).header("Mcp-Name", "=?base64?ZWNobw?=").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + @Test void mcpNameIsCheckedAgainstTheUriOfAResourceRead() throws Exception { Map params = new HashMap<>(); @@ -440,6 +490,120 @@ void missingMcpNameIsRejectedForNonAsciiName() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + 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"); + } + + @Test + void paramHeaderMismatchIsRejectedAsJson() throws Exception { + HttpRequest request = callRegion("eu-west1").header("Mcp-Param-Region", "us-east1").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).contains("application/json")); + assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.HEADER_MISMATCH); + assertThatJson(response.body()).inPath("$.error.message") + .isEqualTo("Mcp-Param-Region header does not match argument 'region'"); + } + + @Test + void missingParamHeaderIsRejected() throws Exception { + HttpRequest request = callRegion("eu-west1").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("Missing required header: Mcp-Param-Region"); + } + + @Test + void matchingBase64ParamHeaderIsAccepted() throws Exception { + HttpRequest request = callRegion("Hello").header("Mcp-Param-Region", "=?base64?SGVsbG8=?=").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThatJson(response.body()).inPath("$.result.content[0].text").isEqualTo("region:Hello"); + } + + @Test + void contextExtractorCannotSwitchOffParamHeaderValidation() throws Exception { + HttpRequest request = callRegion("eu-west1").header("Mcp-Param-Region", "us-east1") + .header(FORGE_NOT_MIRRORED, "true") + .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); + } + + @Test + void lowercaseParamHeaderNameIsAccepted() throws Exception { + HttpRequest request = callRegion("eu-west1").header("mcp-param-region", "eu-west1").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThatJson(response.body()).inPath("$.result.content[0].text").isEqualTo("region:eu-west1"); + } + + @Test + void whitespaceAroundPlainParamHeaderValueIsTrimmed() throws Exception { + HttpRequest request = callRegion("eu-west1").header("Mcp-Param-Region", " eu-west1\t ").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThatJson(response.body()).inPath("$.result.content[0].text").isEqualTo("region:eu-west1"); + } + + @Test + void duplicatedParamHeaderIsRejected() throws Exception { + HttpRequest request = callRegion("eu-west1").header("Mcp-Param-Region", "eu-west1") + .header("mcp-param-region", "eu-west1") + .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-Param-Region header is malformed"); + } + + @Test + void rawNonAsciiBytesInPlainParamHeaderValueAreRejected() throws Exception { + // HttpClient replaces non-ASCII header characters with '?', so the raw UTF-8 + // bytes of "café" are written to a socket instead. + Map params = Map.of("_meta", meta(), "name", "region", "arguments", Map.of("region", "café")); + byte[] body = JSON_MAPPER + .writeValueAsString(Map.of("jsonrpc", "2.0", "id", 1, "method", "tools/call", "params", params)) + .getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream request = new ByteArrayOutputStream(); + // HTTP/1.0, so the response is not chunked and ends when the server closes + request.write(("POST " + ENDPOINT + " HTTP/1.0\r\n" + "Host: localhost:" + PORT + "\r\n" + + "Content-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\n" + + "Mcp-Method: tools/call\r\n" + "Mcp-Name: region\r\n" + "MCP-Protocol-Version: " + + McpSchema.LATEST_PROTOCOL_VERSION + "\r\n" + "Content-Length: " + body.length + "\r\n" + + "Mcp-Param-Region: ") + .getBytes(StandardCharsets.US_ASCII)); + request.write("café".getBytes(StandardCharsets.UTF_8)); + request.write("\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + request.write(body); + + String response; + try (Socket socket = new Socket("localhost", PORT)) { + socket.setSoTimeout(5000); + socket.getOutputStream().write(request.toByteArray()); + socket.getOutputStream().flush(); + response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + String statusLine = response.substring(0, response.indexOf("\r\n")); + String responseBody = response.substring(response.indexOf("\r\n\r\n") + 4); + + assertThat(statusLine).contains(" 400 "); + assertThatJson(responseBody).inPath("$.error.code").isEqualTo(ErrorCodes.HEADER_MISMATCH); + assertThatJson(responseBody).inPath("$.error.message").isEqualTo("Mcp-Param-Region header is malformed"); + } + @Test void invalidJsonIsAnsweredWithJsonRpcParseError() throws Exception { HttpResponse response = HttpClient.newHttpClient() 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 b0cd9a490..15e287a5f 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 @@ -37,13 +37,23 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; +import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; 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.McpRequestContext; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncResponse; import io.modelcontextprotocol.modern.server.McpTransportResponse; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; +import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; import io.modelcontextprotocol.modern.server.feature.ServerChange; import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -521,6 +531,80 @@ void requestAfterCloseIsNotHandled() throws Exception { assertThat(handled).isEmpty(); } + @Test + void requestsAndNotificationsStateThatParamHeadersAreNotMirrored() throws Exception { + AtomicReference requestContext = new AtomicReference<>(); + AtomicReference notificationContext = new AtomicReference<>(); + start(new McpRequestManager() { + @Override + public Mono handleBlocking(McpTransportContext transportContext, + JSONRPCRequest request) { + throw new AssertionError("stdio must never handle requests for a blocking caller"); + } + + @Override + public Mono handle(McpTransportContext transportContext, JSONRPCRequest request) { + requestContext.set(transportContext); + return Mono.just(McpTransportResponse.result(JSONRPCResponse.result(request.id(), Map.of()))); + } + + @Override + public Mono handleNotification(McpTransportContext transportContext, + JSONRPCNotification notification) { + notificationContext.set(transportContext); + return Mono.empty(); + } + }); + + // Lines are read in order, so the notification is handled before the request. + send("notifications/roots/list_changed", null, Map.of()); + 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); + } + + @Test + void annotatedToolIsCalledWithoutParamHeaders() throws Exception { + Tool tool = Tool.builder("region", this.jsonMapper, """ + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """).build(); + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .jsonMapper(this.jsonMapper) + .feature(ToolsFeature.ofSync(new McpSyncToolRepository() { + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage.of(List.of(tool)); + } + + @Override + public Tool find(McpRequestContext ctx, String name) { + return tool; + } + + @Override + public McpSyncResponse call(McpRequestContext ctx, CallToolRequest request) { + return McpSyncResponse + .result(CallToolResult.builder().addContent(TextContent.builder("ok").build()).build()); + } + }, this.jsonMapper, PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) + .build(); + start(server); + + send("tools/call", 2, Map.of("_meta", meta(), "name", "region", "arguments", Map.of("region", "eu-west1"))); + + assertThatJson(readLineWithTimeout()).inPath("$.result.content[0].text").isEqualTo("ok"); + } + @Test void closeGracefullyCancelsListenStreams() throws Exception { AtomicBoolean publisherCancelled = new AtomicBoolean();