Wymcp. Plugs. SingletonHeaders
(Wymcp v0.8.3)
View Source
Enforces the cardinality of the singleton request headers wymcp owns — a
wire check; where it runs is
Wymcp.Router's wire-check invariant to state.
For what a singleton header is, and the one policy in use — reject — see that entry. The headers this check reads:
| Header | A duplicate |
|---|---|
MCP-Protocol-Version | HTTP 400 + JSON-RPC -32600, naming the header |
Mcp-Method | HTTP 400 + JSON-RPC -32600, naming the header |
Mcp-Name | HTTP 400 + JSON-RPC -32600, naming the header |
any repeated Mcp-Param-* name | HTTP 400 + JSON-RPC -32600, naming the family |
The Mcp-Param-* row is a prefix, not a name: which Mcp-Param-* headers exist is
a property of the tool a call names, and this check reads no tool
definition. It is matched after every exact row, and its rejection names
Mcp-Param-* rather than the header the client repeated — that name is a
client-controlled string, and this message is also the telemetry event's.
Every row rejects: a singleton is a singleton on every request, and a
second policy would be a second thing to keep true. Mcp-Session-Id and
Last-Event-ID are not rows — a server speaking this revision alone
ignores both headers, so their cardinality is nobody's business here.
Failing closed rather than picking a value is the point: a repeated header
is the signature of a broken proxy, and quietly choosing one of its values
would mask that. The reject policy therefore reads cardinality and nothing
else — it never looks at the values. Two identical MCP-Protocol-Version
values are a 400, and so are two of which one matches the body's protocol
version: comparing the value is Wymcp.Plugs.HeaderBinding's job further
down the chain.
Three singleton headers are not here. Origin stays with
Wymcp.Plugs.OriginCheck, which has already validated it when this check
runs — nothing had validated that header when the origin check ran, so it
carries its own duplicate arm and its own copy of the message. That arm
runs whether or not an :origin allowlist is configured, so Origin
rejects a duplicate exactly as every row above does. Content-Type
stays with the parse step in Wymcp.Plugs.Pipeline
for the same ordering reason: the body is parsed before this check runs,
so the parse step reads that header's cardinality itself, under the same
reject policy and with its own copy of the message.
Authorization is the consumer's Wymcp.Auth implementation's to
read, and it runs before this check for the same ordering reason; see that
module's example, which reads the header three ways.
What the reject policy buys downstream
Halting is the normalization. Every path that reaches a downstream
Plug.Conn.get_req_header/2 either was halted here or carried at most one
value to begin with, so [] or [value] is a fact about the conn rather
than a convention — which is why no read in Wymcp.Plugs.HeaderBinding
carries a duplicate arm: each faces a two-way present/absent decision on
cardinality. A header's value is a separate concern, so a read that also
compares the value keeps a clause for it.
There is deliberately no assign mirroring that guarantee. An assign is
absent when this plug did not run, and absent reads as "header missing" —
a silent wrong answer (a 400 on a request that carried the header). A
two-clause get_req_header/2 read facing a bypassed check crashes instead.
For a guarantee this load-bearing, loud beats silent, and
Wymcp.WireCheckInvariantTest is the mechanism that catches a route wired
without the check.
Where it is wired
The check is wired once, inside Wymcp.Plugs.Pipeline after
Wymcp.Plugs.Auth, so the parse step and Wymcp.Plugs.Classify have
already run and a rejection can carry the body id and the message kind.
Rejections speak the JSON-RPC dialect; their id comes from
Wymcp.Response.send_rejection/5, which derives it rather than taking it
from this plug.