Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243)
Implements the server half of SEP-2243 custom headers for the 2026-07-28 Streamable HTTP path: tools/call requests are validated Mcp-Param-*-header ↔ body before dispatch, and mismatches are rejected with HTTP 400 + JSON-RPC -32020 (HeaderMismatch). The client half (mirroring, sentinel codec, annotation validation) already shipped; this closes the remaining server-side gap and removes the http-custom-header-server-validation entries from both conformance expected-failures baselines.
Motivation and Context
The draft transport spec requires it (Server Validation): "Any server that processes the message body MUST validate that encoded header values, after decoding if Base64-encoded, match the corresponding values in the request body. Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32020 (HeaderMismatch) if any validation fails." Until now the modern entry validated MCP-Protocol-Version/Mcp-Method/Mcp-Name only; the http-custom-header-server-validation conformance scenario ran 3 accept-pass / 6 reject-fail and was baselined as a known failure.
Design
Schema source = the server's own registered tools/list handler. The validator needs the called tool's inputSchema to know which Mcp-Param-* headers are recognized (the header name comes from the x-mcp-header annotation value, not the property name, so without the schema there is nothing to compare). Rather than adding a registry hook or configuration, the entry resolves the schema by dispatching an internal tools/list through the normal serve_one path with the caller's own envelope. Properties of this approach:
- Nothing to configure on
MCPServeror on low-level servers — anyone servingtools/listgets validation automatically. - A visibility-scoped catalog (per-caller tool filtering) validates against exactly what this caller was advertised; a tool hidden from the caller was never advertised, so its headers are unrecognized and ignored.
- Validation is skipped — never failing the call — when: no
tools/listhandler is registered (an undiscoverable catalog has no recognized headers), the tool isn't in the listing (dispatch owns unknown-tool), the handler raises (logged at exception level — a deliberate availability-over-strictness call: a server broken here is equally broken for real discovery, and the skip is just the pre-feature status quo for that request), the pagination walk exceeds a 100-page cap or repeats a cursor (logged), or the call has no arguments and noMcp-Param-*headers (no declaration can be violated in either direction). - The cost is an internal listing per validated
tools/call: middleware, lifespans, and expensive/paginatedtools/listhandlers see extra invocations. Documented indocs/migration.md; optimizable later behind the same surface (e.g. a registry fast-path for the built-in handler) without API change.
Validation semantics (pure, exported mcp.shared.inbound.validate_mcp_param_headers, sharing the schema walker and scalar rendering with the client emit side so the two halves cannot drift):
- Presence per the spec's scenario table: argument present → header must be present and match after sentinel decoding; argument
null/absent → header must not be expected. A header present for an absent/null argument is rejected — the spec's table doesn't pin this cell, but its purpose clause ("a load balancer routing on the header value while the MCP server executes based on the body value") is exactly this case; the Go SDK rejects too, the TypeScript SDK skips. - A recognized header supplied more than once is rejected: intermediaries typically read the first copy while a last-wins lookup would validate the second, recreating the divergence the gate exists to prevent.
- Strict canonical base64 in the
=?base64?...?=sentinel: wrong padding, non-alphabet characters, non-zero trailing bits, or invalid UTF-8 are malformed (the conformance suite mandates strict rejection; Python's default lenientb64decodewould wrongly accept two of its reject cases). This now applies toMcp-Namedecoding symmetrically. - Integer-typed declarations compare numerically (
42≡42.0, the spec's SHOULD) behind a canonical-decimal gate (no1e2), exactly (no float round-trip), and in both directions (an integral-float body value like42.0matches). - Unrecognized
Mcp-Param-*headers are forwarded-and-ignored per the spec; a header claiming a non-primitive body value is a mismatch, while a non-primitive value without a header is left to params validation at dispatch.
How Has This Been Tested?
- Conformance (pinned referee
conformance@4944b268, locally against the everything-server):http-custom-header-server-validation9/9 (was 3/9);http-header-validation13/13 (unchanged — guards the shared decoder hardening);server-stateless25/28 with exactly the 3 pre-existing known failures. - Live wire probes against the running everything-server: accept (plain, sentinel, mixed-case header name, undeclared-header-ignored, both-sides-empty) and reject (mismatch, missing header, bad padding, orphan header) all return the documented status/code/message; a legacy 2025
initializeon the same endpoint is untouched. ./scripts/test: 4750 passed, 100% branch coverage,strict-no-coverclean. New unit matrix intests/shared/test_inbound.pymirrors every conformance leg plus the edge semantics (duplicates, huge-digit headers, integral floats, nested paths, invalid-annotation schemas); entry-level wire tests intests/server/test_streamable_http_modern.pycover skip paths (no list handler, unlisted tool, raising handler, cursor cycle, page cap), pagination, SSE-mode 400-as-JSON, and envelope isolation of the internal listing.
Breaking Changes
Spec-mandated behavior change on the 2026-07-28 path only (documented in docs/migration.md): a client that sends an annotated argument without its mirroring header — e.g. one that calls a tool it never listed — is now rejected with 400/-32020 instead of silently served. The spec's recovery is to re-list and retry (a client-side SHOULD this SDK does not implement yet; the TypeScript SDK does). Base64-sentinel decoding, including for Mcp-Name, is now strictly canonical. Legacy (≤2025-11-25) traffic is unaffected.
Types of changes
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to change)
- Documentation update
Checklist
- I have read the MCP Documentation
- My code follows the repository's style guidelines
- New and existing tests pass locally
- I have added appropriate error handling
- I have added or updated documentation as needed
Additional context
The orphan-header cell (header present, argument absent) is genuinely unpinned by the spec's table and the SDKs split on it (Go rejects, TypeScript skips); this PR takes the reject posture with the purpose-clause rationale above. May be worth a small spec clarification upstream.