ConduitMcp.Plugs.HeaderValidation (ConduitMCP v0.11.0)

Copy Markdown View Source

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.

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

invalidate(server_module)

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