From 3511375204fd2df86fb9804f0109ab89a714784a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 8 Aug 2026 20:38:29 +0900 Subject: [PATCH] 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. --- README.md | 14 ++ lib/mcp/client.rb | 19 +- lib/mcp/client/http.rb | 54 +++++ lib/mcp/client/mcp_param_headers.rb | 242 ++++++++++++++++++++++ test/mcp/client/http_test.rb | 139 +++++++++++++ test/mcp/client/mcp_param_headers_test.rb | 208 +++++++++++++++++++ test/mcp/client_test.rb | 48 +++++ 7 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 lib/mcp/client/mcp_param_headers.rb create mode 100644 test/mcp/client/mcp_param_headers_test.rb diff --git a/README.md b/README.md index f83cdaa5..613cbd5f 100644 --- a/README.md +++ b/README.md @@ -2483,6 +2483,20 @@ Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` afte the server now serves the modern lifecycle and the automatic negotiation adopted it. Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix. +### Custom Headers from Tool Parameters (SEP-2243) + +On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries +an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers, so intermediaries can route +on the values without parsing bodies. The declarations are learned from `tools/list` responses: +list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values +(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`, +and a `null` or absent argument omits its header. + +Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names, +duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from +`tools/list` results on modern connections, with a warning naming the tool. +Legacy connections are unaffected: nothing is learned, mirrored, or excluded. + ## Transport Layer Interface If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 3a35495b..4db01973 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "client/elicitation" +require_relative "client/mcp_param_headers" require_relative "client/modern_envelope" require_relative "client/oauth" require_relative "client/stdio" @@ -248,7 +249,9 @@ def list_tools(cursor: nil, meta: nil, cancellation: nil) response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} - tools = (result["tools"] || []).map do |tool| + tools = (result["tools"] || []).filter_map do |tool| + next if exclude_invalid_x_mcp_header?(tool) + Tool.new( name: tool["name"], description: tool["description"], @@ -573,6 +576,20 @@ def ping(meta: nil, cancellation: nil) private + # SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate + # the spec constraints MUST be excluded from `tools/list` results, so one malformed definition + # does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way. + # Legacy connections, and transports without a lifecycle notion, list everything as before. + def exclude_invalid_x_mcp_header?(tool) + return false unless transport.respond_to?(:modern?) && transport.modern? + + scan = McpParamHeaders.scan(tool["inputSchema"]) + return false if scan[:valid] + + warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}") + true + end + # Resolves the effective SEP-2575 lifecycle mode for `connect`: # # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe, diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 538bfce7..c3d8dc46 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -6,6 +6,7 @@ require_relative "../methods" require_relative "../protocol_deprecations" require_relative "../version" +require_relative "mcp_param_headers" require_relative "modern_envelope" module MCP @@ -39,6 +40,12 @@ class HTTP # and the server transports' request cap. MAX_MESSAGE_BYTES = 4 * 1024 * 1024 + # Upper bound on the tools whose `x-mcp-header` declarations are retained for + # `Mcp-Param-*` mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing + # (the spec's guidance to send without custom headers), so a server rotating tool names across + # `tools/list` responses cannot grow the registry without bound. + MAX_MCP_PARAM_TOOLS = 1000 + # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host # is trivially observed and stolen. @@ -255,6 +262,7 @@ def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYT @listener_thread = nil @modern_client_info = nil @modern_capabilities = nil + @mcp_param_declarations = {} end # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. @@ -380,6 +388,7 @@ def send_request(request:) body = resolve_response_body(stream, response, method, params) capture_session_info(method, response, body) if response + capture_mcp_param_declarations(method, params, body) body rescue MessageTooLargeError => e @@ -712,11 +721,56 @@ def request_metadata_headers(method, params) name = params[:name] || params["name"] name = params[:uri] || params["uri"] unless name.is_a?(String) metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String) + + if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]]) + arguments = params[:arguments] || params["arguments"] + + metadata_headers.merge!(McpParamHeaders.build(declarations, arguments)) + end end metadata_headers end + # Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises, + # so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243). + # The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs, + # so legacy connections learn nothing. Only a valid, non-empty declaration set is kept: + # an invalid tool definition mirrors nothing, + # following the spec's guidance to send without custom headers when no reliable declarations are available. + def capture_mcp_param_declarations(method, params, body) + return unless modern? + return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash) + + tools = body.dig("result", "tools") + return unless tools.is_a?(Array) + + # An uncursored request answered without `nextCursor` is the complete tool universe, + # so knowledge about unlisted tools is stale; the registry is rebuilt from this listing, + # the same pruning the Python SDK applies to complete listings. + cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"]) + complete = !cursor && body.dig("result", "nextCursor").nil? + registry = complete ? {} : @mcp_param_declarations + + tools.each do |tool| + next unless tool.is_a?(Hash) + + name = tool["name"] + next unless name.is_a?(String) + + scan = McpParamHeaders.scan(tool["inputSchema"]) + if scan[:valid] && !scan[:declarations].empty? + next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS + + registry[name] = scan[:declarations] + else + registry.delete(name) + end + end + + @mcp_param_declarations = registry if complete + end + # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF, # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as # `=?base64??=`. Safe ASCII values are sent unchanged. diff --git a/lib/mcp/client/mcp_param_headers.rb b/lib/mcp/client/mcp_param_headers.rb new file mode 100644 index 00000000..4aa4d54e --- /dev/null +++ b/lib/mcp/client/mcp_param_headers.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +module MCP + class Client + # The custom-header half of SEP-2243 (MCP 2026-07-28): scanning a tool's `inputSchema` for + # `x-mcp-header` declarations and encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers, + # with the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values. + # Mirrors the TypeScript SDK's `mcpParamHeaders` codec; the standard-header half (`Mcp-Method`, `Mcp-Name`) + # lives with the transport. + # + # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters + module McpParamHeaders + # The fixed prefix every custom-parameter header carries. + HEADER_PREFIX = "Mcp-Param-" + + # The schema-extension property name a tool's `inputSchema` carries. + X_MCP_HEADER_KEY = "x-mcp-header" + + # RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces, + # control characters (including CR/LF), and the HTTP delimiters. + RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze + + # The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because + # the published conformance referee annotates `type: "number"` parameters and expects them + # mirrored; the TypeScript SDK makes the same accommodation. + PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze + + # JSON Schema keywords the SEP-2243 static-reachability constraint excludes from + # the `properties`-only chain. An `x-mcp-header` under any of these invalidates + # the tool definition rather than being silently ignored. + NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ + "items", + "prefixItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "patternProperties", + "dependentSchemas", + "oneOf", + "anyOf", + "allOf", + "not", + "if", + "then", + "else", + "$defs", + "definitions", + ].freeze + + # Keywords whose value maps names to subschemas rather than being one subschema or a list of them. + OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze + + # Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored; + # the TypeScript SDK refuses unsafe integers the same way. + MAX_SAFE_INTEGER = (2**53) - 1 + + BASE64_SENTINEL_PREFIX = "=?base64?" + BASE64_SENTINEL_SUFFIX = "?=" + + class << self + # Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint + # the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed + # declaring properties, and static reachability through a chain of `properties` keys only. + # Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`, + # or `{ valid: false, reason: "..." }` on the first violation. + def scan(input_schema) + declarations = [] + fault = visit(input_schema, [], true, declarations, {}) + + fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations } + end + + # Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and + # the call's `arguments`. A `null` or absent value omits its header (the spec's MUST-omit rows); + # a non-primitive or non-representable value is omitted rather than emitted malformed. + def build(declarations, arguments) + declarations.each_with_object({}) do |declaration, headers| + value = value_at_path(arguments, declaration[:path]) + next if value.nil? + + string_value = primitive_to_string(value) + next unless string_value + + encoded = begin + encode_value(string_value) + rescue EncodingError + # A string that cannot be represented as UTF-8 (e.g. binary data) has no header + # representation; omit it like the other non-representable values. + next + end + + headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded + end + end + + # Converts a primitive argument to its header string per the spec's type-conversion rules: + # strings pass through, booleans become lowercase `"true"` / `"false"`, and numbers become + # their decimal string. `nil` means "not representable: do not emit a header". + def primitive_to_string(value) + case value + when String + value + when true + "true" + when false + "false" + when Integer + value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil + when Float + return unless value.finite? + + # JSON has one number type: an integral float serializes without the fractional part, + # matching the `String(42.0)` the JavaScript reference emits. + value == value.truncate ? value.truncate.to_s : value.to_s + end + end + + # Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value + # passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`. + def encode_value(value) + return value unless needs_base64?(value) + + "#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}" + end + + private + + def visit(node, path, reachable, declarations, seen_lower) + return unless node.is_a?(Hash) + + if key?(node, X_MCP_HEADER_KEY) + fault = validate_declaration(node, path, reachable, declarations, seen_lower) + + return fault if fault + end + + properties = read(node, "properties") + if properties.is_a?(Hash) + properties.each do |key, child| + fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower) + + return fault if fault + end + end + + # Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass + # through with `reachable: false`, so an annotation under any of them is reported. + # `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs is out of scope. + NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword| + next unless (sub = read(node, keyword)) + + branches = if sub.is_a?(Array) + sub + elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword) + sub.values + else + [sub] + end + + branches.each do |branch| + fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower) + + return fault if fault + end + end + + nil + end + + def validate_declaration(node, path, reachable, declarations, seen_lower) + if !reachable || path.empty? + return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys" + + end + + annotation = read(node, X_MCP_HEADER_KEY) + + unless annotation.is_a?(String) && !annotation.empty? + return "#{path_name(path)}: x-mcp-header MUST be a non-empty string" + end + + unless RFC9110_TOKEN.match?(annotation) + return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token" + end + + type = read(node, "type") + unless type.is_a?(String) && PERMITTED_TYPES.include?(type) + return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties " \ + "(got `#{type.inspect}`)" + end + + lower = annotation.downcase + prior = seen_lower[lower] + if prior + return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)" + end + + seen_lower[lower] = annotation + declarations << { path: path, header_name: annotation, type: type } + nil + end + + # A value cannot ride as a plain ASCII field value when it is empty, already shaped like + # the Base64 sentinel (the spec's ambiguity rule), carries edge whitespace that field parsing + # would strip, or contains a byte outside visible ASCII plus interior tab. + def needs_base64?(value) + return true if value.empty? + return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX) + return true if value != value.strip + + value.each_byte.any? { |byte| byte != 0x09 && !byte.between?(0x20, 0x7e) } + end + + def value_at_path(root, path) + path.reduce(root) do |node, key| + break unless node.is_a?(Hash) + + read(node, key) + end + end + + # Schemas and arguments arrive with string keys off the wire but may carry symbol keys + # when constructed in Ruby; read both forms like the SDK's other readers. + def read(hash, key) + value = hash[key] + + value.nil? ? hash[key.to_sym] : value + end + + def key?(hash, key) + hash.key?(key) || hash.key?(key.to_sym) + end + + def path_name(path) + path.empty? ? "" : path.join(".") + end + end + end + end +end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index da1a0164..ac1128cb 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -821,6 +821,107 @@ def test_send_request_parses_json_response_when_adapter_does_not_stream 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 @@ def requested?(stub) 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 diff --git a/test/mcp/client/mcp_param_headers_test.rb b/test/mcp/client/mcp_param_headers_test.rb new file mode 100644 index 00000000..639a0abd --- /dev/null +++ b/test/mcp/client/mcp_param_headers_test.rb @@ -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 diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index bc7cd14f..b97f9f62 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -375,6 +375,54 @@ def test_tools_returns_empty_array_when_no_tools 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: {})