# `ConduitMcp.Plugs.HeaderValidation`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/plugs/header_validation.ex#L1)

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

1. `MCP-Protocol-Version` is present, single, and equal to the `_meta`
   protocol version — else `-32020`.
2. That version is one this server implements — else `-32022`, with
   `data.supported` and `data.requested`. The mismatch check comes first:
   a header that disagrees with the body is `-32020` even when neither
   version is supported.
3. `Mcp-Method` is present, single, and equal to the body `method` — else
   `-32020`.
4. On `tools/call`, `prompts/get`, `resources/read` and the tasks
   extension's `tasks/get|update|cancel` only: `Mcp-Name` is present,
   single, and — after decoding the `=?base64?…?=` sentinel — equal to
   `params.name` (`params.uri` for `resources/read`, `params.taskId` for
   `tasks/*`) — else `-32020`.
5. On `tools/call`: for every parameter of the called tool annotated with
   `"x-mcp-header": "Name"`, `Mcp-Param-Name` must be present and match the
   argument (integers compare numerically, `42` equals `42.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.

# `invalidate`

```elixir
@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`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
