Skip to content

feat: x-mcp-header parameter mirroring — Mcp-Param validation (SEP-2243) - #86

Open
kalidke wants to merge 8 commits into
mainfrom
feature/x-mcp-header
Open

feat: x-mcp-header parameter mirroring — Mcp-Param validation (SEP-2243)#86
kalidke wants to merge 8 commits into
mainfrom
feature/x-mcp-header

Conversation

@kalidke

@kalidke kalidke commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Implements x-mcp-header parameter mirroring (the optional half of SEP-2243) — the last unimplemented item on the 2026-07-28 spec's server side. The conformance suite's http-custom-header-server-validation scenario, previously unrunnable against this server (no annotated tool existed), passes 10/10.

Design

  • Annotation. ToolParameter gains a header::Union{String,Nothing} field, emitted as x-mcp-header in the generated input schema; a raw input_schema carries the same key verbatim. Modern HTTP clients mirror an annotated argument as an Mcp-Param-<suffix> request header so transport intermediaries can route on it.
  • Validation happens in 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 existing decode_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 unrecognized Mcp-Param-* headers are ignored (forward compatibility); stdio (param_headers === nothing) and legacy-era requests skip the check entirely.
  • Plumbing mirrors the per-request auth pattern exactly: the connection handler collects Mcp-Param-* headers (collect_param_headers — lowercased suffixes, OWS stripped per RFC 9110, duplicates flagged) 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_messagehandle_requesthandle_modern_requestserve_modern into RequestContext.param_headers — never via shared transport state.

Verification

  • Conformance: http-custom-header-server-validation 10/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 gains test_header_param. All baselines unchanged: standard headers 14/14, modern-dated 40/40, server-stateless 30/30, legacy 59, tasks-* substantive all-pass.
  • Suite 1872/1872 (27 new tests in 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_headers unit 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

kalidke and others added 8 commits August 4, 2026 19:31
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant