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:

HeaderA duplicate
MCP-Protocol-VersionHTTP 400 + JSON-RPC -32600, naming the header
Mcp-MethodHTTP 400 + JSON-RPC -32600, naming the header
Mcp-NameHTTP 400 + JSON-RPC -32600, naming the header
any repeated Mcp-Param-* nameHTTP 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.