Wymcp. Plugs. HeaderBinding
(Wymcp v0.8.7)
View Source
The header-binding check: on
every 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. A generated input
schema declares none, so a call to a use Wymcp.Tool tool has no
Mcp-Param-* row; every header annotation a hand-written schema declares
is honoured. 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.Validate, 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 — so
it joins Wymcp.Plugs.Pipeline's body-bound declaration rather than
Wymcp.Router's wire-check list.
Notifications pass through untouched. The spec leaves header requirements for notification POSTs undefined, and JSON-RPC forbids an error answer to one.
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 — the slot Wymcp.JsonRpc's contract gives
the one-line cause, a rule this check kept on its own before it was one. 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.
Both values are quoted through the echo form, Wymcp.Bound.echo/1, under
the rule Wymcp.Bound states in "What a refusal quotes". The comparison
reads the whole values; only what the answer quotes is bounded.
received is omitted on :invalid_header, whose bytes were refused, and
two checks decide which values are refused so. 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. Neither is what keeps the envelope encodable:
Wymcp.Bound.echo/1 renders a string that is not valid UTF-8 inspected,
so whatever reaches data encodes. A decoded value that survives is valid
UTF-8, not necessarily visible ASCII — a sentinel may legitimately carry a
newline — and data.received quotes it unscrubbed, cut at the bound.
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 header annotation's declared type can spell. Between them they gate
those two rows and no others. The tool lookup in annotations/2 carries
the name half of the rule into the Mcp-Param-* rows without a guard of
its own: the callback-shape check refuses a name/0 that is not a string
at the registration moment, so
every stored definition's name is a string, a params.name that is not
one identifies no tool, and no Mcp-Param-* row is built against its
arguments either. The other two rows 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, 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.