Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion lib/mcp/client.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?<base64>?=`. Safe ASCII values are sent unchanged.
Expand Down
242 changes: 242 additions & 0 deletions lib/mcp/client/mcp_param_headers.rb
Original file line number Diff line number Diff line change
@@ -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