Mirror x-mcp-header tool parameters into Mcp-Param-* headers per 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 commit is contained in:
parent
4fac357343
commit
3511375204
3 changed files with 723 additions and 1 deletions
|
|
@ -821,6 +821,107 @@ module MCP
|
|||
assert_equal({ "result" => { "tools" => [] } }, response)
|
||||
end
|
||||
|
||||
def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers
|
||||
# SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header`
|
||||
# declarations, and the following `tools/call` mirrors the annotated arguments into
|
||||
# `Mcp-Param-*` headers.
|
||||
call_headers = nil
|
||||
client = mcp_param_test_client(
|
||||
tools: [mcp_param_annotated_tool],
|
||||
on_call: ->(headers) { call_headers = headers },
|
||||
)
|
||||
client.connect(mode: :modern)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
||||
client.send_request(request: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "test_custom_headers",
|
||||
arguments: { region: "us-west1", priority: 42, non_ascii_val: "Hello, 世界", null_val: nil },
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal("us-west1", call_headers["Mcp-Param-Region"])
|
||||
assert_equal("42", call_headers["Mcp-Param-Priority"])
|
||||
assert_equal("=?base64?#{["Hello, 世界"].pack("m0")}?=", call_headers["Mcp-Param-NonAsciiVal"])
|
||||
refute(call_headers.key?("Mcp-Param-NullVal"), "a null argument must omit its header")
|
||||
end
|
||||
|
||||
def test_send_request_mirrors_nothing_for_a_tool_with_an_invalid_x_mcp_header_declaration
|
||||
# An invalid tool definition (here a case-insensitive duplicate) mirrors nothing rather than
|
||||
# emitting a partial or malformed header set.
|
||||
invalid_tool = {
|
||||
name: "invalid_duplicate",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
one: { type: "string", "x-mcp-header": "Region" },
|
||||
two: { type: "string", "x-mcp-header": "REGION" },
|
||||
},
|
||||
},
|
||||
}
|
||||
call_headers = nil
|
||||
client = mcp_param_test_client(tools: [invalid_tool], on_call: ->(headers) { call_headers = headers })
|
||||
client.connect(mode: :modern)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
||||
client.send_request(request: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: "invalid_duplicate", arguments: { one: "a", two: "b" } },
|
||||
})
|
||||
|
||||
refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") })
|
||||
assert_equal("tools/call", call_headers["Mcp-Method"])
|
||||
end
|
||||
|
||||
def test_send_request_mirrors_nothing_on_a_legacy_connection
|
||||
# The custom headers exist on the modern lifecycle only (SEP-2243), matching
|
||||
# the TypeScript and Python SDKs: without modern adoption nothing is learned or mirrored.
|
||||
call_headers = nil
|
||||
client = mcp_param_test_client(
|
||||
tools: [mcp_param_annotated_tool],
|
||||
on_call: ->(headers) { call_headers = headers },
|
||||
)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
||||
client.send_request(request: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: "test_custom_headers", arguments: { region: "us-west1" } },
|
||||
})
|
||||
|
||||
refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") })
|
||||
assert_equal("tools/call", call_headers["Mcp-Method"])
|
||||
end
|
||||
|
||||
def test_send_request_prunes_declarations_dropped_by_a_complete_listing
|
||||
# A complete (uncursored, `nextCursor`-less) listing is the full tool universe,
|
||||
# so declarations of unlisted tools are stale and stop mirroring.
|
||||
call_headers = nil
|
||||
listings = [[mcp_param_annotated_tool], []]
|
||||
client = mcp_param_test_client(
|
||||
tools: -> { listings.shift || [] },
|
||||
on_call: ->(headers) { call_headers = headers },
|
||||
)
|
||||
client.connect(mode: :modern)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
||||
client.send_request(request: { jsonrpc: "2.0", id: 2, method: "tools/list" })
|
||||
client.send_request(request: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "test_custom_headers", arguments: { region: "us-west1" } },
|
||||
})
|
||||
|
||||
refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") })
|
||||
end
|
||||
|
||||
def test_send_request_parses_sse_response_when_adapter_does_not_stream
|
||||
sse_body = "event: message\n" \
|
||||
'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \
|
||||
|
|
@ -2088,6 +2189,44 @@ module MCP
|
|||
WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive?
|
||||
end
|
||||
|
||||
# Builds an `HTTP` client over the Faraday test adapter for the SEP-2243 mirroring tests:
|
||||
# the stub serves `server/discover` (so `connect(mode: :modern)` works), answers `tools/list`
|
||||
# with `tools` (an Array, or a Proc returning the listing per request), and captures
|
||||
# the request headers of any other POST via `on_call`.
|
||||
def mcp_param_test_client(tools:, on_call:)
|
||||
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
||||
stub.post("/") do |env|
|
||||
case JSON.parse(env.request_body)["method"]
|
||||
when "server/discover"
|
||||
discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" }
|
||||
[200, { "Content-Type" => "application/json" }, { result: discover }.to_json]
|
||||
when "tools/list"
|
||||
listing = tools.respond_to?(:call) ? tools.call : tools
|
||||
[200, { "Content-Type" => "application/json" }, { result: { tools: listing } }.to_json]
|
||||
else
|
||||
on_call.call(env.request_headers)
|
||||
[200, { "Content-Type" => "application/json" }, { result: { content: [] } }.to_json]
|
||||
end
|
||||
end
|
||||
end
|
||||
HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
|
||||
end
|
||||
|
||||
def mcp_param_annotated_tool
|
||||
{
|
||||
name: "test_custom_headers",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
region: { type: "string", "x-mcp-header": "Region" },
|
||||
priority: { type: "integer", "x-mcp-header": "Priority" },
|
||||
non_ascii_val: { type: "string", "x-mcp-header": "NonAsciiVal" },
|
||||
null_val: { type: "string", "x-mcp-header": "NullVal" },
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def url
|
||||
"http://example.com"
|
||||
end
|
||||
|
|
|
|||
208
test/mcp/client/mcp_param_headers_test.rb
Normal file
208
test/mcp/client/mcp_param_headers_test.rb
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "mcp/client/mcp_param_headers"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
class McpParamHeadersTest < Minitest::Test
|
||||
def test_scan_collects_declarations_from_properties
|
||||
scan = McpParamHeaders.scan({
|
||||
"type" => "object",
|
||||
"properties" => {
|
||||
"region" => { "type" => "string", "x-mcp-header" => "Region" },
|
||||
"priority" => { "type" => "integer", "x-mcp-header" => "Priority" },
|
||||
"verbose" => { "type" => "boolean", "x-mcp-header" => "Verbose" },
|
||||
"plain" => { "type" => "string" },
|
||||
},
|
||||
})
|
||||
|
||||
assert(scan[:valid])
|
||||
assert_equal(
|
||||
[
|
||||
{ path: ["region"], header_name: "Region", type: "string" },
|
||||
{ path: ["priority"], header_name: "Priority", type: "integer" },
|
||||
{ path: ["verbose"], header_name: "Verbose", type: "boolean" },
|
||||
],
|
||||
scan[:declarations],
|
||||
)
|
||||
end
|
||||
|
||||
def test_scan_collects_nested_declarations_and_symbol_keys
|
||||
scan = McpParamHeaders.scan({
|
||||
type: "object",
|
||||
properties: {
|
||||
options: {
|
||||
type: "object",
|
||||
properties: {
|
||||
region: { type: "string", "x-mcp-header": "Region" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert(scan[:valid])
|
||||
assert_equal([{ path: ["options", "region"], header_name: "Region", type: "string" }], scan[:declarations])
|
||||
end
|
||||
|
||||
def test_scan_accepts_number_typed_declarations
|
||||
scan = McpParamHeaders.scan({
|
||||
"type" => "object",
|
||||
"properties" => { "float_val" => { "type" => "number", "x-mcp-header" => "FloatVal" } },
|
||||
})
|
||||
|
||||
assert(scan[:valid])
|
||||
assert_equal("number", scan[:declarations].first[:type])
|
||||
end
|
||||
|
||||
def test_scan_rejects_an_annotation_at_the_schema_root
|
||||
scan = McpParamHeaders.scan({ "type" => "object", "x-mcp-header" => "Root" })
|
||||
|
||||
refute(scan[:valid])
|
||||
assert_includes(scan[:reason], "statically reachable")
|
||||
end
|
||||
|
||||
def test_scan_rejects_annotations_under_non_reachable_keywords
|
||||
["oneOf", "items", "$defs"].each do |keyword|
|
||||
nested = { "type" => "string", "x-mcp-header" => "Hidden" }
|
||||
sub = keyword == "$defs" ? { "entry" => nested } : [nested]
|
||||
scan = McpParamHeaders.scan({ "type" => "object", keyword => sub })
|
||||
|
||||
refute(scan[:valid], "expected the annotation under #{keyword} to invalidate the schema")
|
||||
end
|
||||
end
|
||||
|
||||
def test_scan_rejects_empty_and_non_string_annotations
|
||||
["", 42].each do |annotation|
|
||||
scan = McpParamHeaders.scan({
|
||||
"type" => "object",
|
||||
"properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } },
|
||||
})
|
||||
|
||||
refute(scan[:valid])
|
||||
assert_includes(scan[:reason], "non-empty string")
|
||||
end
|
||||
end
|
||||
|
||||
def test_scan_rejects_non_token_annotation_names
|
||||
["has space", "colon:name", "日本語", "line\nbreak"].each do |annotation|
|
||||
scan = McpParamHeaders.scan({
|
||||
"type" => "object",
|
||||
"properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } },
|
||||
})
|
||||
|
||||
refute(scan[:valid], "expected #{annotation.inspect} to be rejected")
|
||||
assert_includes(scan[:reason], "RFC 9110")
|
||||
end
|
||||
end
|
||||
|
||||
def test_scan_rejects_non_primitive_declaring_properties
|
||||
[{ "type" => "object" }, { "type" => "array" }, {}].each do |extra|
|
||||
scan = McpParamHeaders.scan({
|
||||
"type" => "object",
|
||||
"properties" => { "value" => extra.merge("x-mcp-header" => "Value") },
|
||||
})
|
||||
|
||||
refute(scan[:valid])
|
||||
assert_includes(scan[:reason], "primitive-typed")
|
||||
end
|
||||
end
|
||||
|
||||
def test_scan_rejects_case_insensitively_duplicated_names
|
||||
scan = McpParamHeaders.scan({
|
||||
"type" => "object",
|
||||
"properties" => {
|
||||
"one" => { "type" => "string", "x-mcp-header" => "Region" },
|
||||
"two" => { "type" => "string", "x-mcp-header" => "REGION" },
|
||||
},
|
||||
})
|
||||
|
||||
refute(scan[:valid])
|
||||
assert_includes(scan[:reason], "case-insensitively unique")
|
||||
end
|
||||
|
||||
def test_primitive_to_string_conversions
|
||||
assert_equal("us-west1", McpParamHeaders.primitive_to_string("us-west1"))
|
||||
assert_equal("true", McpParamHeaders.primitive_to_string(true))
|
||||
assert_equal("false", McpParamHeaders.primitive_to_string(false))
|
||||
assert_equal("42", McpParamHeaders.primitive_to_string(42))
|
||||
assert_equal("42", McpParamHeaders.primitive_to_string(42.0))
|
||||
assert_equal("3.14159", McpParamHeaders.primitive_to_string(3.14159))
|
||||
assert_nil(McpParamHeaders.primitive_to_string(2**53 + 1))
|
||||
assert_nil(McpParamHeaders.primitive_to_string(Float::INFINITY))
|
||||
assert_nil(McpParamHeaders.primitive_to_string({ "nested" => true }))
|
||||
end
|
||||
|
||||
def test_encode_value_passes_safe_ascii_through
|
||||
assert_equal("us-west1", McpParamHeaders.encode_value("us-west1"))
|
||||
assert_equal("SELECT * FROM users", McpParamHeaders.encode_value("SELECT * FROM users"))
|
||||
end
|
||||
|
||||
def test_encode_value_wraps_unsafe_values_in_the_base64_sentinel
|
||||
{
|
||||
"" => "",
|
||||
"Hello, 世界" => "Hello, 世界",
|
||||
" padded " => " padded ",
|
||||
"\tindented" => "\tindented",
|
||||
"line1\nline2" => "line1\nline2",
|
||||
"line1\r\nline2" => "line1\r\nline2",
|
||||
"=?base64?Zm9v?=" => "=?base64?Zm9v?=",
|
||||
}.each do |raw, decoded|
|
||||
encoded = McpParamHeaders.encode_value(raw)
|
||||
|
||||
assert_match(/\A=\?base64\?.*\?=\z/, encoded, "expected #{raw.inspect} to be wrapped")
|
||||
payload = encoded.delete_prefix("=?base64?").delete_suffix("?=")
|
||||
assert_equal(decoded, payload.unpack1("m0").force_encoding(Encoding::UTF_8))
|
||||
end
|
||||
end
|
||||
|
||||
def test_build_mirrors_declared_arguments_into_prefixed_headers
|
||||
declarations = [
|
||||
{ path: ["region"], header_name: "Region", type: "string" },
|
||||
{ path: ["priority"], header_name: "Priority", type: "integer" },
|
||||
{ path: ["method_val"], header_name: "Method", type: "string" },
|
||||
]
|
||||
|
||||
headers = McpParamHeaders.build(declarations, { "region" => "us-west1", priority: 42, "method_val" => "test-method" })
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"Mcp-Param-Region" => "us-west1",
|
||||
"Mcp-Param-Priority" => "42",
|
||||
"Mcp-Param-Method" => "test-method",
|
||||
},
|
||||
headers,
|
||||
)
|
||||
end
|
||||
|
||||
def test_build_omits_null_absent_and_non_primitive_values
|
||||
declarations = [
|
||||
{ path: ["null_val"], header_name: "NullVal", type: "string" },
|
||||
{ path: ["absent"], header_name: "Absent", type: "string" },
|
||||
{ path: ["object_val"], header_name: "ObjectVal", type: "string" },
|
||||
]
|
||||
|
||||
headers = McpParamHeaders.build(declarations, { "null_val" => nil, "object_val" => { "a" => 1 } })
|
||||
|
||||
assert_empty(headers)
|
||||
end
|
||||
|
||||
def test_build_reads_nested_paths
|
||||
declarations = [{ path: ["options", "region"], header_name: "Region", type: "string" }]
|
||||
|
||||
headers = McpParamHeaders.build(declarations, { "options" => { "region" => "us-east1" } })
|
||||
|
||||
assert_equal({ "Mcp-Param-Region" => "us-east1" }, headers)
|
||||
end
|
||||
|
||||
def test_build_omits_values_that_cannot_be_represented_as_utf_8
|
||||
declarations = [{ path: ["blob"], header_name: "Blob", type: "string" }]
|
||||
binary = (+"\xff\xfe").force_encoding(Encoding::ASCII_8BIT)
|
||||
|
||||
headers = McpParamHeaders.build(declarations, { "blob" => binary })
|
||||
|
||||
assert_empty(headers)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -375,6 +375,54 @@ module MCP
|
|||
assert_equal([], tools)
|
||||
end
|
||||
|
||||
def test_tools_excludes_invalid_x_mcp_header_definitions_on_a_modern_connection
|
||||
# SEP-2243: on the modern lifecycle a tool definition violating the `x-mcp-header`
|
||||
# constraints MUST be excluded from `tools/list` results, with a warning naming the tool.
|
||||
transport = mock
|
||||
transport.stubs(:modern?).returns(true)
|
||||
mock_response = { "result" => { "tools" => [
|
||||
{ "name" => "valid_tool", "inputSchema" => { "type" => "object" } },
|
||||
{
|
||||
"name" => "invalid_tool",
|
||||
"inputSchema" => {
|
||||
"type" => "object",
|
||||
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
|
||||
},
|
||||
},
|
||||
] } }
|
||||
transport.expects(:send_request).returns(mock_response).once
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
# `rake` runs the suite with `-W0`, which silences `Kernel#warn`; restore warnings
|
||||
# locally so the SHOULD-level warning is observable.
|
||||
original_verbose = $VERBOSE
|
||||
$VERBOSE = false
|
||||
tools = nil
|
||||
_out, warning = capture_io { tools = client.tools }
|
||||
$VERBOSE = original_verbose
|
||||
|
||||
assert_equal(["valid_tool"], tools.map(&:name))
|
||||
assert_includes(warning, "invalid_tool")
|
||||
end
|
||||
|
||||
def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection
|
||||
transport = mock
|
||||
transport.stubs(:modern?).returns(false)
|
||||
mock_response = { "result" => { "tools" => [
|
||||
{
|
||||
"name" => "invalid_tool",
|
||||
"inputSchema" => {
|
||||
"type" => "object",
|
||||
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
|
||||
},
|
||||
},
|
||||
] } }
|
||||
transport.expects(:send_request).returns(mock_response).once
|
||||
client = Client.new(transport: transport)
|
||||
|
||||
assert_equal(["invalid_tool"], client.tools.map(&:name))
|
||||
end
|
||||
|
||||
def test_call_tool_sends_request_to_transport_and_returns_content
|
||||
transport = mock
|
||||
tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {})
|
||||
|
|
|
|||
Loading…
Reference in a new issue