Skip to content

Mirror x-mcp-header tool parameters into Mcp-Param-* headers per SEP-2243 - #498

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:mirror_x_mcp_header_params_into_headers
Open

Mirror x-mcp-header tool parameters into Mcp-Param-* headers per SEP-2243#498
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:mirror_x_mcp_header_params_into_headers

Conversation

@koic

@koic koic commented Aug 8, 2026

Copy link
Copy Markdown
Member

Motivation and Context

SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate inputSchema properties with x-mcp-header so intermediaries can route on argument values without parsing bodies: the client MUST mirror each annotated argument of a tools/call into an Mcp-Param-{Name} header, encode values that cannot ride as plain ASCII field values with the =?base64?...?= sentinel, and omit the header for null or absent arguments. MCP::Client::HTTP sent only the standard Mcp-Method/Mcp-Name mirror headers, so every http-custom-headers check of the 2026-07-28 conformance requirements fails.

The new MCP::Client::McpParamHeaders module ports the TypeScript SDK's mcpParamHeaders codec:

  • scan walks a tool's inputSchema for declarations and validates every constraint the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties (number is accepted alongside the spec's string/integer/boolean because the published referee annotates type: "number" parameters, the same accommodation the TypeScript SDK makes), and static reachability through a chain of properties keys only: an annotation under items, the combinators, or $defs invalidates the whole tool definition.
  • build resolves each declaration's path in the call's arguments, omits null/absent and non-representable values (unsafe integers, non-finite floats, non-primitives), converts primitives per the spec's rules, and encodes with the sentinel exactly where a plain ASCII field value cannot carry the value (empty, edge whitespace, bytes outside visible ASCII, sentinel-shaped input).

MCP::Client::HTTP learns declarations from the tools/list responses passing through it (the same source of truth the TypeScript SDK's response cache uses) and mirrors on each tools/call alongside the existing Mcp-Method/Mcp-Name construction. Learning and mirroring exist on the modern lifecycle only, matching both reference SDKs. An invalid tool definition mirrors nothing, per the spec's guidance to send without custom headers when no reliable declarations are available; a complete (uncursored, nextCursor-less) listing rebuilds the registry so declarations of unlisted tools stop mirroring (the Python SDK's pruning), and the registry is capped (MAX_MCP_PARAM_TOOLS, 1000) so a server rotating tool names cannot grow it without bound.

On the modern lifecycle, MCP::Client#list_tools and #tools also enforce the spec's MUST: a tool definition whose x-mcp-header annotations violate the constraints is excluded from the listing, with a warning naming the tool and the reason (the spec's SHOULD), so one malformed definition does not block the valid tools - the same filtering the Python SDK's _absorb_tool_listing and the TypeScript SDK's cached-listing finalize apply. A value that cannot be represented as UTF-8 omits its header like the other non-representable values. The server-side validation half of the codec is a follow-up.

How Has This Been Tested?

New test/mcp/client/mcp_param_headers_test.rb covers the scan constraint matrix (root and non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive conversions (including 42.0 serializing as 42 and unsafe integers refusing), the encoding matrix (plain pass-through, empty, non-ASCII, edge whitespace, CR/LF, sentinel-shaped input), and header construction with nested paths and omitted values (including a value that cannot be represented as UTF-8). test/mcp/client/http_test.rb drives the transport end to end over a modern connection: a tools/list teaching the declarations, the following tools/call carrying the mirrored headers, an invalid declaration mirroring nothing, a legacy connection learning nothing, and a complete listing pruning the declarations of unlisted tools. test/mcp/client_test.rb covers the MUST-level exclusion: the invalid definition dropped from #tools with the warning on a modern connection, and listed unchanged on a legacy one. bundle exec rake is green.

Breaking Changes

None. Requests to servers whose tools carry no x-mcp-header annotations are byte-for-byte unchanged, and the new headers only appear for tools that declare them.

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

…SEP-2243

## Motivation and Context

SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate `inputSchema` properties with
`x-mcp-header` so intermediaries can route on argument values without parsing bodies: the client MUST
mirror each annotated argument of a `tools/call` into an `Mcp-Param-{Name}` header, encode values that
cannot ride as plain ASCII field values with the `=?base64?...?=` sentinel, and omit the header for
`null` or absent arguments. `MCP::Client::HTTP` sent only the standard `Mcp-Method`/`Mcp-Name` mirror
headers, so every `http-custom-headers` check of the 2026-07-28 conformance requirements fails.

The new `MCP::Client::McpParamHeaders` module ports the TypeScript SDK's `mcpParamHeaders` codec:

- `scan` walks a tool's `inputSchema` for declarations and validates every constraint the spec places
  on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties
  (`number` is accepted alongside the spec's `string`/`integer`/`boolean` because the published
  referee annotates `type: "number"` parameters, the same accommodation the TypeScript SDK makes),
  and static reachability through a chain of `properties` keys only: an annotation under `items`,
  the combinators, or `$defs` invalidates the whole tool definition.
- `build` resolves each declaration's path in the call's `arguments`, omits `null`/absent and
  non-representable values (unsafe integers, non-finite floats, non-primitives), converts primitives
  per the spec's rules, and encodes with the sentinel exactly where a plain ASCII field value
  cannot carry the value (empty, edge whitespace, bytes outside visible ASCII, sentinel-shaped input).

`MCP::Client::HTTP` learns declarations from the `tools/list` responses passing through it (the same
source of truth the TypeScript SDK's response cache uses) and mirrors on each `tools/call` alongside
the existing `Mcp-Method`/`Mcp-Name` construction. Learning and mirroring exist on the modern
lifecycle only, matching both reference SDKs. An invalid tool definition mirrors nothing, per
the spec's guidance to send without custom headers when no reliable declarations are available;
a complete (uncursored, `nextCursor`-less) listing rebuilds the registry so declarations of
unlisted tools stop mirroring (the Python SDK's pruning), and the registry is capped
(`MAX_MCP_PARAM_TOOLS`, 1000) so a server rotating tool names cannot grow it without bound.

On the modern lifecycle, `MCP::Client#list_tools` and `#tools` also enforce the spec's MUST:
a tool definition whose `x-mcp-header` annotations violate the constraints is excluded from
the listing, with a warning naming the tool and the reason (the spec's SHOULD), so one malformed
definition does not block the valid tools - the same filtering the Python SDK's
`_absorb_tool_listing` and the TypeScript SDK's cached-listing finalize apply. A value that cannot
be represented as UTF-8 omits its header like the other non-representable values.
The README documents the mirroring and the exclusion under the lifecycle negotiation section.
The server-side validation half of the codec is a follow-up.

## How Has This Been Tested?

New `test/mcp/client/mcp_param_headers_test.rb` covers the scan constraint matrix (root and
non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive
conversions (including `42.0` serializing as `42` and unsafe integers refusing), the encoding matrix
(plain pass-through, empty, non-ASCII, edge whitespace, CR/LF, sentinel-shaped input), and header
construction with nested paths and omitted values (including a value that cannot be represented
as UTF-8). `test/mcp/client/http_test.rb` drives the transport end to end over a modern connection:
a `tools/list` teaching the declarations, the following `tools/call` carrying the mirrored headers,
an invalid declaration mirroring nothing, a legacy connection learning nothing, and a complete
listing pruning the declarations of unlisted tools. `test/mcp/client_test.rb` covers the MUST-level
exclusion: the invalid definition dropped from `#tools` with the warning on a modern connection,
and listed unchanged on a legacy one. `bundle exec rake` is green.

## Breaking Changes

None. Requests to servers whose tools carry no `x-mcp-header` annotations are byte-for-byte unchanged,
and the new headers only appear for tools that declare them.
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