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
7 changed files with 723 additions and 1 deletions
14
README.md
14
README.md
|
|
@ -2510,6 +2510,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:
|
||||
|
|
|
|||
|
|
@ -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 @@ module MCP
|
|||
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 @@ module MCP
|
|||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ require_relative "../configuration"
|
|||
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 @@ module MCP
|
|||
# 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 @@ module MCP
|
|||
@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 @@ module MCP
|
|||
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 @@ module MCP
|
|||
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?<base64>?=`. Safe ASCII values are sent unchanged.
|
||||
|
|
|
|||
242
lib/mcp/client/mcp_param_headers.rb
Normal file
242
lib/mcp/client/mcp_param_headers.rb
Normal file
|
|
@ -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? ? "<root>" : path.join(".")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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