Skip to content

MCP 2026-07-28 (4/11): x-mcp-header — Mcp-Param-* headers, tool validation, HeaderMismatch retry - #227

Open
simonx1 wants to merge 7 commits into
mcp-2026/streamable-http-modernfrom
mcp-2026/x-mcp-header
Open

MCP 2026-07-28 (4/11): x-mcp-header — Mcp-Param-* headers, tool validation, HeaderMismatch retry#227
simonx1 wants to merge 7 commits into
mcp-2026/streamable-http-modernfrom
mcp-2026/x-mcp-header

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

Fourth PR of the MCP 2026-07-28 series (stacked on #226). Implements Custom Headers from Tool Parameters (SEP-2243).

  • MCPClient::HeaderParams (new) — validate_schema enforces every constraint on x-mcp-header values (non-empty, 1*tchar, no CR/LF, case-insensitively unique, only on integer/string/boolean properties, only statically reachable via a chain of properties keys — never through items, oneOf/anyOf/allOf/not, if/then/else, $defs, $ref or the root). annotations lists the reachable annotated paths; headers_for reads the argument at each exact path (String or Symbol keys), omits absent/null values, converts per type (decimal integers within the IEEE754 safe range, lowercase booleans, strings with the =?base64?…?= sentinel when not header-safe) and raises ValidationError for anything that cannot be mirrored.
  • Transports — on a modern session the HTTP transports drop invalid tools from tools/list with a warning naming the tool and the reason ("Rejection means the client MUST exclude the invalid tool"), attach Mcp-Param-{name} headers to tools/call (fetching the tool list on demand when a tool is called first), and on a -32020 HeaderMismatch refresh tools/list and retry once with recomputed headers. Legacy sessions are untouched; stdio ignores the annotation as the spec allows.

Test plan

  • bundle exec rspec — 1869 examples, 0 failures (28 new in x_mcp_header_2026_spec.rb)
  • bundle exec rubocop — clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 force-pushed the mcp-2026/x-mcp-header branch from dfce337 to 48fedad Compare September 1, 2026 19:35
@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from f5bc6d6 to 39ae5ba Compare September 1, 2026 19:55
@simonx1
simonx1 force-pushed the mcp-2026/x-mcp-header branch 2 times, most recently from bc796b9 to d516b8c Compare September 1, 2026 19:58
@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from 0e00bfa to 725e88f Compare September 5, 2026 00:19
@simonx1
simonx1 force-pushed the mcp-2026/x-mcp-header branch from 4efbb6d to 422b5c8 Compare September 5, 2026 00:22
MCP 2026-07-28 Streamable HTTP "Custom Headers from Tool Parameters":

- MCPClient::HeaderParams validates x-mcp-header annotations (non-empty
  HTTP field-name token, case-insensitively unique, only on
  string/integer/boolean properties, only statically reachable through
  properties keys) and extracts Mcp-Param-{name} values with the spec's
  type conversion and Base64 sentinel encoding; unsafe integers and
  non-primitive values fail the call locally.
- Modern HTTP transports exclude tools with invalid annotations from
  tools/list (warning with the tool name), attach the headers on
  tools/call (fetching the tool list on demand), and after a HeaderMismatch
  refresh tools/list and retry once.
- stdio ignores the annotation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
@simonx1
simonx1 force-pushed the mcp-2026/streamable-http-modern branch from 5817253 to 8f28115 Compare September 5, 2026 12:32
simonx1 and others added 6 commits September 5, 2026 14:33
Codex + grok findings:
- Only schema-bearing keywords are walked: instance data (default,
  examples, enum, const) inside inputSchema is never an annotation.
- Mirroring is a MUST: a call whose tool list cannot be fetched fails
  instead of going out without Mcp-Param headers.
- Transport list caches follow list_changed notifications; a
  HeaderMismatch refresh is announced upward as tools/list_changed so
  Client's cache and the in-flight structured-content validation use the
  refreshed definition; a generation counter stops a stale concurrent
  fetch from overwriting a refreshed list; a failed refresh re-raises the
  HeaderMismatch.
- Peer-controlled text in the new log lines (error messages, property
  names) is escaped and capped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… list fetches

Grok + codex second-round findings on x-mcp-header:
- Integral Floats (42.0) encode as decimal integers; a single-element type
  array names a primitive type; contentSchema and draft-07 dependencies
  are walked for misplaced annotations.
- A list fetch that was invalidated while in flight is never cached, not
  even into an empty cache, and is re-fetched (bounded); tool names in the
  rejection warning are capped like the rest of the peer text.
- The client re-resolves the tool after a call only when the transport's
  tool-list generation changed during the call, independent of its own
  cache state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Codex verification findings on x-mcp-header:

- A result was re-resolved against the transport's *current* tool list
  whenever the list generation moved during the call, so a
  tools/list_changed that merely raced the call (on the response stream or
  from another thread) decided what the result was validated against --
  inventing ValidationErrors in :strict mode, and letting a looser
  replacement pass a result the answering definition forbade. The transport
  now records the definition each tools/call request derives its Mcp-Param-*
  headers from (MCPClient::CalledToolDefinition), and the client validates
  against that one: the retry's refreshed definition after a HeaderMismatch,
  and otherwise the definition in force when the request was sent. A call
  host code nests inside another -- from a notification listener or a
  handler for a server-initiated request -- records into a slot of its own.

- Computed parameter headers were applied after the configured ones and
  nothing cleared a configured header the extraction omitted, so a
  configured Mcp-Param-Region survived a call that omitted `region` and no
  tools/list refresh could correct it. On a modern session the Mcp-Param-*
  namespace is now cleared before the computed headers are attached,
  case-insensitively; legacy sessions, where it has no protocol meaning,
  keep sending it.

New examples pin both, plus what the earlier ones left unpinned: the
mirrored headers agree with the arguments actually serialized into the body
on both POSTs of a HeaderMismatch recovery, the request _meta survives the
re-send, one listed annotated tool exercises the standard and parameter
headers together, and the safe-integer range is checked at both ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The four methods that implement "Custom Headers from Tool Parameters" —
deriving the Mcp-Param-* headers, clearing that reserved namespace, fetching
the list they are derived from, and the refresh-and-retry-once recovery —
are a self-contained piece of this transport. They move to
HttpTransportBase::ParamHeaders, which keeps the module under the length
cop once the branch above this one adds to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…s on plain HTTP

Review round 3 found two defects in the x-mcp-header work.

The HeaderMismatch refresh-and-retry ran inside a `rescue` clause, so an
exception its re-send raised bypassed the sibling clause that handles a
broken response stream — and vice versa. Both orderings were reproducible:
a HeaderMismatch retry whose stream closed surfaced as
ResponseStreamClosedError instead of being re-issued, and a re-issue that
was rejected for its headers surfaced as HeaderMismatchError without a
tools/list refresh. Both re-sends now `retry` one guarded block, so each
recovery still covers the other's re-send. Each is spent once, so a call is
sent at most three times.

The plain HTTP dispatcher invoked host code without opening a
CalledToolDefinition slot of its own, unlike the Streamable HTTP one. A
tools/call nested by a progress listener replaced the outer call's slot, so
the answering definition was lost and :strict validation ran against the
pre-refresh schema. It now wraps the dispatch the same way.

Coverage, driven by four mutations that survived the previous examples:
HeaderMismatch recovery is now pinned as modern-only, argument encoding is
pinned on the wire (sentinel-shaped values, empty strings, control
characters, false, zero, integral floats), modern structured-output
rejection has an example of its own, and the two concurrency examples use
queue barriers and assert the stale fetch's own return value rather than
only the cache. Also added: the recovery, local-rejection, error-detail and
list_changed-invalidation examples run against both HTTP transports;
paginated and shrinking tool lists; more schema-traversal keywords; and
positive assertions on the warnings that were only checked for absence. The
client-level re-resolution example now changes a real annotation instead of
stubbing HeaderParams.headers_for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A modern call_tool reads tools/list first, to derive the call's
Mcp-Param-* headers. A fixture that broke every request therefore lost
the list, and the loss surfaced before the call the example is about ever
went out. Only tools/call breaks now, so the example still pins what it
names: one re-issue, then the loss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
@simonx1
simonx1 force-pushed the mcp-2026/x-mcp-header branch from 1342c25 to 3f8903c Compare September 5, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant