mcp 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # The custom-header half of SEP-2243 (MCP 2026-07-28), shared by the client and the server transport: scanning a tool's `inputSchema`
5
+ # for `x-mcp-header` declarations, encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers with
6
+ # the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values, decoding such headers, and checking that
7
+ # the headers a request carries agree with the arguments in its body. Mirrors the TypeScript SDK's `mcpParamHeaders` codec;
8
+ # the standard-header half (`Mcp-Method`, `Mcp-Name`) lives with the transports.
9
+ #
10
+ # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters
11
+ module McpParamHeaders
12
+ # The fixed prefix every custom-parameter header carries.
13
+ HEADER_PREFIX = "Mcp-Param-"
14
+
15
+ # The schema-extension property name a tool's `inputSchema` carries.
16
+ X_MCP_HEADER_KEY = "x-mcp-header"
17
+
18
+ # RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces,
19
+ # control characters (including CR/LF), and the HTTP delimiters.
20
+ RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
21
+
22
+ # The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because
23
+ # the published conformance referee annotates `type: "number"` parameters and expects them mirrored;
24
+ # the TypeScript SDK makes the same accommodation.
25
+ PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze
26
+
27
+ # JSON Schema keywords the SEP-2243 static-reachability constraint excludes from the `properties`-only chain.
28
+ # An `x-mcp-header` under any of these invalidates the tool definition rather than being silently ignored.
29
+ NON_REACHABLE_SUBSCHEMA_KEYWORDS = [
30
+ "items",
31
+ "prefixItems",
32
+ "contains",
33
+ "additionalProperties",
34
+ "unevaluatedProperties",
35
+ "unevaluatedItems",
36
+ "propertyNames",
37
+ "patternProperties",
38
+ "dependentSchemas",
39
+ "oneOf",
40
+ "anyOf",
41
+ "allOf",
42
+ "not",
43
+ "if",
44
+ "then",
45
+ "else",
46
+ "contentSchema",
47
+ "$defs",
48
+ "definitions",
49
+ ].freeze
50
+
51
+ # Keywords whose value maps names to subschemas rather than being one subschema or a list of them.
52
+ OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze
53
+
54
+ # Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored;
55
+ # the TypeScript SDK refuses unsafe integers the same way.
56
+ MAX_SAFE_INTEGER = (2**53) - 1
57
+
58
+ BASE64_SENTINEL_PREFIX = "=?base64?"
59
+ BASE64_SENTINEL_SUFFIX = "?="
60
+
61
+ # The decimal numerals the numeric comparison of an integer or number declaration accepts from a header:
62
+ # the rendering of an integer (`42`, `-7`), or one with a fraction, which the spec's SHOULD equates to the integer (`42.0`).
63
+ # Anything else (`0x2a`, `1e3`, `042`, ` 42`) is compared as a string and so never agrees by accident.
64
+ CANONICAL_DECIMAL = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?\z/.freeze
65
+
66
+ NUMERIC_TYPES = ["integer", "number"].freeze
67
+
68
+ # The longest numeral the numeric comparison reads, which bounds the arbitrary-precision parsing a header can ask for.
69
+ # A longer numeral is judged by byte equality alone: that still accepts the rendering a client mirrors,
70
+ # however long (an integral double renders as its digits), and refuses every other spelling of the number.
71
+ MAX_NUMERAL_BYTES = 64
72
+
73
+ class << self
74
+ # Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint the spec places on them:
75
+ # RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties, and static reachability through
76
+ # a chain of `properties` keys only. Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`,
77
+ # or `{ valid: false, reason: "..." }` on the first violation.
78
+ def scan(input_schema)
79
+ declarations = []
80
+ fault = visit(input_schema, [], true, declarations, {})
81
+
82
+ fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations }
83
+ end
84
+
85
+ # Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and the call's `arguments`.
86
+ # A `null` or absent value omits its header (the spec's MUST-omit rows); a non-primitive or non-representable value is
87
+ # omitted rather than emitted malformed.
88
+ def build(declarations, arguments)
89
+ declarations.each_with_object({}) do |declaration, headers|
90
+ value = value_at_path(arguments, declaration[:path])
91
+ next if value.nil?
92
+
93
+ string_value = primitive_to_string(value)
94
+ next unless string_value
95
+
96
+ encoded = begin
97
+ encode_value(string_value)
98
+ rescue EncodingError
99
+ # A string that cannot be represented as UTF-8 (e.g. binary data) has no header representation;
100
+ # omit it like the other non-representable values.
101
+ next
102
+ end
103
+
104
+ headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded
105
+ end
106
+ end
107
+
108
+ # Converts a primitive argument to its header string per the spec's type-conversion rules: strings pass through,
109
+ # booleans become lowercase `"true"` / `"false"`, and numbers become their decimal string.
110
+ # `nil` means "not representable: do not emit a header".
111
+ def primitive_to_string(value)
112
+ case value
113
+ when String
114
+ value
115
+ when true
116
+ "true"
117
+ when false
118
+ "false"
119
+ when Integer
120
+ value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil
121
+ when Float
122
+ return unless value.finite?
123
+
124
+ # JSON has one number type: an integral float serializes without the fractional part,
125
+ # matching the `String(42.0)` the JavaScript reference emits.
126
+ value == value.truncate ? value.truncate.to_s : value.to_s
127
+ end
128
+ end
129
+
130
+ # Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value
131
+ # passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`.
132
+ def encode_value(value)
133
+ return value unless needs_base64?(value)
134
+
135
+ "#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}"
136
+ end
137
+
138
+ # Decodes an `Mcp-Param-{Name}` header value: one carrying the sentinel yields its payload decoded as UTF-8,
139
+ # any other value is taken literally, as the spec's examples show. `nil` marks a value the server has to refuse rather
140
+ # than compare: one holding a byte outside the field-value set (the spec's invalid characters; a conforming client
141
+ # wraps such a value), or a sentinel whose payload is not canonical Base64 (padding, alphabet, trailing bits) or
142
+ # not valid UTF-8, since a lenient decoder would let two different header values stand for one body value.
143
+ def decode_value(value)
144
+ return unless field_value?(value)
145
+ return value unless sentinel_wrapped?(value)
146
+
147
+ payload_size = value.bytesize - BASE64_SENTINEL_PREFIX.bytesize - BASE64_SENTINEL_SUFFIX.bytesize
148
+ decoded = value.byteslice(BASE64_SENTINEL_PREFIX.bytesize, payload_size).unpack1("m0").force_encoding(Encoding::UTF_8)
149
+ decoded.valid_encoding? ? decoded : nil
150
+ rescue ArgumentError
151
+ nil
152
+ end
153
+
154
+ # The server side of the spec's table for custom headers: for each declaration, the `Mcp-Param-{Name}` header and
155
+ # the body value at the declared path are present together and agree once the header is decoded, or are absent together.
156
+ # The block is called with each declared header name and returns the raw field value, or `nil` when the request
157
+ # does not carry it. Returns a message naming the first disagreement by header and argument path, never by value,
158
+ # or `nil` when every declaration agrees.
159
+ def mismatch(declarations, arguments)
160
+ declarations.each do |declaration|
161
+ header_name = "#{HEADER_PREFIX}#{declaration[:header_name]}"
162
+ raw = yield(header_name)
163
+ value = value_at_path(arguments, declaration[:path])
164
+ argument = declaration[:path].join(".")
165
+
166
+ if value.nil?
167
+ # The spec has the server expect no header for a null or absent value. One sent anyway claims a value
168
+ # the body does not carry, which is exactly what an intermediary acting on the header would act on.
169
+ return "#{header_name} header is present but the request body carries no `#{argument}` argument" if raw
170
+
171
+ next
172
+ end
173
+
174
+ rendered = primitive_to_string(value)
175
+ if rendered.nil?
176
+ # A value without a header rendering (an object, an array, an integer past the safe range) is one
177
+ # a conforming client omits the header for, so a header claiming it can never agree with it.
178
+ return "#{header_name} header does not match the `#{argument}` argument" if raw
179
+
180
+ next
181
+ end
182
+
183
+ return "#{header_name} header is missing but the request body carries a `#{argument}` argument" if raw.nil?
184
+ return "#{header_name} header holds a character that is not allowed in a field value" unless field_value?(raw)
185
+
186
+ decoded = decode_value(raw)
187
+ return "#{header_name} header carries a malformed Base64 value" if decoded.nil?
188
+
189
+ unless agrees?(declaration[:type], value, rendered, decoded)
190
+ return "#{header_name} header does not match the `#{argument}` argument"
191
+ end
192
+ end
193
+
194
+ nil
195
+ end
196
+
197
+ private
198
+
199
+ # A decoded header agrees with a body value when it equals the value's header rendering byte for byte, or, for
200
+ # an integer or number declaration with a numeric body value, when it is a canonical decimal of the same number
201
+ # within `MAX_NUMERAL_BYTES`: the spec's SHOULD that `42.0` equals `42`. An integer compares exactly, so a value
202
+ # past the double range still compares; a float compares as the double the header denotes.
203
+ def agrees?(type, value, rendered, decoded)
204
+ return true if decoded.b == rendered.b
205
+ return false unless NUMERIC_TYPES.include?(type) && value.is_a?(Numeric)
206
+ return false unless decoded.bytesize <= MAX_NUMERAL_BYTES && CANONICAL_DECIMAL.match?(decoded)
207
+
208
+ value.is_a?(Integer) ? Rational(decoded) == value : Float(decoded) == value
209
+ end
210
+
211
+ def visit(node, path, reachable, declarations, seen_lower)
212
+ return unless node.is_a?(Hash)
213
+
214
+ if key?(node, X_MCP_HEADER_KEY)
215
+ fault = validate_declaration(node, path, reachable, declarations, seen_lower)
216
+
217
+ return fault if fault
218
+ end
219
+
220
+ properties = read(node, "properties")
221
+ if properties.is_a?(Hash)
222
+ properties.each do |key, child|
223
+ fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower)
224
+
225
+ return fault if fault
226
+ end
227
+ end
228
+
229
+ # Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass through with `reachable: false`,
230
+ # so an annotation under any of them is reported. `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs
231
+ # is out of scope.
232
+ NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword|
233
+ next unless (sub = read(node, keyword))
234
+
235
+ branches = if sub.is_a?(Array)
236
+ sub
237
+ elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword)
238
+ sub.values
239
+ else
240
+ [sub]
241
+ end
242
+
243
+ branches.each do |branch|
244
+ fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower)
245
+
246
+ return fault if fault
247
+ end
248
+ end
249
+
250
+ nil
251
+ end
252
+
253
+ def validate_declaration(node, path, reachable, declarations, seen_lower)
254
+ if !reachable || path.empty?
255
+ return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys"
256
+ end
257
+
258
+ annotation = read(node, X_MCP_HEADER_KEY)
259
+
260
+ unless annotation.is_a?(String) && !annotation.empty?
261
+ return "#{path_name(path)}: x-mcp-header MUST be a non-empty string"
262
+ end
263
+
264
+ unless RFC9110_TOKEN.match?(annotation)
265
+ return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token"
266
+ end
267
+
268
+ type = read(node, "type")
269
+ unless type.is_a?(String) && PERMITTED_TYPES.include?(type)
270
+ return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties (got `#{type.inspect}`)"
271
+ end
272
+
273
+ lower = annotation.downcase
274
+ prior = seen_lower[lower]
275
+ if prior
276
+ return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)"
277
+ end
278
+
279
+ seen_lower[lower] = annotation
280
+ declarations << { path: path, header_name: annotation, type: type }
281
+ nil
282
+ end
283
+
284
+ # A value cannot ride as a plain ASCII field value when it is empty, already shaped like the Base64 sentinel
285
+ # (the spec's ambiguity rule), carries edge whitespace that field parsing would strip, or contains
286
+ # a byte outside visible ASCII plus interior tab.
287
+ def needs_base64?(value)
288
+ return true if value.empty?
289
+ return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX)
290
+ return true if value != value.strip
291
+
292
+ !field_value?(value)
293
+ end
294
+
295
+ def sentinel_wrapped?(value)
296
+ return false if value.bytesize < BASE64_SENTINEL_PREFIX.bytesize + BASE64_SENTINEL_SUFFIX.bytesize
297
+
298
+ value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX)
299
+ end
300
+
301
+ # The bytes RFC 9110 lets a field value carry, which the spec's value encoding keeps a plain header within:
302
+ # visible ASCII, space, and horizontal tab. Checked byte by byte, so a value of any encoding is judged the same.
303
+ def field_value?(value)
304
+ value.each_byte.all? { |byte| byte == 0x09 || byte.between?(0x20, 0x7e) }
305
+ end
306
+
307
+ def value_at_path(root, path)
308
+ path.reduce(root) do |node, key|
309
+ break unless node.is_a?(Hash)
310
+
311
+ read(node, key)
312
+ end
313
+ end
314
+
315
+ # Schemas and arguments arrive with string keys off the wire but may carry symbol keys when constructed in Ruby;
316
+ # read both forms like the SDK's other readers.
317
+ def read(hash, key)
318
+ value = hash[key]
319
+
320
+ value.nil? ? hash[key.to_sym] : value
321
+ end
322
+
323
+ def key?(hash, key)
324
+ hash.key?(key) || hash.key?(key.to_sym)
325
+ end
326
+
327
+ def path_name(path)
328
+ path.empty? ? "<root>" : path.join(".")
329
+ end
330
+ end
331
+ end
332
+ end
@@ -39,6 +39,15 @@ module MCP
39
39
 
40
40
  DEFAULT_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"].freeze
41
41
 
42
+ EC_CURVE_ALGORITHMS = {
43
+ "prime256v1" => "ES256",
44
+ "secp256r1" => "ES256",
45
+ "secp384r1" => "ES384",
46
+ "secp521r1" => "ES512",
47
+ "secp256k1" => "ES256K",
48
+ }.freeze
49
+ private_constant :EC_CURVE_ALGORITHMS
50
+
42
51
  HMAC_ALGORITHM_PATTERN = /\AHS\d+\z/i.freeze
43
52
  private_constant :HMAC_ALGORITHM_PATTERN
44
53
 
@@ -126,12 +135,32 @@ module MCP
126
135
  leeway: @leeway,
127
136
  }
128
137
 
129
- if @key
130
- JWT.decode(token, @key, true, options)
131
- elsif @static_jwks
132
- JWT.decode(token, nil, true, options.merge(jwks: @static_jwks))
138
+ JWT.decode(token, nil, true, options) do |header|
139
+ raise InvalidTokenError, "Invalid token" unless @key || header["kid"]
140
+
141
+ key = @key || JWT::JWK::KeyFinder.new(jwks: @static_jwks || jwk_loader).key_for(header["kid"])
142
+ keys = Array(key).select { |candidate| algorithm_matches_key?(header["alg"], candidate) }
143
+ raise InvalidTokenError, "Invalid token" if keys.empty?
144
+
145
+ keys
146
+ end
147
+ end
148
+
149
+ def algorithm_matches_key?(algorithm, key)
150
+ case key
151
+ when OpenSSL::PKey::RSA
152
+ algorithm.match?(/\A(?:RS|PS)(?:256|384|512)\z/i)
153
+ when OpenSSL::PKey::EC, OpenSSL::PKey::EC::Point
154
+ curve_algorithm = EC_CURVE_ALGORITHMS[key.group.curve_name]
155
+ curve_algorithm && curve_algorithm.casecmp?(algorithm)
156
+ when String
157
+ algorithm.match?(/\AHS(?:256|384|512)\z/i)
158
+ when JWT::JWK::KeyBase
159
+ algorithm_matches_key?(algorithm, key.verify_key)
133
160
  else
134
- JWT.decode(token, nil, true, options.merge(jwks: jwk_loader))
161
+ ed25519_key = (defined?(Ed25519::VerifyKey) && key.is_a?(Ed25519::VerifyKey)) ||
162
+ (defined?(RbNaCl::Signatures::Ed25519::VerifyKey) && key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey))
163
+ ed25519_key && ["EdDSA", "Ed25519"].any? { |name| name.casecmp?(algorithm) }
135
164
  end
136
165
  end
137
166
 
@@ -96,6 +96,18 @@ module MCP
96
96
  response
97
97
  end
98
98
 
99
+ # Sessions a server-wide log broadcast is offered to, each applying its own level or the server default.
100
+ # Public only so that `Server#notify_log_message` can call it. Custom transports may implement this hook;
101
+ # returning `nil` keeps the server-wide filter.
102
+ #
103
+ # @api private
104
+ # @return [Array<ServerSession>, nil]
105
+ def logging_sessions
106
+ return unless @session
107
+
108
+ @session.era == :legacy ? [@session] : []
109
+ end
110
+
99
111
  def send_notification(method, params = nil)
100
112
  notification = {
101
113
  jsonrpc: "2.0",