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,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module SchemaValidator
5
+ # Applying schemas to one instance value, without recursing per schema.
6
+ # Extended into SchemaValidator, so the methods are its own.
7
+ #
8
+ # A validation walks two things at once: down the instance, and — at each
9
+ # value — through the schemas that apply to it. Only the first nests the
10
+ # data, and only the first is counted by MAX_NODE_DEPTH; the second is a
11
+ # `$ref` hop or a composition branch applied to the *same* value, bounded
12
+ # by MAX_REF_DEPTH and the node-visit cap instead.
13
+ #
14
+ # A schema composed through a handful of `$defs` mixins applies several
15
+ # of those per instance level, so if each of them cost a Ruby frame the
16
+ # stack would grow with the product of the instance depth and the mixin
17
+ # count — and a conforming result would abort on a transport reader
18
+ # thread's stack, which is where a tool call is validated. So the
19
+ # same-instance applications run on an explicit stack here (this
20
+ # module's `pending` list) rather than on the interpreter's: a step is
21
+ # requested by returning `[:apply, schema, ref_depth, speculative,
22
+ # continuation, collect]` and finished by returning `[:done, errors,
23
+ # evaluated]`, and {SchemaValidator.validate_node} drives the two until
24
+ # the outermost application is done. A Ruby frame is then spent only on
25
+ # a step into a child value ({SchemaValidator.validate_child}), which is
26
+ # exactly what MAX_NODE_DEPTH bounds.
27
+ #
28
+ # Every application of a schema to an object or an array keeps what it
29
+ # evaluated of the value (an {Evaluated}): the annotations
30
+ # `unevaluatedProperties` and `unevaluatedItems` read, collected from the
31
+ # node's own keywords and from every in-place applicator whose subschema
32
+ # passed. A subschema hands its own record back with its verdict, and a
33
+ # failed one hands back nothing.
34
+ module Evaluation
35
+ # One (sub)schema being applied to one value: the instance and the
36
+ # context are fixed for a whole trampoline (the same value throughout),
37
+ # the rest is what this application accumulates.
38
+ # @!attribute errors
39
+ # @return [Array<String>] this node's own errors so far
40
+ # @!attribute counted_before
41
+ # @return [Integer] ctx.errors when the application began, so only
42
+ # this node's own errors are charged to MAX_ERRORS
43
+ # @!attribute evaluated
44
+ # @return [Evaluated, nil] what was evaluated of an object or array
45
+ # @!attribute collecting
46
+ # @return [Boolean] whether an unevaluated keyword at this node or at
47
+ # one applying it to the same value reads the annotations, so every
48
+ # branch that passes must be evaluated rather than the first one
49
+ Application = Struct.new(:data, :path, :ctx, :schema, :dialect, :ref_depth, :errors, :counted_before,
50
+ :evaluated, :collecting, keyword_init: true)
51
+
52
+ # The composition keywords, in the order they are applied. Each step
53
+ # takes the application and a continuation, and returns a step.
54
+ COMPOSITION_STEPS = %i[compose_all_of compose_any_of compose_one_of compose_not compose_conditional
55
+ compose_dependent_schemas].freeze
56
+
57
+ # The keywords that apply another schema to the same instance before
58
+ # the node's own keywords: `$ref` and the dynamic references (2020-12
59
+ # Core Section 8.2.3.2, 2019-09 Core Section 8.2.4.2.1). Applied in
60
+ # this order, each alongside the others (draft-07's `$ref` alone
61
+ # replaces its siblings).
62
+ REFERENCE_KEYWORDS = %w[$ref $dynamicRef $recursiveRef].freeze
63
+
64
+ # Begin applying one (sub)schema to the value, under the bound on the
65
+ # walk itself: one more node visited.
66
+ # @param data [Object] the value
67
+ # @param schema [Object] a subschema (Hash or boolean)
68
+ # @param path [String] location for error messages
69
+ # @param ctx [Context] the validation context
70
+ # @param ref_depth [Integer] $ref hops taken to reach this schema
71
+ # @param collecting [Boolean] whether the annotations this application
72
+ # produces are read by an unevaluated keyword above it
73
+ # @return [Array] a step: [:done, errors, evaluated] or [:apply, ...]
74
+ # @raise [Aborted] when a bound is hit
75
+ def start_node(data, schema, path, ctx, ref_depth, collecting: false)
76
+ count_visit(ctx)
77
+ return [:done, [], nil] if schema == true
78
+ return [:done, count_errors(ctx, ["#{path}: schema false accepts no value"]), nil] if schema == false
79
+ return [:done, [], nil] unless schema.is_a?(Hash)
80
+
81
+ dialect = node_dialect(schema, ctx)
82
+ app = Application.new(data: data, path: path, ctx: ctx, schema: schema, dialect: dialect,
83
+ ref_depth: ref_depth, errors: [], counted_before: ctx.errors,
84
+ evaluated: (Evaluated.new if data.is_a?(Hash) || data.is_a?(Array)),
85
+ collecting: collecting || reads_annotations?(schema, dialect))
86
+ apply_references(app, applied_references(app))
87
+ end
88
+
89
+ # @param schema [Hash]
90
+ # @param dialect [String, nil]
91
+ # @return [Boolean] whether the node carries an unevaluated keyword
92
+ # that reads the annotations of the schemas applied to its value
93
+ def reads_annotations?(schema, dialect)
94
+ %w[unevaluatedProperties unevaluatedItems].any? do |keyword|
95
+ keyword_known?(keyword, dialect) && schema_value?(schema[keyword])
96
+ end
97
+ end
98
+
99
+ # The reference keywords a node applies: `$ref` and the dynamic
100
+ # references the dialect defines. Where a dynamic reference binds is
101
+ # {References#dynamic_binding}'s business, and {#ref_target} asks it
102
+ # once the plain target has resolved.
103
+ # @param app [Application]
104
+ # @return [Array<String>]
105
+ def applied_references(app)
106
+ REFERENCE_KEYWORDS.select { |keyword| app.schema.key?(keyword) && keyword_known?(keyword, app.dialect) }
107
+ end
108
+
109
+ # Apply the references a schema object carries, one after the other,
110
+ # then its own keywords. draft-07: "$ref" replaces the schema it
111
+ # appears in; later drafts apply it alongside the sibling keywords.
112
+ # @param app [Application]
113
+ # @param keywords [Array<String>] the references still to apply
114
+ # @return [Array] a step
115
+ def apply_references(app, keywords)
116
+ return apply_keywords(app) if keywords.empty?
117
+
118
+ keyword = keywords.first
119
+ replaces = keyword == '$ref' && app.dialect == DRAFT_07
120
+ kind, target = ref_target(app, keyword)
121
+ if kind == :errors
122
+ return [:done, target, app.evaluated] if replaces
123
+
124
+ app.errors.concat(target)
125
+ return apply_references(app, keywords.drop(1))
126
+ end
127
+
128
+ [:apply, target, app.ref_depth + 1, false, lambda do |errors, evaluated|
129
+ next [:done, errors, evaluated] if replaces
130
+
131
+ app.errors.concat(errors)
132
+ app.evaluated&.merge!(evaluated) if errors.empty?
133
+ apply_references(app, keywords.drop(1))
134
+ end, app.collecting]
135
+ end
136
+
137
+ # Resolve a reference a schema object carries.
138
+ # @param app [Application]
139
+ # @param keyword [String] `$ref`, or a dynamic reference applied as one
140
+ # @return [Array(Symbol, Object)] [:target, schema] or [:errors, errors]
141
+ # @raise [Aborted] when the hop budget is exhausted
142
+ def ref_target(app, keyword = '$ref')
143
+ ctx = app.ctx
144
+ ref = app.schema[keyword]
145
+ if unusable_ref?(ref, ctx, app.schema)
146
+ return ref_problem(app, "external #{keyword} #{clip(ref.inspect)} is not dereferenced")
147
+ end
148
+ if app.ref_depth >= MAX_REF_DEPTH
149
+ raise Aborted, "#{keyword} chain exceeds #{MAX_REF_DEPTH} hops (cycle?) at #{clip(ref.inspect)}"
150
+ end
151
+
152
+ target = resolve_reference(ctx.root, ref, ctx.dialect, ctx, from: app.schema)
153
+ return ref_problem(app, "unresolvable local #{keyword} #{clip(ref.inspect)}") if target.equal?(UNRESOLVED)
154
+
155
+ if keyword != '$ref'
156
+ kind, bound = dynamic_binding(app.schema, keyword, ctx.root, ctx.dialect, ctx)
157
+ target = bound if kind == :bound
158
+ end
159
+ unless schema_value?(target)
160
+ return ref_problem(app, "#{keyword} #{clip(ref.inspect)} does not point at a schema")
161
+ end
162
+
163
+ [:target, target]
164
+ end
165
+
166
+ # @param ref [Object] the reference's value
167
+ # @param ctx [Context] the validation context
168
+ # @param from [Hash] the schema object holding the reference
169
+ # @return [Boolean] whether it is a reference this validator never follows
170
+ def unusable_ref?(ref, ctx, from)
171
+ !ref.is_a?(String) || external_ref?(ref, ctx.root, ctx.dialect, ctx, from: from)
172
+ end
173
+
174
+ # @param app [Application]
175
+ # @param message [String] the problem, without the location
176
+ # @return [Array(Symbol, Array<String>)]
177
+ def ref_problem(app, message)
178
+ [:errors, count_errors(app.ctx, ["#{app.path}: #{message}"])]
179
+ end
180
+
181
+ # Apply the assertions this validator evaluates for the value's own
182
+ # type, then hand over to the composition keywords. A step into a child
183
+ # value happens here, and is the one place a Ruby frame is spent.
184
+ # @param app [Application]
185
+ # @return [Array] a step
186
+ def apply_keywords(app)
187
+ data = app.data
188
+ schema = app.schema
189
+ errors = app.errors
190
+ errors.concat(validate_type(data, schema['type'], app.path)) if schema.key?('type')
191
+ # A node its own `type` already rejected is decided: the keywords for
192
+ # the value's actual type could only add detail, and evaluating them
193
+ # would spend the peer's `patternProperties` expressions and item
194
+ # schemas on a value this schema has refused.
195
+ return compose(app) unless errors.empty?
196
+
197
+ errors.concat(validate_enum(data, schema, app.path))
198
+ case data
199
+ when Hash then errors.concat(validate_object(data, schema, app.path, app.ctx, app.dialect, app.evaluated))
200
+ when Array then errors.concat(validate_array(data, schema, app.path, app.ctx, app.dialect, app.evaluated))
201
+ when String then errors.concat(validate_string(data, schema, app.path, app.ctx.deadline))
202
+ when Numeric then errors.concat(validate_number(data, schema, app.path, app.dialect))
203
+ end
204
+ compose(app)
205
+ end
206
+
207
+ # Run the composition keywords in order, each handed the step that
208
+ # continues with the next.
209
+ # @param app [Application]
210
+ # @param index [Integer] the composition keyword to apply
211
+ # @return [Array] a step
212
+ def compose(app, index = 0)
213
+ return finish_node(app) if index >= COMPOSITION_STEPS.length
214
+
215
+ send(COMPOSITION_STEPS[index], app) { compose(app, index + 1) }
216
+ end
217
+
218
+ # Finish an application. The unevaluated keywords come last: they read
219
+ # what every other keyword and every passed applicator evaluated of the
220
+ # value. A keyword the validator does not evaluate makes this node's
221
+ # verdict partial, so a pass here is not a proof for not / oneOf / if.
222
+ # A node its supported assertions already rejected is decided whatever
223
+ # else it holds, and pays for no such measurement. Errors raised by
224
+ # nested nodes were counted when they were produced; only this node's
225
+ # own errors are new.
226
+ # @param app [Application]
227
+ # @return [Array] a step
228
+ def finish_node(app)
229
+ ctx = app.ctx
230
+ apply_unevaluated(app) if app.errors.empty? && app.evaluated
231
+ ctx.undecided += 1 if app.errors.empty? && partial_keywords?(app.schema, app.dialect, app.data, ctx)
232
+ [:done, count_errors(ctx, app.errors, already_counted: ctx.errors - app.counted_before), app.evaluated]
233
+ end
234
+
235
+ # `unevaluatedProperties` / `unevaluatedItems` (JSON Schema 2020-12
236
+ # Core Sections 11.3 and 11.2): the keyword's schema applies to every
237
+ # member or item nothing applied to this value evaluated, and what it
238
+ # validated counts as evaluated from then on.
239
+ # @param app [Application]
240
+ # @return [void]
241
+ def apply_unevaluated(app)
242
+ keyword = app.data.is_a?(Hash) ? 'unevaluatedProperties' : 'unevaluatedItems'
243
+ sub = app.schema[keyword]
244
+ return unless keyword_known?(keyword, app.dialect) && schema_value?(sub)
245
+
246
+ errors = app.data.is_a?(Hash) ? unevaluated_property_errors(app, sub) : unevaluated_item_errors(app, sub)
247
+ app.errors.concat(errors)
248
+ app.evaluated.all!
249
+ end
250
+
251
+ # The schema half of a dependency: `dependentSchemas` in 2019-09 and
252
+ # 2020-12, and the schema entries of draft-07's `dependencies` (JSON
253
+ # Schema 2020-12 Core Section 10.2.2.4, draft-07 Section 6.5.7). Each
254
+ # dependency whose trigger the instance carries applies its schema to
255
+ # that same instance, so — like allOf — it runs on the trampoline
256
+ # rather than on the interpreter's stack.
257
+ # @param app [Application]
258
+ # @return [Array] a step
259
+ def compose_dependent_schemas(app, &cont)
260
+ return cont.call unless app.data.is_a?(Hash)
261
+
262
+ subs = triggered_dependencies(app)
263
+ return cont.call if subs.empty?
264
+
265
+ dependency_branch(app, subs, 0, cont)
266
+ end
267
+
268
+ # @param app [Application]
269
+ # @return [Array<Array(String, Object)>] the trigger and schema of every
270
+ # dependency the instance turns on, in document order
271
+ def triggered_dependencies(app)
272
+ subs = []
273
+ %w[dependentSchemas dependencies].each do |keyword|
274
+ map = app.schema[keyword] if keyword_known?(keyword, app.dialect)
275
+ next unless map.is_a?(Hash)
276
+
277
+ map.each do |trigger, sub|
278
+ subs << [trigger.to_s, sub] if schema_value?(sub) && property_present?(app.data, trigger)
279
+ end
280
+ end
281
+ subs
282
+ end
283
+
284
+ # @param app [Application]
285
+ # @param subs [Array] the triggered dependencies
286
+ # @param idx [Integer] the dependency to apply
287
+ # @param cont [Proc] what follows
288
+ # @return [Array] a step
289
+ def dependency_branch(app, subs, idx, cont)
290
+ return cont.call if idx >= subs.length
291
+
292
+ trigger, sub = subs[idx]
293
+ [:apply, sub, app.ref_depth, false, lambda do |errors, evaluated|
294
+ if errors.empty?
295
+ app.evaluated&.merge!(evaluated)
296
+ else
297
+ app.errors << "#{app.path}: does not satisfy the schema required by property " \
298
+ "'#{clip(trigger)}' (#{clip(errors.first.to_s)})"
299
+ end
300
+ dependency_branch(app, subs, idx + 1, cont)
301
+ end, app.collecting]
302
+ end
303
+
304
+ # allOf: the first failing branch decides it, and later branches are
305
+ # not evaluated (they cannot change the outcome, but could abort it).
306
+ # @param app [Application]
307
+ # @return [Array] a step
308
+ def compose_all_of(app, &cont)
309
+ subs = app.schema['allOf']
310
+ return cont.call unless subs.is_a?(Array)
311
+
312
+ all_of_branch(app, subs, 0, cont)
313
+ end
314
+
315
+ # @param app [Application]
316
+ # @param subs [Array] the allOf branches
317
+ # @param idx [Integer] the branch to evaluate
318
+ # @param cont [Proc] what follows allOf
319
+ # @return [Array] a step
320
+ def all_of_branch(app, subs, idx, cont)
321
+ return cont.call if idx >= subs.length
322
+
323
+ [:apply, subs[idx], app.ref_depth, false, lambda do |errors, evaluated|
324
+ if errors.empty?
325
+ app.evaluated&.merge!(evaluated)
326
+ next all_of_branch(app, subs, idx + 1, cont)
327
+ end
328
+
329
+ app.errors << "#{app.path}: does not satisfy allOf/#{idx} (#{clip(errors.first.to_s)})"
330
+ cont.call
331
+ end, app.collecting]
332
+ end
333
+
334
+ # anyOf is monotonic: a definite pass decides it whatever the other
335
+ # branches, and only undecided branches leave it undecided. Where an
336
+ # unevaluated keyword reads the annotations, every branch is evaluated
337
+ # (each one that passes contributes what it evaluated); otherwise the
338
+ # first definite pass ends it, so a branch that cannot change the
339
+ # outcome cannot abort a decided validation.
340
+ # @param app [Application]
341
+ # @return [Array] a step
342
+ def compose_any_of(app, &cont)
343
+ subs = app.schema['anyOf']
344
+ return cont.call unless subs.is_a?(Array)
345
+
346
+ branch_verdicts(app, subs, stop: ->(vs) { !app.collecting && vs.include?(:pass) },
347
+ decided: ->(vs) { vs.all?(:fail) }) do |verdicts, evaluations|
348
+ if verdicts.all?(:fail)
349
+ app.errors << "#{app.path}: does not satisfy any schema in anyOf"
350
+ else
351
+ merge_passed(app, verdicts, evaluations)
352
+ end
353
+ cont.call
354
+ end
355
+ end
356
+
357
+ # oneOf: two definite passes decide it; with an undecided branch and at
358
+ # most one pass, "exactly one" cannot be told either way.
359
+ # @param app [Application]
360
+ # @return [Array] a step
361
+ def compose_one_of(app, &cont)
362
+ subs = app.schema['oneOf']
363
+ return cont.call unless subs.is_a?(Array)
364
+
365
+ branch_verdicts(app, subs, stop: ->(vs) { vs.count(:pass) > 1 },
366
+ decided: ->(vs) { vs.none?(:undecided) }) do |verdicts, evaluations|
367
+ matches = verdicts.count(:pass)
368
+ if matches > 1 || (verdicts.none?(:undecided) && matches != 1)
369
+ app.errors << "#{app.path}: satisfies #{matches} schemas in oneOf, expected exactly one"
370
+ elsif matches == 1
371
+ merge_passed(app, verdicts, evaluations)
372
+ end
373
+ cont.call
374
+ end
375
+ end
376
+
377
+ # Take over what every passed branch evaluated of the value.
378
+ # @return [void]
379
+ def merge_passed(app, verdicts, evaluations)
380
+ return unless app.evaluated
381
+
382
+ verdicts.each_with_index { |verdict, idx| app.evaluated.merge!(evaluations[idx]) if verdict == :pass }
383
+ end
384
+
385
+ # @param app [Application]
386
+ # @return [Array] a step
387
+ def compose_not(app, &cont)
388
+ return cont.call unless app.schema.key?('not')
389
+
390
+ branch_verdict(app, app.schema['not']) do |verdict, _evaluated|
391
+ app.errors << "#{app.path}: value satisfies the schema in not" if verdict == :pass
392
+ cont.call
393
+ end
394
+ end
395
+
396
+ # if / then / else. An `if` asserts nothing by itself, but it is
397
+ # evaluated even without `then` or `else`: the annotations of a
398
+ # condition that passed are what an `unevaluated*` beside it reads
399
+ # (JSON Schema 2020-12 Section 10.2.2.1). An undecided condition
400
+ # applies neither branch, but may still be settled by the branches
401
+ # agreeing ({#unconditional_conditional}). A condition that passed
402
+ # evaluated the value, and so does the branch applied.
403
+ # @param app [Application]
404
+ # @return [Array] a step
405
+ def compose_conditional(app, &cont)
406
+ schema = app.schema
407
+ return cont.call unless schema.key?('if')
408
+
409
+ branch_verdict(app, schema['if']) do |verdict, evaluated|
410
+ branch = { pass: 'then', fail: 'else' }[verdict]
411
+ next unconditional_conditional(app, cont) if branch.nil?
412
+
413
+ app.evaluated&.merge!(evaluated) if verdict == :pass
414
+ next cont.call unless schema.key?(branch)
415
+
416
+ [:apply, schema[branch], app.ref_depth, false, lambda do |errors, branch_evaluated|
417
+ app.errors.concat(errors)
418
+ app.evaluated&.merge!(branch_evaluated) if errors.empty?
419
+ cont.call
420
+ end, app.collecting]
421
+ end
422
+ end
423
+
424
+ # A condition this validator cannot decide still settles the instance
425
+ # when both outcomes reject it: exactly one of `then` and `else` is
426
+ # applied (JSON Schema 2020-12 Section 10.2.2.2 and 10.2.2.3), so a
427
+ # value both of them reject is invalid whichever way the condition
428
+ # goes. Reporting nothing there would turn a definite failure into a
429
+ # pass — and, under :strict, accept a result the schema rejects. Only
430
+ # a definite rejection counts on each side; what a branch could not
431
+ # evaluate leaves the conditional undecided as before.
432
+ # @param app [Application]
433
+ # @param cont [Proc] what follows the conditional
434
+ # @return [Array] a step
435
+ def unconditional_conditional(app, cont)
436
+ schema = app.schema
437
+ return cont.call unless schema.key?('then') && schema.key?('else')
438
+
439
+ undecided = app.ctx.undecided
440
+ [:apply, schema['then'], app.ref_depth, true, lambda do |then_errors, _evaluated|
441
+ next resume_conditional(app, undecided, cont) if then_errors.empty?
442
+
443
+ [:apply, schema['else'], app.ref_depth, true, lambda do |else_errors, _else_evaluated|
444
+ unless else_errors.empty?
445
+ app.errors << "#{app.path}: fails both then and else of an if this validator cannot decide " \
446
+ "(#{clip(then_errors.first.to_s)})"
447
+ end
448
+ resume_conditional(app, undecided, cont)
449
+ end, app.collecting]
450
+ end, app.collecting]
451
+ end
452
+
453
+ # Resume after measuring the branches: what they could not evaluate is
454
+ # not this node's uncertainty (the condition's already is).
455
+ # @return [Array] a step
456
+ def resume_conditional(app, undecided, cont)
457
+ app.ctx.undecided = undecided
458
+ cont.call
459
+ end
460
+
461
+ # The verdicts of a keyword's branches, and what each branch evaluated.
462
+ # Evaluation stops as soon as the branches seen so far settle the
463
+ # composition (`stop`): a branch that cannot change the outcome is not
464
+ # evaluated, so it cannot abort a decided validation. Once the
465
+ # composition is decided — early, or after every branch (`decided`) —
466
+ # the uncertainty count is restored to what it was before the branches
467
+ # ran.
468
+ # @param app [Application]
469
+ # @param subs [Array] the branches
470
+ # @return [Array] a step
471
+ def branch_verdicts(app, subs, stop:, decided:, &cont)
472
+ before = app.ctx.undecided
473
+ verdicts = []
474
+ evaluations = []
475
+ advance = nil
476
+ advance = lambda do
477
+ if verdicts.length >= subs.length || stop.call(verdicts)
478
+ app.ctx.undecided = before if stop.call(verdicts) || decided.call(verdicts)
479
+ next cont.call(verdicts, evaluations)
480
+ end
481
+
482
+ branch_verdict(app, subs[verdicts.length]) do |verdict, evaluated|
483
+ verdicts << verdict
484
+ evaluations << evaluated
485
+ advance.call
486
+ end
487
+ end
488
+ advance.call
489
+ end
490
+
491
+ # The verdict of a branch evaluated speculatively (its errors are a
492
+ # verdict, not output): :fail when a supported assertion rejected the
493
+ # value, :pass when every assertion was evaluated and accepted it,
494
+ # :undecided when it was accepted only as far as the validator could
495
+ # evaluate. Non-monotonic compositions (not, oneOf, if) never treat
496
+ # :undecided as a match. A definite verdict leaves no uncertainty
497
+ # behind: what a failing branch could not evaluate does not matter once
498
+ # it failed. The continuation also receives what the branch evaluated
499
+ # of the value (nothing, for a failed one).
500
+ # @param app [Application]
501
+ # @param sub [Object] the branch schema
502
+ # @return [Array] a step
503
+ def branch_verdict(app, sub, &cont)
504
+ ctx = app.ctx
505
+ before = ctx.undecided
506
+ [:apply, sub, app.ref_depth, true, lambda do |errors, evaluated|
507
+ unless errors.empty?
508
+ ctx.undecided = before
509
+ next cont.call(:fail, nil)
510
+ end
511
+
512
+ cont.call(ctx.undecided == before ? :pass : :undecided, evaluated)
513
+ end, app.collecting]
514
+ end
515
+ end
516
+ end
517
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module SchemaValidator
5
+ # What an input schema requires of every instance, read through the
6
+ # applicators that apply unconditionally: the root, what its `$ref`
7
+ # chain reaches, and each `allOf` member (each of those recursively).
8
+ # The tools specification says clients SHOULD follow `$ref` resolution
9
+ # when validating tool inputs, and SEP-2106 made `$ref` and `allOf` legal
10
+ # on an inputSchema: a `required` behind them is as required as one at
11
+ # the root. A branch the instance may or may not satisfy (`anyOf`,
12
+ # `oneOf`, `if`) decides nothing here and is left to the server, which
13
+ # MUST validate the arguments anyway. Extended into SchemaValidator, so
14
+ # the methods are its own.
15
+ module InputRequirements
16
+ # @param schema [Object] the input schema (string or symbol keys)
17
+ # @return [Array(Array<String>, Hash{String => Object})] the required
18
+ # property names, and the declared properties by name (a property
19
+ # declared nearer the root wins)
20
+ def input_requirements(schema)
21
+ root = normalize_schema(schema)
22
+ return [[], {}] unless root.is_a?(Hash)
23
+
24
+ declared = dialect(root)
25
+ scan = { count: 0, dialect: declared && canonical_dialect(declared), anchors: nil,
26
+ walked: {}.compare_by_identity, required: [], properties: {}, pending: [root] }
27
+ read_requirement_positions(root, scan)
28
+ [scan[:required].uniq, scan[:properties]]
29
+ rescue TooLarge
30
+ # Unusable anyway: the preflight reports it.
31
+ [[], {}]
32
+ end
33
+
34
+ # Read every queued position, bounded like the preflight walk.
35
+ # @return [void]
36
+ def read_requirement_positions(root, scan)
37
+ until scan[:pending].empty?
38
+ node = scan[:pending].pop
39
+ next unless node.is_a?(Hash) && !scan[:walked].key?(node)
40
+
41
+ scan[:walked][node] = true
42
+ scan[:count] += 1
43
+ return if scan[:count] > MAX_SUBSCHEMAS
44
+
45
+ read_requirements(node, root, scan)
46
+ end
47
+ end
48
+
49
+ # One position's requirements, and what it applies next. Under draft-07
50
+ # nothing beside a `$ref` is applied (draft-07 Core Section 8.3) — the
51
+ # draft-07 of the resource the position belongs to, which an embedded
52
+ # resource declares for itself (2020-12 Core Section 9.3.2), not the
53
+ # document root's.
54
+ # @return [void]
55
+ def read_requirements(node, root, scan)
56
+ ref = node['$ref']
57
+ queue_referenced(node, ref, root, scan) if ref.is_a?(String)
58
+ return if position_dialect(node, root, scan) == DRAFT_07 && node.key?('$ref')
59
+
60
+ scan[:required].concat(node['required'].map(&:to_s)) if node['required'].is_a?(Array)
61
+ scan[:properties] = node['properties'].merge(scan[:properties]) if node['properties'].is_a?(Hash)
62
+ scan[:pending].concat(node['allOf'].reverse) if node['allOf'].is_a?(Array)
63
+ end
64
+
65
+ # The dialect in force at a position: the one the anchor index recorded
66
+ # for its resource, else the root's.
67
+ # @return [String, nil]
68
+ def position_dialect(node, root, scan)
69
+ scan[:anchors] ||= anchor_index(root, scan[:dialect])
70
+ indexed_dialect(node, scan) || scan[:dialect]
71
+ end
72
+
73
+ # Queue what a local reference reaches (an external one is never
74
+ # dereferenced, and an unresolvable one is the preflight's to report).
75
+ # @return [void]
76
+ def queue_referenced(node, ref, root, scan)
77
+ return if external_ref?(ref, root, scan[:dialect], scan, from: node)
78
+
79
+ target = resolve_reference(root, ref, scan[:dialect], scan, from: node)
80
+ scan[:pending] << target unless target.equal?(UNRESOLVED)
81
+ end
82
+ end
83
+ end
84
+ end