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:
commit
f0c9665383
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