feat: x-mcp-header parameter mirroring — Mcp-Param validation (SEP-2243) - #86
Open
kalidke wants to merge 8 commits into
Open
feat: x-mcp-header parameter mirroring — Mcp-Param validation (SEP-2243)#86kalidke wants to merge 8 commits into
kalidke wants to merge 8 commits into
Conversation
The optional half of SEP-2243: a tool parameter may declare a header-mirroring suffix (ToolParameter(header = "Routing-Key"), emitted as x-mcp-header in the generated schema, or the same key in a raw input_schema), and modern HTTP clients mirror that argument as an Mcp-Param-<suffix> request header for transport-level routing. The server validates every mirror against the body before any execution path in handle_call_tool: strict =?base64?...?= sentinel decoding via the existing decode_mcp_header_value (invalid padding/characters rejected), literal comparison otherwise, and -32020 (mapped to HTTP 400 by the existing status layer) when the header is missing while its argument is in the body, duplicated, malformed, or mismatched. Headers for absent arguments and unrecognized Mcp-Param-* headers are ignored (forward compatibility); stdio (no headers) and legacy-era requests are untouched. Plumbing follows the per-request auth pattern exactly: the connection handler collects Mcp-Param-* headers (collect_param_headers — lowercased suffixes, OWS stripped, duplicates flagged :invalid) into the QueuedHttpRequest envelope (back-compat 3-arg constructor kept), the single server loop consumes them via pending_param_headers (base-transport default nothing), and they thread through process_message -> handle_request -> handle_modern_request -> serve_modern into RequestContext.param_headers — never via shared transport state. Fixture server gains test_header_param; the conformance suite's http-custom-header-server-validation scenario, previously unrunnable (no annotated tool), passes 10/10. All baselines intact: standard headers 14/14, modern-dated 40/40, stateless 30/30, legacy 59, tasks-* substantive all-pass. Suite 1872/1872 (27 new tests); param-header file 27/27 on Julia 1.11. With this, nothing on the 2026-07-28 spec's server side remains unimplemented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… skip; positional shim per Codex round 1 BLOCK 1: the mirror check ran inside handle_call_tool, AFTER the per-request log opt-in installed — with a debug logLevel, the 'request completed' notification preceded the -32020 and committed the response as an SSE 200. Validation is now a transport-level preflight in handle_modern_request, alongside the other pre-logger checks; the -32020 is the request's only message. (ctx.param_headers removed — the preflight uses the kwarg directly.) BLOCK 2: only top-level schema properties were scanned — a nested x-mcp-header annotation bypassed validation entirely. tool_header_params now recursively collects annotated property PATHS (object nesting at any depth; arrays cannot mirror per-element values, so items is not descended) and the validator resolves them through the arguments. BLOCK 3: string-form comparison falsely rejected valid JavaScript number mirrors (String(1e-7) is '1e-7'; Julia prints '1.0e-7'). Numbers now compare NUMERICALLY (tryparse the decoded header, ==); strings exact, booleans via true/false. BLOCK 4: an explicit JSON null required a header (haskey succeeded, value nothing, header missing → -32020) — per SEP-2243 clients omit the header for null and servers must not expect it. Null values now skip the mirror. BLOCK 5: adding the header field removed the released five-argument positional ToolParameter constructor — restored with a generic forwarding shim (the MCPPrompt lesson from PR #84). WARN 1: invalid annotations were advertised as-is. register! now refuses them: suffixes must be nonempty HTTP tokens, case-insensitively unique per tool (header names are case-insensitive — 'Route' and 'route' would collapse onto one mirrored header). WARN 2: violation messages embedded unbounded schema-derived names, and a >4096-byte -32020 envelope escaped the status-mapping sniff as HTTP 200. Names are now bounded (120 chars) in every violation message, keeping the envelope inside the mapping path. Suite 1890/1890 (18 new tests); param-header file 45/45 on Julia 1.11; conformance custom-headers 10/10, std-headers 14/14, modern 40/40, stateless 30/30 — all intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion validation per Codex round 2
BLOCK 1: converting every Real to Float64 collapsed distinct integers beyond
2^53 (body 9007199254740993 matched header ...92) and tryparse(Float64)
accepted hex ('0x10' matched body 16), defeating routing integrity. Integers
now compare EXACTLY: the mirror must be the integer digit string (JSON integer
grammar), compared via BigInt. Floats compare numerically but only through the
JSON number grammar — hex, Inf, NaN and other Julia-parseable exotica reject.
BLOCK 2: an error envelope whose echoed request id blew past the 4096-byte
sniff cap mapped to HTTP 200 (400 is a spec MUST for header violations). Large
payloads now get a structural sniff instead of an unconditional 200: the
needles ('"error":{"code":' not preceded by a top-level '"result":') are
un-spoofable from inside JSON string content — quotes in string values
serialize escaped — and key on the serializer's stable field order; embedded
error-shaped keys in a success payload's data stay 200.
WARN: registration validation had three gaps — 'Route\n' passed (PCRE $
matches before a trailing newline; now \A..\z), non-string annotation values
were skipped-but-advertised (now rejected), and annotations under an array's
items were ignored-but-advertised (now rejected as unsatisfiable — one header
cannot carry per-element values). The validator now walks the raw schema
including items.
NIT: two stale comments (handle_call_tool / RequestContext.param_headers)
updated to the preflight design.
Suite 1909/1909 (19 new tests); param-header file 64/64 on Julia 1.11;
conformance custom 10/10, std-headers 14/14, modern 40/40, stateless 30/30.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dex round 3 BLOCK 1: JSON3 normalizes numerically integral tokens (42.0, 1e3, -0.0) to Int64, so valid mirrors of those spellings hit the digit-string-only integer path and falsely rejected — while 1e3 vs header 1000 showed the asymmetry. Integer bodies now compare through _json_integer_value: an exact evaluation of the FULL JSON number grammar (decimal and exponent forms included) into BigInt — never through Float64, so distinct values beyond 2^53 stay distinct, hex still rejects, and a 1e999999 mirror is refused by the exponent cap instead of allocating a gigadigit BigInt. Values outside the JavaScript-safe integer range refuse to mirror entirely (spec rule): -32020 naming the range. BLOCK 2: registration now enforces the spec's annotatable-type rules — the annotated property must declare type string, integer, or boolean (number, objects, and arrays are excluded), on BOTH the ToolParameter and raw-schema paths — and the walker checks each node's own annotation with a reachability flag: annotations directly on items nodes, under composition branches (allOf/anyOf/oneOf/not), additionalProperties/patternProperties, prefixItems, or $defs/definitions are rejected as not statically reachable. The test tool's number-typed annotation is corrected to integer. Suite 1931/1931 (22 new tests); param-header file 86/86 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chability per Codex round 4 BLOCK 1: JSON3 parses integer tokens beyond Int64 as Float64, which slid past the Integer-only JavaScript-safe range check into the approximate float comparison — 9223372036854775808 and ...809 collapsed to the same Float64 and both 'matched'. The range check now covers integral floats too; such values refuse to mirror with the range-naming -32020. BLOCK 2: _json_integer_value parsed the exponent lexeme with parse(Int) BEFORE the ±64 cap — a 1e999999999999999999999999999999 mirror threw OverflowError (surfacing as -32603/HTTP 200), and 1e-9223372036854775808 threw through abs(typemin). Now tryparse (overflow → nothing → out-of-range) with explicit bounds instead of abs. BLOCK 3: the keyword blacklist could never enumerate every schema-valued path (if/then/else, contains, dependentSchemas, propertyNames, unevaluated*, older-draft keywords, future ones). Validation is now whitelist-by- construction: annotations reachable through a PURE properties chain are collected, then a generic deep scan over the whole schema finds every x-mcp-header occurrence — any occurrence outside the reachable set rejects, whatever keyword carried it. (Identity-compared; aliasing one subschema object into both a reachable and unreachable position is documented as out of scope.) Suite 1948/1948 (17 new tests incl. a someFutureKeyword probe); param-header file 103/103 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BLOCK 1: the generic deep scan mistook ordinary keys for annotations — a property NAMED x-mcp-header (its key lives in the properties name-map), or the key appearing inside instance data (default/const/examples), falsely rejected valid annotation-free schemas at registration. The walk is now context-aware: properties and the name-map keywords (patternProperties/$defs/definitions/ dependentSchemas) treat KEYS as names and descend their VALUES as schemas; instance-valued keyword contents (default/const/examples/enum/...) are skipped as data; a node's own annotation is checked at visit time. BLOCK 2: Tuples and Sets serialize as JSON arrays but the scan did not descend them, so an annotation inside a tuple-valued anyOf was advertised unvalidated. Validation now runs on the schema AS IT WILL BE ADVERTISED: a JSON round-trip normalizes every serialization-equivalent container before the walk. WARN: the identity-alias exception is gone as a side effect — normalization duplicates an aliased subschema into a proper tree, so a node straddling a reachable and an unreachable position is caught (regression-tested). Suite 1954/1954 (6 new tests); param-header file 109/109 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…core data semantics per Codex round 6 BLOCK 1: validation/advertisement used the normalized JSON tree while runtime enforcement walked the raw Julia containers — a NamedTuple-shaped properties map advertised annotations that the raw collector could not see (mirroring silently disabled), and a Char annotation validated but was skipped at enforcement. validate_tool_headers now RETURNS the mirror table (property path -> suffix) derived from the same normalized tree it validates; register! persists it on the new Server.tool_header_paths, and the preflight consumes the persisted table — validation, advertisement, and enforcement can no longer diverge. The raw-container collectors are deleted. BLOCK 2: the walk treated unknown keys as schema context, but JSON Schema 2020-12 defines unrecognized keywords as annotations whose value is DATA — dependentRequired (name-keyed arrays), draft-07 array-valued dependencies, and custom metadata keywords containing an x-mcp-header key all falsely rejected. The walk now descends ONLY the known subschema keywords (items/prefixItems/additionalItems/contains/additionalProperties/ unevaluated*/propertyNames/if/then/else/not/allOf/anyOf/oneOf) and name-map keywords (patternProperties/$defs/definitions/dependentSchemas/dependencies, dict-valued entries only); everything else — instance keywords and unknown keywords alike — is data per the core spec and never walked. The someFutureKeyword expectation flips accordingly (a conforming client applies the same core rule). Suite 1960/1960; param-header file 115/115 on Julia 1.11; conformance custom 10/10, modern 40/40, stateless 30/30. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ram rejection per Codex round 7 BLOCK 1: contentSchema (2020-12 schema-valued) was missing from the subschema keywords — an annotation nested inside it registered, advertised, and left the mirror table empty. Included in the 2020-12 set. BLOCK 2: keyword classification unioned draft-07 and 2020-12 — data under the REMOVED additionalItems keyword false-rejected in (default) 2020-12 schemas, while 2020-12-only keywords were wrongly walked in declared draft-07 schemas. The walker now selects dialect-specific subschema and name-map keyword sets from the root $schema (2020-12 default per MCP; draft-07 gets additionalItems/definitions/dependencies, loses prefixItems/unevaluated*/ contentSchema/$defs/dependentSchemas). The draft-07 dependencies regressions now declare their dialect explicitly. WARN 1: same-name re-registration overwrote the mirror table but APPENDED the tool — first-match dispatch kept the OLD handler active with its enforcement gone. register! now replaces tool and table atomically for MCPTools. WARN 2: duplicate ToolParameter names collapse in schema generation (last wins), splitting the advertised schema from a mirror table built from all parameters — refused at registration when header mirroring is in use. Comments softened: the no-divergence guarantee holds for register!-managed tools; direct field mutation bypasses like any raw mutation. Suite 1965/1965; param-header file 120/120 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements
x-mcp-headerparameter mirroring (the optional half of SEP-2243) — the last unimplemented item on the 2026-07-28 spec's server side. The conformance suite'shttp-custom-header-server-validationscenario, previously unrunnable against this server (no annotated tool existed), passes 10/10.Design
ToolParametergains aheader::Union{String,Nothing}field, emitted asx-mcp-headerin the generated input schema; a rawinput_schemacarries the same key verbatim. Modern HTTP clients mirror an annotated argument as anMcp-Param-<suffix>request header so transport intermediaries can route on it.handle_call_tool, before any execution path (sync, MRTR, task detach), for modern-era requests whose transport supplied headers: for every annotated parameter present in the body arguments, the mirrored header must exist, must not be duplicated or unsafe, must decode — a=?base64?...?=sentinel is validated strictly via the existingdecode_mcp_header_value(canonical alphabet, padding, length; invalid → reject, per the SEP-2243 test-case table), anything else is a literal — and must equal the body value's string form. Violations are-32020, which the existing status layer maps to HTTP 400. Headers for absent arguments and unrecognizedMcp-Param-*headers are ignored (forward compatibility); stdio (param_headers === nothing) and legacy-era requests skip the check entirely.Mcp-Param-*headers (collect_param_headers— lowercased suffixes, OWS stripped per RFC 9110, duplicates flagged) into theQueuedHttpRequestenvelope (back-compat 3-arg constructor kept), the single server loop consumes them viapending_param_headers(base-transport defaultnothing), and they thread throughprocess_message→handle_request→handle_modern_request→serve_modernintoRequestContext.param_headers— never via shared transport state.Verification
http-custom-header-server-validation10/10 (valid-Base64 decode-and-match, invalid padding and invalid characters rejected, missing-prefix/suffix treated as literal, missing-header-with-body-value rejected — all with 400 +-32020); fixture server gainstest_header_param. All baselines unchanged: standard headers 14/14, modern-dated 40/40,server-stateless30/30, legacy 59,tasks-*substantive all-pass.test/protocol/test_param_headers.jl: schema emission, literal/sentinel/number/raw-schema accepts, all five violation classes with the 400 mapping, absent-argument and unknown-header tolerance, stdio and legacy-era skips,collect_param_headersunit behavior). Param-header file 27/27 on Julia 1.11.With this merged, the compliance map's modern-era section is complete — remaining 0.7 work is docs and release chores.
🤖 Generated with Claude Code