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 dependencies may 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

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.

Returns:

  • (Boolean)


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.

Parameters:

  • schema (Hash, nil) —

    the tool's inputSchema

Returns:

  • (Array<Array(Array<String>, String)>) —

    [property path, header name] pairs



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.

Returns:

  • (Object, nil) —

    the value, nil when absent (or explicitly null)

Raises:



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).

Parameters:

  • value (String, Integer, true, false) —

    the parameter value

Returns:

  • (String) —

    the header value



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.

Parameters:

  • value (Object) —

    the argument value

  • path (Array<String>) —

    the property path (for messages)

Returns:

  • (String)

Raises:



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.

Parameters:

  • schema (Hash, nil) —

    the tool's inputSchema

  • arguments (Hash, nil) —

    the call arguments (String or Symbol keys)

Returns:

  • (Hash{String => String}) —

    header name => encoded value

Raises:



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.

Parameters:

  • name (String, Symbol) —

    an HTTP header name

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Parameters:

  • text (String) —

    a UTF-8 header value

Returns:

  • (Boolean) —

    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.

Parameters:

  • schema (Hash, nil) —

    the tool's inputSchema

Returns:

  • (Array<String>) —

    violations (empty when the schema is acceptable)



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