Module: MCPClient::HeaderParams
- Defined in:
- lib/mcp_client/header_params.rb
Overview
MCP 2026-07-28 Streamable HTTP "Custom Headers from Tool Parameters"
(SEP-2243). A tool's inputSchema may annotate a property with
x-mcp-header; on the Streamable HTTP transport the client mirrors the
argument value into an Mcp-Param-{name} request header so that
intermediaries can route on it without parsing the body.
This module validates the annotations (clients MUST reject tool definitions that violate the constraints) and extracts the header values for a call (clients MUST mirror the designated values, omitting a header whose argument is absent or null).
Constant Summary collapse
- ANNOTATION =
'x-mcp-header'- HEADER_PREFIX =
'Mcp-Param-'- HEADER_PREFIX_DOWNCASE =
HTTP field names are case-insensitive, so membership of the mirrored namespace is decided on the lower-cased name.
HEADER_PREFIX.downcase.freeze
- TOKEN =
HTTP field-name token: 1*tchar (RFC 9110 Section 5.6.2)
/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/- PRIMITIVE_TYPES =
The only JSON Schema types an annotated property may have.
%w[string integer boolean].freeze
- SAFE_INTEGER_MAX =
Integer values must fit IEEE754 double precision exactly.
(2**53) - 1
- SAFE_INTEGER_MIN =
-SAFE_INTEGER_MAX
- HEADER_SAFE_VALUE =
Header value that may travel as-is: visible ASCII (0x21-0x7E), spaces and tabs only in the interior (RFC 9110 field values; MCP 2026-07-28 Streamable HTTP "Value Encoding"). RFC 9110 field values may also be empty, so an empty string travels as an empty field value rather than as an encoding of nothing.
/\A(?:[\x21-\x7E](?:[\x20-\x7E\t]*[\x21-\x7E])?)?\z/- BASE64_SENTINEL_START =
The Base64 sentinel markers; a plain value that starts with the one and ends with the other must itself be encoded to avoid ambiguity. The rule is on the start and the end alone: "=?base64?=" is sentinel-shaped even though its two markers overlap.
'=?base64?'- BASE64_SENTINEL_END =
'?='- SCHEMA_KEYWORDS =
JSON Schema 2020-12 keywords whose value is one subschema.
%w[additionalProperties items contains not if then else propertyNames unevaluatedProperties unevaluatedItems additionalItems contentSchema].freeze
- SCHEMA_MAP_KEYWORDS =
Keywords whose value is a map of subschemas (draft-07
dependenciesmay hold schemas too). %w[properties patternProperties $defs definitions dependentSchemas dependencies].freeze
- SCHEMA_ARRAY_KEYWORDS =
Keywords whose value is an array of subschemas.
%w[allOf anyOf oneOf prefixItems].freeze
- MAX_REF_HOPS =
How many references the type lookup will follow before giving up.
8
Class Method Summary collapse
- .annotated?(node) ⇒ Boolean private
-
.annotations(schema) ⇒ Array<Array(Array<String>, String)>
The statically reachable annotated properties of an inputSchema.
- .check_annotation(node, path, reachable, document, errors, seen, found) ⇒ Object private
-
.dig_argument(arguments, path) ⇒ Object?
Read the argument at an exact property path, accepting String or Symbol keys at each step.
-
.encode_header_value(value) ⇒ String
Encode a parameter value for an MCP request header (Mcp-Name, Mcp-Param-*): strings as-is when header-safe, integers in decimal, booleans lowercase; anything not safely representable — non-ASCII, control characters, leading/trailing whitespace, or a value that looks like the sentinel — as
=?base64?<b64 of UTF-8>?=. -
.encode_value(value, path) ⇒ String
Encode one mirrored argument, enforcing the primitive-type and safe integer constraints.
-
.headers_for(schema, arguments) ⇒ Hash{String => String}
The
Mcp-Param-*headers for one tools/call. -
.mirrored_header?(name) ⇒ Boolean
Whether an HTTP header name belongs to the mirrored namespace, which the client owns on a modern session: its members are derived from the call's arguments and from nothing else.
- .pointer_step(node, token) ⇒ Object private
-
.primitive_type?(node, document = nil) ⇒ Boolean
private
Whether a property schema declares exactly one of the primitive types.
-
.resolve_local_pointer(document, ref) ⇒ Object
private
Resolve a same-document JSON pointer (RFC 6901) given as a URI fragment.
-
.sentinel_shaped?(text) ⇒ Boolean
Whether the value would read as a Base64 sentinel.
-
.subschemas(key_name, value) ⇒ Object
private
The subschemas held by a keyword's value (none for instance data such as default, examples, enum or const).
-
.typed_node(node, document) ⇒ Object
private
The schema object that states this property's type: the node itself, or what its local
$refchain leads to. -
.validate_schema(schema) ⇒ Array<String>
Check every
x-mcp-headerannotation in an inputSchema against the transport's constraints. -
.walk(node, path, root:, reachable:, document:, errors:, seen:, found:) ⇒ Object
private
Recursive schema walk over schema-bearing keywords only (instance data such as
default,examples,enumorconstis never a schema).
Class Method Details
.annotated?(node) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
283 284 285 |
# File 'lib/mcp_client/header_params.rb', line 283 def annotated?(node) node.key?(ANNOTATION) || node.key?(ANNOTATION.to_sym) end |
.annotations(schema) ⇒ Array<Array(Array<String>, String)>
The statically reachable annotated properties of an inputSchema.
62 63 64 65 66 67 68 |
# File 'lib/mcp_client/header_params.rb', line 62 def annotations(schema) return [] unless schema.is_a?(Hash) found = [] walk(schema, [], root: true, reachable: false, document: schema, errors: [], seen: {}, found: found) found end |
.check_annotation(node, path, reachable, document, errors, seen, found) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'lib/mcp_client/header_params.rb', line 288 def check_annotation(node, path, reachable, document, errors, seen, found) value = node.key?(ANNOTATION) ? node[ANNOTATION] : node[ANNOTATION.to_sym] # Property names are peer-controlled: inspect escapes control characters. where = path.empty? ? 'the schema root' : path.join('.').inspect unless value.is_a?(String) errors << "#{ANNOTATION} at #{where} must be a string" return end errors << "#{ANNOTATION} at #{where} must not be empty" if value.empty? if !value.empty? && !value.match?(TOKEN) errors << "#{ANNOTATION} at #{where} must be an HTTP field-name token (#{value.inspect})" end unless reachable errors << "#{ANNOTATION} at #{where} is not statically reachable via properties keys from the schema root" end unless primitive_type?(node, document) errors << "#{ANNOTATION} at #{where} must be on a primitive property (integer, string or boolean)" end key = value.downcase if seen.key?(key) errors << "#{ANNOTATION} values must be case-insensitively unique: #{value.inspect} at #{where} " \ "duplicates #{seen[key]}" else seen[key] = where end found << [path, value] if reachable && errors.empty? end |
.dig_argument(arguments, path) ⇒ Object?
Read the argument at an exact property path, accepting String or Symbol keys at each step. A step given under both kinds of key with different values is rejected: the JSON body serializes both, which one the server reads is its business, and no header can agree with an argument that is two values.
152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/mcp_client/header_params.rb', line 152 def dig_argument(arguments, path) path.reduce(arguments) do |node, key| return nil unless node.is_a?(Hash) if node.key?(key) && node.key?(key.to_sym) && node[key] != node[key.to_sym] raise MCPClient::Errors::ValidationError, "Argument #{path.join('.')} is given under both a String and a Symbol key with different values" end node.key?(key) ? node[key] : node[key.to_sym] end end |
.encode_header_value(value) ⇒ String
Encode a parameter value for an MCP request header (Mcp-Name,
Mcp-Param-*): strings as-is when header-safe, integers in decimal,
booleans lowercase; anything not safely representable — non-ASCII,
control characters, leading/trailing whitespace, or a value that looks
like the sentinel — as =?base64?<b64 of UTF-8>?=.
A Ruby String carries an encoding of its own, and the value being mirrored is the one the JSON body carries: UTF-8. The conversion therefore comes first — deciding header safety on, say, UTF-16 bytes would be deciding it on a different string (and an ASCII pattern cannot even be matched against one).
106 107 108 109 110 111 |
# File 'lib/mcp_client/header_params.rb', line 106 def encode_header_value(value) text = value.to_s.encode('UTF-8') return text if text.match?(HEADER_SAFE_VALUE) && !sentinel_shaped?(text) "=?base64?#{[text].pack('m0')}?=" end |
.encode_value(value, path) ⇒ String
Encode one mirrored argument, enforcing the primitive-type and safe integer constraints.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/mcp_client/header_params.rb', line 119 def encode_value(value, path) # JSON has no integer type: a schema integer may be parsed as 42.0. value = value.to_i if value.is_a?(Float) && value.finite? && value == value.floor case value when Integer unless value.between?(SAFE_INTEGER_MIN, SAFE_INTEGER_MAX) raise MCPClient::Errors::ValidationError, "Argument #{path.join('.')} is mirrored into an HTTP header and must be within the safe " \ "integer range (#{SAFE_INTEGER_MIN}..#{SAFE_INTEGER_MAX})" end value.to_s when String, true, false encode_header_value(value) else raise MCPClient::Errors::ValidationError, "Argument #{path.join('.')} is mirrored into an HTTP header and must be a primitive " \ "(string, integer or boolean), got #{value.class}" end end |
.headers_for(schema, arguments) ⇒ Hash{String => String}
The Mcp-Param-* headers for one tools/call.
84 85 86 87 88 89 90 91 |
# File 'lib/mcp_client/header_params.rb', line 84 def headers_for(schema, arguments) annotations(schema).each_with_object({}) do |(path, name), headers| value = dig_argument(arguments, path) next if value.nil? headers["#{HEADER_PREFIX}#{name}"] = encode_value(value, path) end end |
.mirrored_header?(name) ⇒ Boolean
Whether an HTTP header name belongs to the mirrored namespace, which the client owns on a modern session: its members are derived from the call's arguments and from nothing else.
75 76 77 |
# File 'lib/mcp_client/header_params.rb', line 75 def mirrored_header?(name) name.to_s.downcase.start_with?(HEADER_PREFIX_DOWNCASE) end |
.pointer_step(node, token) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
275 276 277 278 279 280 |
# File 'lib/mcp_client/header_params.rb', line 275 def pointer_step(node, token) case node when Hash then node.key?(token) ? node[token] : node[token.to_sym] when Array then token.match?(/\A(?:0|[1-9][0-9]*)\z/) ? node[token.to_i] : nil end end |
.primitive_type?(node, document = nil) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Whether a property schema declares exactly one of the primitive types.
A type array naming the one type is the same declaration as the bare
string, and JSON Schema has no other way to spell a nullable primitive
than to union it with "null": the constraint is on the property's type,
while a null value has its own rule -- the header is omitted -- so
dropping the whole tool over ["string", "null"] would reject a schema
the transport can mirror perfectly well.
A property that states its type through a reference states it all the
same: JSON Schema 2020-12 evaluates $ref beside its siblings (Core
8.2.3.1), so {"$ref": "#/$defs/r", "x-mcp-header": "Region"} is a
primitive property whenever the target is one. That is separate from
the reachability rule, which is about where the ANNOTATION sits and
still never passes through a reference.
226 227 228 229 230 231 232 233 |
# File 'lib/mcp_client/header_params.rb', line 226 def primitive_type?(node, document = nil) node = typed_node(node, document) return false unless node.is_a?(Hash) type = node.key?('type') ? node['type'] : node[:type] declared = Array(type) - ['null'] declared.size == 1 && declared.first.is_a?(String) && PRIMITIVE_TYPES.include?(declared.first) end |
.resolve_local_pointer(document, ref) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Resolve a same-document JSON pointer (RFC 6901) given as a URI fragment.
260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/mcp_client/header_params.rb', line 260 def resolve_local_pointer(document, ref) return nil unless document.is_a?(Hash) && ref.start_with?('#') pointer = ref[1..] return document if pointer.empty? return nil unless pointer.start_with?('/') pointer.split('/', -1).drop(1).reduce(document) do |node, token| return nil if token.include?('%') pointer_step(node, token.gsub('~1', '/').gsub('~0', '~')) end end |
.sentinel_shaped?(text) ⇒ Boolean
Returns whether the value would read as a Base64 sentinel.
141 142 143 |
# File 'lib/mcp_client/header_params.rb', line 141 def sentinel_shaped?(text) text.start_with?(BASE64_SENTINEL_START) && text.end_with?(BASE64_SENTINEL_END) end |
.subschemas(key_name, value) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
The subschemas held by a keyword's value (none for instance data such as default, examples, enum or const).
198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/mcp_client/header_params.rb', line 198 def subschemas(key_name, value) if SCHEMA_MAP_KEYWORDS.include?(key_name) value.is_a?(Hash) ? value.values : [] elsif SCHEMA_ARRAY_KEYWORDS.include?(key_name) || (key_name == 'items' && value.is_a?(Array)) Array(value) elsif SCHEMA_KEYWORDS.include?(key_name) [value] else [] end end |
.typed_node(node, document) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
The schema object that states this property's type: the node itself, or
what its local $ref chain leads to. A reference this client cannot
resolve on its own -- an external URI, a pointer into nothing, a cycle,
or a name whose pointer escapes are percent-encoded -- resolves to
nothing, and the property is treated as one whose type is unstated: the
tool is excluded rather than mirrored on a guess.
245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/mcp_client/header_params.rb', line 245 def typed_node(node, document) hops = 0 seen = [] while node.is_a?(Hash) && !node.key?('type') && !node.key?(:type) ref = node.key?('$ref') ? node['$ref'] : node[:$ref] return nil unless ref.is_a?(String) && !seen.include?(ref) && (hops += 1) <= MAX_REF_HOPS seen << ref node = resolve_local_pointer(document, ref) end node end |
.validate_schema(schema) ⇒ Array<String>
Check every x-mcp-header annotation in an inputSchema against the
transport's constraints.
51 52 53 54 55 56 57 |
# File 'lib/mcp_client/header_params.rb', line 51 def validate_schema(schema) return [] unless schema.is_a?(Hash) errors = [] walk(schema, [], root: true, reachable: false, document: schema, errors: errors, seen: {}, found: []) errors end |
.walk(node, path, root:, reachable:, document:, errors:, seen:, found:) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Recursive schema walk over schema-bearing keywords only (instance data
such as default, examples, enum or const is never a schema). A
node is a reachable property when the chain from the root to it
consists solely of properties keys; annotations anywhere else (items,
composition and conditional keywords, $defs, $ref targets, the root
itself) invalidate the tool.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/mcp_client/header_params.rb', line 180 def walk(node, path, root:, reachable:, document:, errors:, seen:, found:) return unless node.is_a?(Hash) ctx = { document: document, errors: errors, seen: seen, found: found } check_annotation(node, path, reachable, document, errors, seen, found) if annotated?(node) node.each do |key, value| key_name = key.to_s if key_name == 'properties' && value.is_a?(Hash) value.each { |name, prop| walk(prop, path + [name.to_s], root: false, reachable: root || reachable, **ctx) } else subschemas(key_name, value).each { |sub| walk(sub, path + [key_name], root: false, reachable: false, **ctx) } end end end |