ruby-mcp-client 2.1.0 → 3.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,449 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module SchemaValidator
5
+ # The keywords that apply to one instance value's own type: the object
6
+ # and array vocabularies, and the bookkeeping they need. Extended into
7
+ # SchemaValidator, so the methods are its own; {Evaluation} calls them
8
+ # once per schema position and {Composition} reads what they left
9
+ # undecided.
10
+ #
11
+ # Everything here is decided by the instance and this schema object
12
+ # alone, which is why it can be evaluated at all. What the keywords
13
+ # evaluate of the value is recorded on the application's {Evaluated} as
14
+ # they go, and `unevaluatedItems` / `unevaluatedProperties` read that
15
+ # record once every applicator has run ({Evaluation#apply_unevaluated}).
16
+ module Instances
17
+ # Validate an object against the keywords that apply to it: `required`,
18
+ # the property-count bounds, the required half of a dependency, the
19
+ # `properties` schemas, and the schemas that decide the members those do
20
+ # not name (`patternProperties`, `additionalProperties`, `propertyNames`).
21
+ # @param data [Hash] the object
22
+ # @param schema [Hash] string-keyed schema
23
+ # @param path [String] location for error messages
24
+ # @param evaluated [Evaluated, nil] where the members the keywords
25
+ # evaluate are recorded
26
+ # @return [Array<String>] validation errors
27
+ def validate_object(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil)
28
+ errors = []
29
+ Array(schema['required']).each do |raw_name|
30
+ name = raw_name.to_s
31
+ errors << "#{path}: missing required property '#{clip(name)}'" unless property_present?(data, name)
32
+ end
33
+ errors.concat(validate_property_counts(data, schema, path))
34
+ errors.concat(validate_dependent_required(data, schema, path, dialect))
35
+ errors.concat(validate_named_properties(data, schema, path, ctx, evaluated))
36
+ errors.concat(validate_other_properties(data, schema, path, ctx, dialect, evaluated))
37
+ end
38
+
39
+ # `unevaluatedProperties` (JSON Schema 2020-12 Core Section 11.3): the
40
+ # keyword's schema applies to every member nothing applied to the
41
+ # value evaluated — not this node's own property keywords, not any
42
+ # applicator that passed. The members are as many as the peer sent, so
43
+ # the sweep consults the deadline as it goes.
44
+ # @param app [Evaluation::Application] the finished application
45
+ # @param sub [Object] the keyword's schema
46
+ # @return [Array<String>] validation errors
47
+ def unevaluated_property_errors(app, sub)
48
+ data = app.data
49
+ ctx = app.ctx
50
+ path = app.path
51
+ data.flat_map do |key, value|
52
+ check_deadline(ctx)
53
+ next [] if app.evaluated.property?(key)
54
+
55
+ name = key.to_s
56
+ unevaluated_errors(value, sub, "#{path}/#{name}", ctx,
57
+ "#{path}: property '#{clip(name)}' is not allowed (unevaluatedProperties is false)")
58
+ end
59
+ end
60
+
61
+ # What one member or item left unevaluated costs: the keyword's schema
62
+ # applied to it, or — where that schema is `false` — one error.
63
+ # @return [Array<String>] validation errors
64
+ def unevaluated_errors(value, sub, path, ctx, refusal)
65
+ if sub == false
66
+ count_visit(ctx)
67
+ return [refusal]
68
+ end
69
+
70
+ validate_child(value, sub, path, ctx)
71
+ end
72
+
73
+ # minProperties / maxProperties (JSON Schema 2020-12 Validation Sections
74
+ # 6.5.1-6.5.2).
75
+ # @return [Array<String>] validation errors
76
+ def validate_property_counts(data, schema, path)
77
+ errors = []
78
+ min = schema['minProperties']
79
+ max = schema['maxProperties']
80
+ if min.is_a?(Numeric) && data.size < min
81
+ errors << "#{path}: object has #{data.size} properties, fewer than minProperties #{min}"
82
+ end
83
+ if max.is_a?(Numeric) && data.size > max
84
+ errors << "#{path}: object has #{data.size} properties, more than maxProperties #{max}"
85
+ end
86
+ errors
87
+ end
88
+
89
+ # The required half of a dependency: `dependentRequired` in 2019-09 and
90
+ # 2020-12, and the property-name arrays of draft-07's `dependencies`
91
+ # (JSON Schema 2020-12 Validation Section 6.5.4, draft-07 Section 6.5.7).
92
+ # @return [Array<String>] validation errors
93
+ def validate_dependent_required(data, schema, path, dialect)
94
+ errors = []
95
+ %w[dependentRequired dependencies].each do |keyword|
96
+ map = schema[keyword] if keyword_known?(keyword, dialect)
97
+ next unless map.is_a?(Hash)
98
+
99
+ map.each do |trigger, dependents|
100
+ next unless dependents.is_a?(Array) && property_present?(data, trigger)
101
+
102
+ dependents.each do |name|
103
+ next if property_present?(data, name)
104
+
105
+ errors << "#{path}: property '#{clip(trigger.to_s)}' requires property '#{clip(name.to_s)}'"
106
+ end
107
+ end
108
+ end
109
+ errors
110
+ end
111
+
112
+ # The `properties` schemas, applied in the order the schema names them.
113
+ # Each member the keyword names is evaluated by it (its annotation).
114
+ # @return [Array<String>] validation errors
115
+ def validate_named_properties(data, schema, path, ctx, evaluated = nil)
116
+ properties = schema['properties']
117
+ return [] unless properties.is_a?(Hash)
118
+
119
+ errors = []
120
+ properties.each do |raw_name, prop_schema|
121
+ next unless schema_value?(prop_schema)
122
+
123
+ name = raw_name.to_s
124
+ key = data.key?(name) ? name : (name.to_sym if data.key?(name.to_sym))
125
+ next if key.nil?
126
+
127
+ evaluated&.name!(name)
128
+ # A property is a smaller instance, so the hops taken to reach this
129
+ # schema cannot repeat forever below it: the budget counts a chain
130
+ # of references applied to one value, not how deep the data nests.
131
+ # The step down is what the depth bound counts.
132
+ errors.concat(validate_child(data[key], prop_schema, "#{path}/#{name}", ctx))
133
+ end
134
+ errors
135
+ end
136
+
137
+ # The property applicators that decide members `properties` does not
138
+ # name: every name is checked against `propertyNames`, a member whose
139
+ # name matches a `patternProperties` pattern is validated against each
140
+ # matching schema, and one left over by both goes to
141
+ # `additionalProperties` (JSON Schema 2020-12 Core Sections 10.3.2.1-3).
142
+ # @return [Array<String>] validation errors
143
+ def validate_other_properties(data, schema, path, ctx, dialect, evaluated = nil)
144
+ patterns = schema['patternProperties'] if keyword_known?('patternProperties', dialect)
145
+ patterns = nil unless patterns.is_a?(Hash)
146
+ names = schema_value?(schema['propertyNames']) ? schema['propertyNames'] : nil
147
+ additional = schema['additionalProperties'] if schema_value?(schema['additionalProperties'])
148
+ return [] if patterns.nil? && names.nil? && additional.nil?
149
+
150
+ named = schema['properties'].is_a?(Hash) ? schema['properties'].keys.map(&:to_s) : []
151
+ data.flat_map do |key, value|
152
+ # How wide this sweep is the peer's choice, not the schema's, and a
153
+ # member the applicators decide without descending into it visits no
154
+ # node of its own: the deadline is consulted here so a huge object
155
+ # cannot run the walk past the budget between two nodes it visits.
156
+ check_deadline(ctx)
157
+ property_errors(key.to_s, value, path, ctx, evaluated,
158
+ names: names, patterns: patterns, additional: additional, named: named)
159
+ end
160
+ end
161
+
162
+ # One member's errors under the property applicators. A member a
163
+ # pattern matched or `additionalProperties` applied to is evaluated by
164
+ # that keyword (its annotation); `propertyNames` evaluates the name,
165
+ # never the member.
166
+ # @return [Array<String>] validation errors
167
+ def property_errors(name, value, path, ctx, evaluated, names:, patterns:, additional:, named:)
168
+ errors = names.nil? ? [] : property_name_errors(name, names, path, ctx)
169
+ matched = false
170
+ patterns&.each do |pattern, sub|
171
+ next unless schema_value?(sub) && pattern_matches?(pattern.to_s, name, ctx.deadline)
172
+
173
+ matched = true
174
+ evaluated&.name!(name)
175
+ errors.concat(validate_child(value, sub, "#{path}/#{name}", ctx))
176
+ end
177
+ return errors if matched || named.include?(name) || additional.nil?
178
+
179
+ # A rejected member costs an error rather than a descent, so it is
180
+ # charged here: the count is what stops a peer-sized object.
181
+ if additional == false
182
+ count_visit(ctx)
183
+ return errors.push("#{path}: property '#{clip(name)}' is not allowed (additionalProperties is false)")
184
+ end
185
+
186
+ evaluated&.name!(name)
187
+ errors.concat(validate_child(value, additional, "#{path}/#{name}", ctx))
188
+ end
189
+
190
+ # `propertyNames` applies its schema to each property *name* (a string),
191
+ # so what it rejects is reported as one error about the name rather than
192
+ # as a type error about a value the instance does not hold there.
193
+ # @return [Array<String>] validation errors
194
+ def property_name_errors(name, sub, path, ctx)
195
+ errors = speculatively(ctx) { validate_child(name, sub, "#{path}/#{name}", ctx) }
196
+ return [] if errors.empty?
197
+
198
+ ["#{path}: property name '#{clip(name)}' does not satisfy propertyNames (#{clip(errors.first.to_s)})"]
199
+ end
200
+
201
+ # Run a block whose errors are a verdict rather than output, so they are
202
+ # not charged to MAX_ERRORS (only what the caller reports is).
203
+ # @return [Object] the block's value
204
+ def speculatively(ctx)
205
+ ctx.speculative += 1
206
+ yield
207
+ ensure
208
+ ctx.speculative -= 1
209
+ end
210
+
211
+ # Validate an array against items/prefixItems/minItems/maxItems.
212
+ # 2020-12 puts positional schemas in `prefixItems` and the rest under
213
+ # `items`; draft-07 and 2019-09 put positional schemas in an `items`
214
+ # array and send what follows the tuple to `additionalItems`. Both forms
215
+ # are honoured.
216
+ # @param data [Array] the array
217
+ # @param schema [Hash] string-keyed schema
218
+ # @param path [String] location for error messages
219
+ # @param evaluated [Evaluated, nil] where the items the keywords
220
+ # evaluate are recorded
221
+ # @return [Array<String>] validation errors
222
+ def validate_array(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil)
223
+ errors = []
224
+ min_items = schema['minItems']
225
+ max_items = schema['maxItems']
226
+ if min_items.is_a?(Numeric) && data.length < min_items
227
+ errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
228
+ end
229
+ if max_items.is_a?(Numeric) && data.length > max_items
230
+ errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
231
+ end
232
+ errors.concat(validate_unique_items(data, schema, path, ctx))
233
+ errors.concat(validate_items(data, schema, path, ctx, dialect, evaluated))
234
+ errors.concat(validate_contains(data, schema, path, ctx, dialect, evaluated))
235
+ end
236
+
237
+ # `unevaluatedItems` (JSON Schema 2020-12 Core Section 11.2): the
238
+ # keyword's schema applies to every item nothing applied to the value
239
+ # evaluated — not the tuple keywords, not `contains`, not any
240
+ # applicator that passed.
241
+ # @param app [Evaluation::Application] the finished application
242
+ # @param sub [Object] the keyword's schema
243
+ # @return [Array<String>] validation errors
244
+ def unevaluated_item_errors(app, sub)
245
+ data = app.data
246
+ ctx = app.ctx
247
+ path = app.path
248
+ errors = []
249
+ data.each_index do |idx|
250
+ check_deadline(ctx)
251
+ next if app.evaluated.item?(idx)
252
+
253
+ errors.concat(unevaluated_errors(data[idx], sub, "#{path}/#{idx}", ctx,
254
+ "#{path}: item #{idx} is not allowed (unevaluatedItems is false)"))
255
+ end
256
+ errors
257
+ end
258
+
259
+ # The item schemas: the positional ones first, then the schema that
260
+ # covers what follows them. The tuple evaluates the leading items it
261
+ # covers and a schema for the rest evaluates every item (their
262
+ # annotations).
263
+ # @return [Array<String>] validation errors
264
+ def validate_items(data, schema, path, ctx, dialect, evaluated = nil)
265
+ items = schema['items']
266
+ # 2020-12 puts positional schemas in prefixItems (items must be a
267
+ # schema); draft-07 and 2019-09 put them in an items array and know no
268
+ # prefixItems.
269
+ positional = if dialect == DEFAULT_DIALECT
270
+ schema['prefixItems'].is_a?(Array) ? schema['prefixItems'] : []
271
+ else
272
+ items.is_a?(Array) ? items : []
273
+ end
274
+ # Past a draft-07 / 2019-09 tuple it is `additionalItems` that applies.
275
+ rest = if items.is_a?(Array)
276
+ schema['additionalItems'] if keyword_known?('additionalItems', dialect)
277
+ else
278
+ items
279
+ end
280
+ return [] if positional.empty? && !schema_value?(rest)
281
+
282
+ note_evaluated_items(evaluated, positional, rest, data)
283
+ errors = []
284
+ data.each_with_index do |item, idx|
285
+ # As in {.validate_other_properties}: the array's length is the
286
+ # peer's, and an item the tuple tail rejects visits no node.
287
+ check_deadline(ctx)
288
+ item_schema = idx < positional.length ? positional[idx] : rest
289
+ next unless schema_value?(item_schema)
290
+
291
+ if item_schema == false && idx >= positional.length && items.is_a?(Array)
292
+ count_visit(ctx)
293
+ errors << "#{path}: item #{idx} is not allowed (additionalItems is false)"
294
+ next
295
+ end
296
+
297
+ # An item is a smaller instance: the hop budget starts over and the
298
+ # depth bound counts the step, so a recursive schema describes data
299
+ # of any depth up to it (see {.validate_named_properties}).
300
+ errors.concat(validate_child(item, item_schema, "#{path}/#{idx}", ctx))
301
+ end
302
+ errors
303
+ end
304
+
305
+ # The tuple evaluates the leading items it covers; a schema for the
306
+ # rest evaluates every item (their annotations).
307
+ # @return [void]
308
+ def note_evaluated_items(evaluated, positional, rest, data)
309
+ return unless evaluated
310
+
311
+ evaluated.prefix!([positional.length, data.length].min)
312
+ evaluated.all! if schema_value?(rest)
313
+ end
314
+
315
+ # uniqueItems (JSON Schema 2020-12 Validation Section 6.4.3). Equality is
316
+ # JSON's, not Ruby's: 1 and 1.0 are the same number and two objects with
317
+ # the same members are equal whatever order they were written in, so the
318
+ # items are compared by a canonical form rather than by identity or by
319
+ # `eql?`.
320
+ # @return [Array<String>] validation errors
321
+ # @raise [Aborted] when a bound is hit
322
+ def validate_unique_items(data, schema, path, ctx)
323
+ return [] unless schema['uniqueItems'] == true
324
+
325
+ seen = {}
326
+ data.each_with_index do |item, idx|
327
+ count_visit(ctx)
328
+ key = comparable_value(item, 0, ctx)
329
+ first = seen[key]
330
+ unless first.nil?
331
+ return ["#{path}: items #{first} and #{idx} are equal, but uniqueItems requires every item to differ"]
332
+ end
333
+
334
+ seen[key] = idx
335
+ end
336
+ []
337
+ end
338
+
339
+ # A value in the form JSON equality compares: numbers as exact rationals
340
+ # (so 1 and 1.0 agree), objects as their members sorted by name and with
341
+ # either Ruby key form read as the same name.
342
+ #
343
+ # The form carries the JSON type, because JSON equality begins with it:
344
+ # an object is never equal to an array, however their members line up
345
+ # (JSON Schema 2020-12 Core Section 4.2.2). Encoding both as a bare
346
+ # Ruby Array made `[{}, []]` and `[{"a": 1}, [["a", 1]]]` read as
347
+ # duplicates, so :strict rejected a conforming result — and, through
348
+ # `not`, accepted one the schema rejects.
349
+ #
350
+ # Canonicalizing a value walks all of it, and the value came from the
351
+ # peer, so every node is accounted for like any other the walk visits.
352
+ # @param value [Object] the instance value
353
+ # @param depth [Integer] how far into the value this is
354
+ # @param ctx [Context] the validation context
355
+ # @return [Object] a value that hashes and compares as JSON equality does
356
+ # @raise [Aborted] when the value nests beyond the bound, or a budget is hit
357
+ def comparable_value(value, depth, ctx)
358
+ raise Aborted, "instance nested deeper than #{MAX_NODE_DEPTH}" if depth > MAX_NODE_DEPTH
359
+
360
+ count_visit(ctx)
361
+ case value
362
+ when Hash then [:object, value.map { |k, v| [k.to_s, comparable_value(v, depth + 1, ctx)] }.sort_by(&:first)]
363
+ when Array then [:array, value.map { |v| comparable_value(v, depth + 1, ctx) }]
364
+ when Numeric then [:number, exact_number(value)]
365
+ when String then [:string, value]
366
+ else value
367
+ end
368
+ end
369
+
370
+ # @return [Object] the number as an exact rational, or as written when
371
+ # no rational describes it (an infinity a Ruby caller passed in)
372
+ def exact_number(value)
373
+ value.to_r
374
+ rescue RangeError, NoMethodError
375
+ value
376
+ end
377
+
378
+ # `contains` (JSON Schema 2020-12 Validation Sections 6.4.4-6.4.5): the
379
+ # items are matched one by one and counted, and the count must lie
380
+ # between `minContains` (1 by default) and `maxContains`. An item the
381
+ # validator could only partly evaluate is neither a match nor a
382
+ # non-match: it widens the range the count may lie in, and where the
383
+ # bounds do not settle the keyword either way the node's verdict is
384
+ # partial, exactly as an unevaluated assertion makes it.
385
+ # @param data [Array] the instance
386
+ # @param schema [Hash] string-keyed schema
387
+ # @param path [String] location for error messages
388
+ # @param dialect [String, nil] the dialect in force
389
+ # @param evaluated [Evaluated, nil] where the items `contains` matched
390
+ # are recorded (its annotation, 2020-12 Core Section 10.3.1.3)
391
+ # @return [Array<String>] validation errors
392
+ def validate_contains(data, schema, path, ctx, dialect, evaluated = nil)
393
+ return [] unless keyword_known?('contains', dialect) && schema_value?(schema['contains'])
394
+
395
+ min = contains_min(schema, dialect)
396
+ max = contains_max(schema, dialect)
397
+ # Bounds that cannot overlap admit no count at all, so the keyword
398
+ # fails before an item is ever matched against the schema.
399
+ if max && min > max
400
+ return ["#{path}: contains requires between #{min} and #{max} matching items, which no count satisfies"]
401
+ end
402
+
403
+ # 2020-12 Core Section 10.3.1.3 gives `contains` the item annotation
404
+ # `unevaluatedItems` reads; 2019-09 does not (its Section 9.3.1.3
405
+ # gives `unevaluatedItems` the annotations of `items` and
406
+ # `additionalItems` alone), so there a matched item stays unevaluated
407
+ # and the keyword still has to answer for it.
408
+ annotating = evaluated if contains_annotates?(dialect)
409
+ hits, unsure = count_contains_matches(data, schema['contains'], path, ctx, annotating)
410
+ errors = []
411
+ errors << "#{path}: expected at least #{min} items matching contains, got #{hits}" if hits + unsure < min
412
+ errors << "#{path}: expected at most #{max} items matching contains, got #{hits}" if max && hits > max
413
+ ctx.undecided += 1 if errors.empty? && unsure.positive? && (hits < min || (max && hits + unsure > max))
414
+ errors
415
+ end
416
+
417
+ # Whether the dialect gives `contains` the item annotation that
418
+ # `unevaluatedItems` consumes: 2020-12 does, 2019-09 does not, and
419
+ # draft-07 has neither keyword.
420
+ # @param dialect [String, nil] the dialect in force
421
+ # @return [Boolean]
422
+ def contains_annotates?(dialect)
423
+ dialect != DRAFT_2019_09 && dialect != DRAFT_07
424
+ end
425
+
426
+ # Match a `contains` schema against every item. The matches are a
427
+ # verdict, not output, so they are evaluated speculatively and what they
428
+ # could not evaluate is not left behind as this node's uncertainty.
429
+ # @return [Array(Integer, Integer)] the items that matched, and those the
430
+ # validator could not decide
431
+ def count_contains_matches(data, sub, path, ctx, evaluated = nil)
432
+ hits = 0
433
+ unsure = 0
434
+ data.each_with_index do |item, idx|
435
+ before = ctx.undecided
436
+ errors = speculatively(ctx) { validate_child(item, sub, "#{path}/#{idx}", ctx) }
437
+ if errors.empty? && ctx.undecided > before
438
+ unsure += 1
439
+ elsif errors.empty?
440
+ hits += 1
441
+ evaluated&.index!(idx)
442
+ end
443
+ ctx.undecided = before
444
+ end
445
+ [hits, unsure]
446
+ end
447
+ end
448
+ end
449
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module SchemaValidator
5
+ # The bounded scan for keywords the validator does not evaluate, so the
6
+ # client can say when its validation is only partial. Extended into
7
+ # SchemaValidator, so the methods are its own.
8
+ module KeywordScan
9
+ # List the unsupported JSON Schema keywords a schema uses (anywhere: at the
10
+ # top level or nested in subschemas). Property names that merely look like
11
+ # keywords (e.g. a property called 'not') are not reported, and
12
+ # data-carrying keywords (enum/const/default/examples) are not scanned.
13
+ # The scan stops at the same bounds as {.check_schema} (a schema beyond
14
+ # them is unusable anyway), so a huge server-supplied schema is never
15
+ # walked whole.
16
+ # @param schema [Object] the JSON schema (string or symbol keys)
17
+ # @return [Array<String>] unique unsupported keywords, in discovery order
18
+ def unsupported_keywords(schema)
19
+ found = []
20
+ root = normalize_schema(schema)
21
+ declared = dialect(root)
22
+ canonical = declared && canonical_dialect(declared)
23
+ scan = { count: 0, dialect: canonical, walked: {}.compare_by_identity, anchors: nil,
24
+ pending: [[root, 0, canonical]] }
25
+ scan_positions(root, found, scan)
26
+ found.uniq
27
+ rescue TooLarge
28
+ # Unusable anyway: check_schema reports it.
29
+ found.uniq
30
+ end
31
+
32
+ # Read every queued position. Like the preflight walk, the scan runs on
33
+ # a stack of its own rather than on the interpreter's: a shallow
34
+ # document whose `$ref`s chain through hundreds of schemas is walked in
35
+ # constant stack space, so scanning one cannot overflow the thread a
36
+ # transport reads on.
37
+ # @return [void]
38
+ def scan_positions(root, found, scan)
39
+ until scan[:pending].empty?
40
+ schema, depth, dialect = scan[:pending].pop
41
+ scan_position(schema, root, found, depth, scan, dialect)
42
+ end
43
+ end
44
+
45
+ # Collect the unsupported keywords one schema position uses (keywords
46
+ # the dialect does not define are unknown, not unsupported) and queue
47
+ # what it leads to. What a local `$ref` applies is scanned too, wherever
48
+ # it lives (a definition bag the dialect does not walk included); each
49
+ # object is scanned once.
50
+ # @param schema [Object] a (sub)schema; non-Hash values are ignored
51
+ # @param root [Hash] the normalized root schema
52
+ # @param found [Array<String>] accumulator
53
+ # @param scan [Hash] :count of subschemas seen so far, the canonical
54
+ # :dialect, the :walked objects, the memoized :anchors index and the
55
+ # :pending positions
56
+ # @return [void]
57
+ def scan_position(schema, root, found, depth, scan, dialect = scan[:dialect])
58
+ return unless schema.is_a?(Hash) && depth <= MAX_SCHEMA_DEPTH
59
+ return if scan[:walked].key?(schema)
60
+
61
+ scan[:walked][schema] = true
62
+ scan[:count] += 1
63
+ return if scan[:count] > MAX_SUBSCHEMAS
64
+
65
+ # An embedded resource declaring its own $schema is scanned under it.
66
+ dialect = embedded_dialect(schema, dialect) || dialect
67
+ if dialect == DRAFT_07 && schema.key?('$ref')
68
+ # Nothing beside the $ref is applied; the definitions stay reachable.
69
+ referenced = [referenced_position(schema, root, depth, scan, dialect)].compact
70
+ return queue_scan(scan, schema, depth, dialect, referenced, :each_definition)
71
+ end
72
+
73
+ # A dynamic reference is applied like a `$ref` — where it binds is
74
+ # decided during evaluation — so it is scanned like one.
75
+ dynamic = DYNAMIC_REFERENCE_KEYWORDS.select { |k| schema.key?(k) && keyword_known?(k, dialect) }
76
+ found.concat((schema.keys & UNSUPPORTED_KEYWORDS).select { |k| keyword_known?(k, dialect) })
77
+ referenced = (['$ref'] + dynamic).filter_map { |k| referenced_position(schema, root, depth, scan, dialect, k) }
78
+ queue_scan(scan, schema, depth, dialect, referenced, :each_subschema)
79
+ end
80
+
81
+ # Queue the positions under a schema object (in document order, the
82
+ # stack being read from its end), then what its references reach.
83
+ # @param walker [Symbol] :each_subschema or :each_definition
84
+ # @return [void]
85
+ def queue_scan(scan, schema, depth, dialect, referenced, walker)
86
+ positions = []
87
+ send(walker, schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
88
+ scan[:pending].concat(positions.reverse)
89
+ scan[:pending].concat(referenced.reverse)
90
+ end
91
+
92
+ # The position what a local reference applies is scanned at
93
+ # (unresolvable or external references are the preflight's business).
94
+ # @param keyword [String] `$ref`, or a dynamic reference applied as one
95
+ # @return [Array, nil]
96
+ def referenced_position(schema, root, depth, scan, dialect, keyword = '$ref')
97
+ ref = schema[keyword]
98
+ return nil unless ref.is_a?(String)
99
+ return nil if external_ref?(ref, root, scan[:dialect], scan, from: schema)
100
+
101
+ target = resolve_reference(root, ref, scan[:dialect], scan, from: schema)
102
+ return nil if target.equal?(UNRESOLVED)
103
+
104
+ # What a reference reaches is scanned under its own resource's dialect
105
+ # and at its own lexical depth, so member order and reference fan-out
106
+ # cannot hide its keywords. A target that is not a schema object has
107
+ # no lexical depth at all — nil, never `false`, so the depth the
108
+ # opaque-pointer branch compares stays a number.
109
+ scan[:depths] ||= lexical_depths(root, scan[:dialect])
110
+ lexical = scan[:depths][target] if target.is_a?(Hash)
111
+ position, opaque = pointer_position(ref, root, scan[:dialect], scan, schema)
112
+ target_depth = if opaque
113
+ [position, lexical].compact.max || (depth + 1)
114
+ else
115
+ lexical || position || (depth + 1)
116
+ end
117
+ [target, target_depth, (target.is_a?(Hash) && indexed_dialect(target, scan)) || dialect]
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module SchemaValidator
5
+ # The bounded, string-keyed copy of a peer-supplied schema document.
6
+ # Extended into SchemaValidator, so the methods are its own.
7
+ module Normalization
8
+ # A copy of the schema with every structural Hash key as a String, so
9
+ # keyword lookups and JSON pointers see one key form. The value of a
10
+ # data-carrying keyword (enum, const, default, examples) of a schema
11
+ # object is left exactly as given, the walk stops at the nesting bound,
12
+ # and — given a budget — the copy stops as soon as the document holds
13
+ # more structural elements (objects, their entries and array members,
14
+ # boolean subschemas included) than MAX_STRUCTURAL_OBJECTS, before the
15
+ # rest is ever read, or as soon as the validation deadline has passed.
16
+ # @param node [Object]
17
+ # @param depth [Integer]
18
+ # @param budget [Hash, nil] :objects copied so far, :deadline (optional)
19
+ # @param mode [Symbol] how this position is read ({#member_mode})
20
+ # @return [Object]
21
+ # @raise [TooLarge] when the budget is exceeded, or the document is
22
+ # nested beyond what MAX_SCHEMA_DEPTH schema levels can hold (a
23
+ # subtree is never kept unread)
24
+ # @raise [Aborted] when the deadline passed
25
+ def deep_stringify(node, depth = 0, budget = nil, mode = :schema)
26
+ # Each schema level takes at most two document levels (a keyword map
27
+ # or array, then the subschema), so a deeper document holds more
28
+ # schema levels than the preflight walk admits.
29
+ raise TooLarge, "schema nesting depth exceeds #{MAX_SCHEMA_DEPTH}" if depth > (MAX_SCHEMA_DEPTH * 2) + 2
30
+
31
+ case node
32
+ when Hash
33
+ charge_structure(budget, node.size + 1)
34
+ node.to_h do |key, value|
35
+ name = key.to_s
36
+ [name, stringify_member(name, value, depth, budget, mode)]
37
+ end
38
+ when Array
39
+ charge_structure(budget, node.length + 1)
40
+ node.map.with_index do |value, idx|
41
+ deep_stringify(value, depth + 1, budget, member_mode(idx.to_s, value, mode))
42
+ end
43
+ else node
44
+ end
45
+ end
46
+
47
+ # The copy of one member: what a schema object holds under a data
48
+ # keyword is kept as given (its key form is the instance's, and it is
49
+ # compared for equality as written), everything else is copied in the
50
+ # mode its position implies.
51
+ # @return [Object]
52
+ def stringify_member(name, value, depth, budget, mode)
53
+ member = member_mode(name, value, mode)
54
+ return value if member == :data
55
+
56
+ deep_stringify(value, depth + 1, budget, member)
57
+ end
58
+
59
+ # How the member a name selects is read: :data (a data keyword of a
60
+ # schema object, kept as given), :list (a map or array of subschemas),
61
+ # :schema, or :plain (anything else — copied, never read as a schema).
62
+ # Only a schema object has keywords: a property, definition or pattern
63
+ # named `enum`, `const`, `default` or `examples` is a schema position
64
+ # like any other (JSON Schema 2020-12 Core Section 4.3.1), and so is
65
+ # everything under a keyword no dialect defines.
66
+ # @return [Symbol]
67
+ def member_mode(name, value, mode)
68
+ return :data if mode == :data
69
+ return :schema if mode == :list
70
+ return :plain unless mode == :schema
71
+ return :data if DATA_KEYWORDS.include?(name)
72
+ return :list if list_member?(name, value)
73
+
74
+ SUBSCHEMA_KEYWORDS.include?(name) ? :schema : :plain
75
+ end
76
+
77
+ # @return [Boolean] whether a keyword holds a map or array of
78
+ # subschemas in the form it was written (draft-07 / 2019-09 read an
79
+ # `items` array positionally)
80
+ def list_member?(name, value)
81
+ (SUBSCHEMA_MAP_KEYWORDS.include?(name) && value.is_a?(Hash)) ||
82
+ ((SUBSCHEMA_ARRAY_KEYWORDS.include?(name) || name == 'items') && value.is_a?(Array))
83
+ end
84
+
85
+ # Account for structural elements about to be copied.
86
+ # @raise [TooLarge] when the budget is exceeded
87
+ # @raise [Aborted] when the deadline passed
88
+ def charge_structure(budget, count)
89
+ return unless budget
90
+
91
+ budget[:objects] += count
92
+ if budget[:objects] > MAX_STRUCTURAL_OBJECTS
93
+ raise TooLarge,
94
+ "schema has more than #{MAX_STRUCTURAL_OBJECTS} structural elements"
95
+ end
96
+
97
+ deadline = budget[:deadline]
98
+ return unless deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
99
+
100
+ raise Aborted, 'time budget exhausted while reading the schema'
101
+ end
102
+ end
103
+ end
104
+ end