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
|
|
@ -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
|
||||
Loading…
Reference in a new issue