Validates the MCP request-metadata headers of a modern (2026-07-28)
Streamable HTTP request against its JSON-RPC body.
Intermediaries may route on these headers without parsing the body, so a header that disagrees with the body is a split-brain hazard, not a formatting nit. Every failure halts with HTTP 400 and a modern JSON-RPC error.
Runs after Plug.Parsers. It uses the request's ConduitMcp.Ctx from
conn.private[:mcp_ctx] when one is stored, and otherwise classifies the
body with ConduitMcp.Era.classify/2 itself, storing a modern result — a
classification failure is answered here with 400 too. Legacy requests, and
an era-exempt server/discover sent without _meta, pass through
untouched. A legacy context is not stored: it depends on the session a
later transport plug resolves.
Checks, in order
MCP-Protocol-Versionis present, single, and equal to the_metaprotocol version — else-32020.- That version is one this server implements — else
-32022, withdata.supportedanddata.requested. The mismatch check comes first: a header that disagrees with the body is-32020even when neither version is supported. Mcp-Methodis present, single, and equal to the bodymethod— else-32020.- On
tools/call,prompts/get,resources/readand the tasks extension'stasks/get|update|cancelonly:Mcp-Nameis present, single, and — after decoding the=?base64?…?=sentinel — equal toparams.name(params.uriforresources/read,params.taskIdfortasks/*) — else-32020. - On
tools/call: for every parameter of the called tool annotated with"x-mcp-header": "Name",Mcp-Param-Namemust be present and match the argument (integers compare numerically,42equals42.0) when the argument has a non-null value, and must be absent when it does not — else-32020. A malformed sentinel is a mismatch too.
Error messages name the header only. They never echo a header or body value, and nothing here logs one.
Tool-schema cache
The x-mcp-header annotations come from the server's own tools/list
(handle_list_tools, following nextCursor), computed on the first
tools/call and cached per server module in :persistent_term. Tools
whose annotations are invalid (see
ConduitMcp.Headers.validate_x_mcp_header/1) are excluded from
Mcp-Param-* validation, and each is logged at :error level when the
cache is built, naming the tool and the offending annotations. The
specification tells clients to drop such a tool from their tool lists, so
a conforming client never calls it; raising instead would fail whichever
unrelated tools/call happened to build the cache. DSL and Component
servers never reach this: their annotations are checked at compile time.
The tool list is read from the server module in
conn.private[:server_module] (set by both transports). The cache is
built from the list an unauthenticated caller would see: the
handle_list_tools callback gets a conn with the transport's
configuration but no principal, assigns, headers or session, so no
caller's view decides another's. A called tool absent from that list —
one a manual-mode server lists only to some callers — is looked up in the
caller's own list on each call, uncached. A manual-mode
handle_list_tools must therefore tolerate a conn without a principal,
and a tool's inputSchema must not differ between callers who can both
see it. DSL and Endpoint servers have a compile-time tool list, so the
cache never goes stale. A manual-mode server whose tool list changes at runtime must
call invalidate/1 after the change; the next tools/call rebuilds the
entry. :persistent_term updates are expensive (a global GC pass), so the
cache suits tool lists that change rarely. A tool missing from the cached
list has no annotated parameters as far as this plug is concerned; the
handler rejects an unknown tool on its own.
Summary
Functions
Drops the cached x-mcp-header annotations for server_module; the next
modern tools/call re-reads them from handle_list_tools.
Functions
@spec invalidate(module()) :: :ok
Drops the cached x-mcp-header annotations for server_module; the next
modern tools/call re-reads them from handle_list_tools.