Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243)

Merged
MA
4mo ago
+971-37

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.

Refs #2900, #2715.

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 MCPServer or on low-level servers — anyone serving tools/list gets 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/list handler 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 no Mcp-Param-* headers (no declaration can be violated in either direction).
  • The cost is an internal listing per validated tools/call: middleware, lifespans, and expensive/paginated tools/list handlers see extra invocations. Documented in docs/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 lenient b64decode would wrongly accept two of its reject cases). This now applies to Mcp-Name decoding symmetrically.
  • Integer-typed declarations compare numerically (42 ≡ 42.0, the spec's SHOULD) behind a canonical-decimal gate (no 1e2), exactly (no float round-trip), and in both directions (an integral-float body value like 42.0 matches).
  • 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-validation 9/9 (was 3/9); http-header-validation 13/13 (unchanged — guards the shared decoder hardening); server-stateless 25/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 initialize on the same endpoint is untouched.
  • ./scripts/test: 4750 passed, 100% branch coverage, strict-no-cover clean. New unit matrix in tests/shared/test_inbound.py mirrors every conformance leg plus the edge semantics (duplicates, huge-digit headers, integral floats, nested paths, invalid-annotation schemas); entry-level wire tests in tests/server/test_streamable_http_modern.py cover 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.

AI Disclaimer