Merge pull request #498 from koic/mirror_x_mcp_header_params_into_headers

Mirror `x-mcp-header` tool parameters into `Mcp-Param-*` headers per SEP-2243
This commit is contained in:
Koichi ITO 2026-08-10 12:20:19 +09:00 • committed by GitHub
commit f0c9665383
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 723 additions and 1 deletions

View file

@ -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

View 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

View file

@ -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: {})