Wymcp. Plugs. HeaderBinding
(Wymcp v0.6.1)
View Source
The header-binding check: on
every modern-classified request, each
mirrored header the spec requires
must be present and must equal the body field it mirrors, wherever the
body value has a header spelling at all (→ Which values bind). A
missing, disagreeing or malformed mirror answers HTTP 400 with -32020.
The rule exists because two components read one request from two places: an intermediary routes on a header while the server answers from the body. Header binding is what keeps those the same fact — and the body stays authoritative throughout. Nothing here acts on a header's value; the check only refuses a request whose headers and body disagree.
What is compared, and in what order
| Mirrored header | Body field | Required on |
|---|---|---|
MCP-Protocol-Version | the protocol version in params._meta | every request |
Mcp-Method | method | every request |
Mcp-Name | params.name | a tools/call whose name is a string |
Mcp-Param-* | the argument its header annotation names | a tools/call whose name is a string and whose argument is present and of the annotated type |
The first failing row answers, and the rows below it are never read — the
same first-matching-row discipline Wymcp.Plugs.SingletonHeaders applies,
and for the same reason: a client fixes one mirror at a time.
Mcp-Param-* rows are per call. The tool params.name names is looked up
in the mount's definitions, and its input schema's header annotations are
read through Wymcp.Tool.Schema.header_annotations/1 — so the framework's
own action annotation and any a hand-written schema declares are honoured
by one code path. An argument the body leaves absent or null expects no
header at all, which is the spec's rule and not a leniency: a header sent
anyway is ignored, as is an undeclared Mcp-Param-* an intermediary added.
A present argument whose type the annotation cannot spell expects none
either, under the binding rule below — a different reason, and wymcp's own
rather than the spec's. An unknown tool name declares no annotations, so
only Mcp-Name is compared and Wymcp.Methods.ToolsCall answers the
unknown tool afterwards.
Where it runs, and what that buys
Directly after Wymcp.Plugs.ProtocolFields and before
Wymcp.Plugs.Session, declared body-bound in Wymcp.Plugs.Pipeline. The
position is what gives the protocol-version row a known-good body
value: the protocol fields check has
just proven the field is a string naming a modern revision wymcp serves, so
a malformed _meta meets its own -32602 first and that one row never
reports a mismatch against garbage.
The other rows carry no such guarantee, and the position cannot give them
one: Wymcp.Plugs.Validate runs after this check, so params.name and
arguments are unvalidated here. The check answers no body defect all the
same, because a row exists only where the body value has a header spelling
at all (→ Which values bind). A tools/call whose name is absent or is
not a string builds no Mcp-Name row, so nothing is compared against it
and Wymcp.Methods.ToolsCall's own -32602 names the real defect further
down the chain. A -32020 naming Mcp-Name or a Mcp-Param-* row is
therefore a header defect by construction, and data.expected is never
null. It is not a
wire check — it needs a parsed body and an
era — so it joins Wymcp.Plugs.Pipeline's body-bound declaration rather
than Wymcp.Router's wire-check list.
Notifications and legacy-classified messages pass through untouched. The
spec leaves header requirements for notification POSTs undefined, and
JSON-RPC forbids an error answer to one; the legacy lane keeps the tolerant
MCP-Protocol-Version check Wymcp.Plugs.Session runs.
The legacy pass-through is legacy-only: it exists only because a second
era does, and it goes at the legacy decommission.
Duplicates never reach here. Wymcp.Plugs.SingletonHeaders owns the
cardinality of all four headers, so every read below faces [] or
[value] and the two-way present/absent decision that check's doc
promises.
The three reasons
Three conditions share one error type and answer three rejection reasons, because they are three operationally different signals and telemetry filters on the atom:
:missing_header— a required mirror is absent. A non-conforming client, or one that never read this revision.:header_mismatch— the header disagrees with the body. A broken intermediary, or a client bug; on aMcp-Param-*row it is also what a stale cached tool schema looks like.:invalid_header— the value carries bytes outside visible ASCII plus space and tab (RFC 9110's field-value set minus its deprecatedobs-textrange, → The-32020body), or it is in sentinel form and does not decode. Garbage or an attack — or a client that mirrored a non-ASCII value raw, or built its sentinel wrong, which is why the next action follows the cause: the sentinel form on a string row whose raw bytes were refused, what a sentinel must carry when one did not decode, and otherwise the row's own mirror instruction.
Bandit hands such bytes through unexamined — a control character, DEL and
a high byte all reached a plug intact when this was measured — so the
:invalid_header arm answers real requests and not only hand-built
connections.
The -32020 body
Wymcp.Response's shared sender speaks -32600 alone, so this check
assembles its own envelope after Wymcp.Response.record_rejection/5, the
way Wymcp.Plugs.ProtocolFields does. The message names the header, the
condition and the next action, so it is per-request where every other
envelope's is the fixed one the error-code table carries:
Wymcp.JsonRpc.error_response/4 is what puts it on the wire. It carries no
client bytes — a Mcp-Param-* header name is server-declared, not
client-controlled — so the same string rides the telemetry event unchanged;
the values go under data:
data.header— the mirrored header's display name.data.expected— the body's value, the authoritative side. Nevernull: a value with no header spelling builds no row to report.data.received— the header's value after sentinel decoding, omitted when the header is missing or its bytes were refused.
Omitting received on :invalid_header is what keeps the envelope
encodable, and two checks share that job — neither is redundant.
field_value?/1 refuses raw bytes outside visible ASCII plus space and
tab — RFC 9110's field-value set minus its deprecated obs-text range
(%x80-FF), which the sentinel form exists to carry — before anything
decodes. decode_sentinel/1's String.valid?/1 then
refuses a sentinel whose decoded bytes are not valid UTF-8, which the
byte check cannot see: =?base64?7aCA?= is visible ASCII on the wire and
decodes to a lone surrogate that JSON.encode!/1 raises on. Dropping
either turns a 400 into a 500 on a value a client controls. A decoded
value that survives is valid UTF-8, not necessarily visible ASCII — a
sentinel may legitimately carry a newline — which is exactly why the
UTF-8 check, and not the byte check, is what makes data.received safe to
report.
Sentinel decoding
A value that opens =?base64? and closes ?= is Base64 of UTF-8 and is
decoded before comparison, on Mcp-Name and Mcp-Param-* only — the two
the spec permits it on. A client MUST encode any plain value that happens
to match the pattern, so no literal survives undecoded. A value in sentinel
form that does not decode, or that decodes to bytes that are not valid
UTF-8, is :invalid_header.
Which values bind
A row exists only where the declared type can spell the body value as a
header: a string row binds a string, an integer row an integral number
(42 and 42.0 have a decimal-integer spelling; 42.5 has none), a
boolean row true or false. Every other value — absent, null, the
wrong JSON type — builds no row, so no header is expected for it and none
is read. That is what keeps this check off the body's business: a
malformed params.name or action is Wymcp.Methods.ToolsCall's to
answer, and a mistyped argument under a hand-written annotation is the
tool's, since the framework validates no property values at all.
Two guards carry that test, one per binding shape: name_binding/1's
is_binary clause builds the Mcp-Name row only for a string
params.name, and bindable?/2 builds a Mcp-Param-* row only for a
value the annotation's declared type can spell. A third clause head,
annotations/2's, carries the name half of the rule into the tool
lookup: a params.name that is not a string identifies no tool, so no
Mcp-Param-* row is built against its arguments either. Between them
they gate those two rows and no others. The other two are built unconditionally
because something upstream has already proven their values:
Wymcp.Plugs.ProtocolFields has proven the protocol version is a string
naming a served revision, and Wymcp.Plugs.Classify tags a message whose
method is not a binary :unknown or :response, never the :request
this plug answers on. Leave them unconditional. Routing them through
either guard for tidiness would turn a regression in either of those into
a row silently skipped rather than a comparison that fails loudly.
Comparison then follows the same three types. An integer is compared
numerically on both sides, so 42 and 42.0 both mirror the header 42
and either mirrors the header 42.0 — the spec's own SHOULD, and what a
client whose JSON decoder yields floats sends. The header stays a decimal
spelling — digits with at most a fraction of zeros — so 4.2e1 and 42.5
are mismatches. A boolean is compared against lowercase true and
false. Everything else is compared as a string.
The accepted edge is a hand-written annotation over a value its own tool
will take anyway: 42.5 under an integer annotation runs, while a
gateway routes on a header this check never read. The spec defines no
answer for a value whose type disagrees with its annotation, and refusing
the request would be stricter than the spec over a call the tool can
serve.