Wymcp.Plugs.SingletonHeaders (Wymcp v0.6.2)

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 reject/degrade policy vocabulary below, see that entry. This check applies one policy per header:

HeaderPolicyA duplicate
Mcp-Session-IdrejectHTTP 400 + JSON-RPC -32600, naming the header
MCP-Protocol-VersionrejectHTTP 400 + JSON-RPC -32600, naming the header
Mcp-MethodrejectHTTP 400 + JSON-RPC -32600, naming the header
Mcp-NamerejectHTTP 400 + JSON-RPC -32600, naming the header
Last-Event-IDdegradepasses; the outcome is assigned as :duplicated
any repeated Mcp-Param-* namerejectHTTP 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.

The three modern rows added beside MCP-Protocol-Version are era-invariant exactly as it is: this check runs before era classification, so a duplicated Mcp-Method on a legacy request, or on a GET, is a 400 too. A singleton is a singleton on every route, and the alternative — a per-era table — is a second thing to keep true.

The Mcp-Session-Id and Last-Event-ID rows are legacy-only: both headers exist only on the legacy lane, and both rows go at the legacy decommission.

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-Session-Id values are a 400, and so are two MCP-Protocol-Version values of which one matches the version negotiated at initialize: this check runs before any session pid resolves, so it has no negotiated version to compare against, and comparing values is Wymcp.Plugs.Session's job further down the chain.

Two 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 under the same policy as the two reject-class headers above. 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

For a reject-class header, 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.Session, Wymcp.Router, or Wymcp.Session 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 — Wymcp.Plugs.Session's enforce_protocol_version_header/2 still checks the single value against the negotiated version, and so has three clauses, not two.

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.

What the degrade policy assigns

Last-Event-ID does not halt, so a duplicate really can still travel. Its outcome is therefore the one that must be carried: it is assigned under :wymcp_last_event_id as {:ok, value} | :missing | :duplicated, and Wymcp.Transport.Stream discards the resumption point on :duplicated with the :info log List.first/1 used to swallow. The whole policy is legacy-only: Last-Event-ID and the stream that reads it exist only on the legacy lane, and the section goes at the legacy decommission.

One table, both insertion points

The table is applied in full at both places the check is wired — inside Wymcp.Plugs.Pipeline on POST (after Wymcp.Plugs.Auth, so body parsing and Wymcp.Plugs.Classify have already run and a rejection can carry the body id and the message kind) and inside Wymcp.Router's with_wire_checks/2 on GET and DELETE. A singleton header is a singleton on every route; a per-route table would be a second thing to keep true. The accepted cost: Last-Event-ID is read only by the GET stream, so its assigned outcome is present and unread on POST and DELETE. That costs nothing on the wire — degrade never rejects. The cost is legacy-only: the GET stream exists only on the legacy lane, and it goes at the legacy decommission.

Rejections speak the route's error dialect via the :error_dialect init option (:json_rpc by default, :plain_json on GET/DELETE); their id comes from Wymcp.Response.send_rejection/6, which derives it rather than taking it from this plug. The :plain_json half is legacy-only: the plain-JSON dialect exists for the GET and DELETE rejection bodies, and it goes with those routes at the legacy decommission.