Mirror x-mcp-header tool parameters into Mcp-Param-* headers per SEP-2243 - #498
Open
koic wants to merge 1 commit into
Open
Mirror x-mcp-header tool parameters into Mcp-Param-* headers per SEP-2243#498koic wants to merge 1 commit into
x-mcp-header tool parameters into Mcp-Param-* headers per SEP-2243#498koic wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate
inputSchemaproperties withx-mcp-headerso intermediaries can route on argument values without parsing bodies: the client MUST mirror each annotated argument of atools/callinto anMcp-Param-{Name}header, encode values that cannot ride as plain ASCII field values with the=?base64?...?=sentinel, and omit the header fornullor absent arguments.MCP::Client::HTTPsent only the standardMcp-Method/Mcp-Namemirror headers, so everyhttp-custom-headerscheck of the 2026-07-28 conformance requirements fails.The new
MCP::Client::McpParamHeadersmodule ports the TypeScript SDK'smcpParamHeaderscodec:scanwalks a tool'sinputSchemafor declarations and validates every constraint the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties (numberis accepted alongside the spec'sstring/integer/booleanbecause the published referee annotatestype: "number"parameters, the same accommodation the TypeScript SDK makes), and static reachability through a chain ofpropertieskeys only: an annotation underitems, the combinators, or$defsinvalidates the whole tool definition.buildresolves each declaration's path in the call'sarguments, omitsnull/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::HTTPlearns declarations from thetools/listresponses passing through it (the same source of truth the TypeScript SDK's response cache uses) and mirrors on eachtools/callalongside the existingMcp-Method/Mcp-Nameconstruction. 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_toolsand#toolsalso enforce the spec's MUST: a tool definition whosex-mcp-headerannotations 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_listingand 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.rbcovers the scan constraint matrix (root and non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive conversions (including42.0serializing as42and 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.rbdrives the transport end to end over a modern connection: atools/listteaching the declarations, the followingtools/callcarrying 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.rbcovers the MUST-level exclusion: the invalid definition dropped from#toolswith the warning on a modern connection, and listed unchanged on a legacy one.bundle exec rakeis green.Breaking Changes
None. Requests to servers whose tools carry no
x-mcp-headerannotations are byte-for-byte unchanged, and the new headers only appear for tools that declare them.Types of changes
Checklist