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 headerBody fieldRequired on
MCP-Protocol-Versionthe protocol version in params._metaevery request
Mcp-Methodmethodevery request
Mcp-Nameparams.namea tools/call whose name is a string
Mcp-Param-*the argument its header annotation namesa 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 a Mcp-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 deprecated obs-text range, → The -32020 body), 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. Never null: 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.