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:
| Header | Policy | A duplicate |
|---|---|---|
Mcp-Session-Id | reject | HTTP 400 + JSON-RPC -32600, naming the header |
MCP-Protocol-Version | reject | HTTP 400 + JSON-RPC -32600, naming the header |
Mcp-Method | reject | HTTP 400 + JSON-RPC -32600, naming the header |
Mcp-Name | reject | HTTP 400 + JSON-RPC -32600, naming the header |
Last-Event-ID | degrade | passes; the outcome is assigned as :duplicated |
any repeated Mcp-Param-* name | reject | 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.
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.