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
@@ -1,43 +1,189 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+ require_relative 'schema_validator/annotations'
5
+ require_relative 'schema_validator/dialects'
6
+ require_relative 'schema_validator/normalization'
7
+ require_relative 'schema_validator/uri_references'
8
+ require_relative 'schema_validator/references'
9
+ require_relative 'schema_validator/shapes'
10
+ require_relative 'schema_validator/keyword_scan'
11
+ require_relative 'schema_validator/composition'
12
+ require_relative 'schema_validator/evaluation'
13
+ require_relative 'schema_validator/scalars'
14
+ require_relative 'schema_validator/ecma_patterns'
15
+ require_relative 'schema_validator/input_requirements'
16
+ require_relative 'schema_validator/instances'
17
+
3
18
  module MCPClient
4
19
  # Self-contained JSON Schema validator used to check a tool call result's
5
- # structuredContent against the tool's declared outputSchema (MCP 2025-11-25
6
- # server/tools spec: "Clients SHOULD validate structured results against this
7
- # schema"; the default schema dialect is JSON Schema 2020-12 per SEP-1613).
20
+ # structuredContent against the tool's declared outputSchema (MCP server/tools
21
+ # spec: "Clients SHOULD validate structured results against this schema";
22
+ # the default schema dialect is JSON Schema 2020-12 per basic "JSON Schema
23
+ # Usage").
8
24
  #
9
- # Only the common JSON Schema keywords are supported:
25
+ # Supported keywords:
10
26
  # - type (single value or array of values), enum, const
11
27
  # - properties, required (objects)
12
- # - items, minItems, maxItems (arrays)
13
- # - minLength, maxLength, pattern (strings)
28
+ # - items, prefixItems (2020-12) / tuple-form items (draft-07, 2019-09),
29
+ # minItems, maxItems (arrays)
30
+ # - minLength, maxLength, pattern (an ECMA-262 regular expression,
31
+ # translated to the Ruby expression that means the same thing) (strings)
14
32
  # - minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)
33
+ # - allOf, anyOf, oneOf, not, if/then/else (composition)
34
+ # - $ref to a location inside the same schema document (`#`, `#/$defs/x`,
35
+ # `#/definitions/x`, any JSON pointer, or a plain-name fragment `#name`
36
+ # naming an `$anchor` / a draft-07 `$id: "#name"` of the referencing
37
+ # schema's own resource — a subschema whose `$id` is a URI starts a new
38
+ # resource), with $defs (2019-09, 2020-12) and definitions, which the
39
+ # modern dialects keep as the deprecated spelling of $defs;
40
+ # under draft-07 a $ref replaces its siblings, under 2019-09 and 2020-12
41
+ # it applies alongside them
42
+ # - boolean schemas (true / false), at the root or as subschemas
43
+ #
44
+ # MCP 2026-07-28 rules honoured here:
45
+ # - a schema without `$schema` is 2020-12; the dialects in
46
+ # SUPPORTED_DIALECTS are accepted and any other declared dialect is an
47
+ # error (not a permissive pass); the keyword grammar follows the dialect
48
+ # (DIALECT_KEYWORDS): a keyword the dialect does not define is ignored,
49
+ # neither shape-checked nor reported;
50
+ # - `$ref` (and `$dynamicRef` / `$recursiveRef`) values that do not point
51
+ # inside the document (network URIs, relative documents, urn:, file:)
52
+ # are never dereferenced, and a schema carrying one is rejected rather
53
+ # than treated as permissive;
54
+ # - resource bounds: schema nesting depth, total subschema count, `$ref`
55
+ # chain length, number of nodes visited, number of errors produced and a
56
+ # per-validation time budget. Hitting a bound aborts the validation with
57
+ # one error: an aborted validation never reads as a pass.
58
+ #
59
+ # - multipleOf (numbers), uniqueItems, contains with minContains /
60
+ # maxContains, additionalItems (draft-07, 2019-09) (arrays)
61
+ # - minProperties, maxProperties, patternProperties, additionalProperties,
62
+ # propertyNames, dependentRequired / dependentSchemas (2019-09, 2020-12)
63
+ # and draft-07 dependencies (objects)
15
64
  #
16
- # The full JSON Schema 2020-12 vocabulary ($ref/$defs, allOf/anyOf/oneOf/not,
17
- # conditional keywords, additionalProperties, format assertions, ...) is out
18
- # of scope: unrecognized keywords are ignored rather than misapplied, so
19
- # validation is best-effort — it may accept data a full validator would
20
- # reject, but it does not reject data that conforms to the schema. So that
21
- # this gap is never silent, {.unsupported_keywords} reports which unapplied
22
- # validation keywords a schema uses; callers surface them as a warning.
65
+ # - unevaluatedItems, unevaluatedProperties at a node that produces every
66
+ # annotation they read: with no in-place applicator beside them (and, for
67
+ # items, no `contains`) they are `additionalItems` / `additionalProperties`
68
+ # over what the node did not name
69
+ #
70
+ # What is left out is exactly what cannot be decided here:
71
+ # unevaluatedItems / unevaluatedProperties where an in-place applicator
72
+ # (allOf, anyOf, oneOf, if, $ref, dependentSchemas — or a `contains`, whose
73
+ # matches annotate the items they matched) contributes annotations
74
+ # collected across a whole composition, the dynamic references
75
+ # ($dynamicRef, $recursiveRef), whose target depends on the dynamic scope a
76
+ # validation was entered through, and the two keywords that only annotate
77
+ # in the default dialect (format, which full validators assert only in
78
+ # format-assertion mode, and contentSchema). Those are ignored rather than
79
+ # misapplied, so validation is best-effort there — it may accept data a
80
+ # full validator would reject, but it does not reject data that conforms to
81
+ # the schema, and an unevaluated keyword is never read as a match for a
82
+ # non-monotonic composition (not, oneOf, if). So that this gap is never
83
+ # silent, {.unsupported_keywords} reports which unapplied validation
84
+ # keywords a schema uses; callers surface them as a warning.
23
85
  module SchemaValidator
24
- # JSON Schema 2020-12 keywords that affect validation but that this
25
- # validator does not evaluate: applicator/reference keywords, assertion
26
- # keywords (multipleOf, uniqueItems, contains bounds, property-count
27
- # bounds, dependentRequired), and format (asserted by full validators in
28
- # format-assertion mode). Their presence means validation is partial: data
29
- # may pass here that a full validator would reject.
30
- UNSUPPORTED_KEYWORDS = %w[
31
- $ref $dynamicRef $defs allOf anyOf oneOf not if then else
32
- additionalProperties patternProperties propertyNames dependentSchemas
33
- prefixItems contains minContains maxContains uniqueItems
34
- multipleOf format dependentRequired minProperties maxProperties
35
- unevaluatedProperties unevaluatedItems
36
- ].freeze
37
-
38
- # Wall-clock budget for ALL pattern matching in a single validate call.
39
- # Schemas come from the remote server, so an expensive expression must not
40
- # be able to monopolize the calling thread.
86
+ extend Dialects
87
+ extend Normalization
88
+ extend UriReferences
89
+ extend References
90
+ extend Shapes
91
+ extend KeywordScan
92
+ extend Composition
93
+ extend Evaluation
94
+ extend Scalars
95
+ extend EcmaPatterns
96
+ extend InputRequirements
97
+ extend Instances
98
+
99
+ # Raised inside a validation to abandon it (time budget, resource bound).
100
+ class Aborted < StandardError; end
101
+
102
+ # The default dialect (basic "JSON Schema Usage": "When a schema does not
103
+ # include a $schema field, it defaults to JSON Schema 2020-12").
104
+ DEFAULT_DIALECT = 'https://json-schema.org/draft/2020-12/schema'
105
+ DRAFT_2019_09 = 'https://json-schema.org/draft/2019-09/schema'
106
+ DRAFT_07 = 'http://json-schema.org/draft-07/schema'
107
+
108
+ # Dialects this validator accepts. Any other declared dialect is
109
+ # reported as unsupported ("MUST handle unsupported dialects gracefully
110
+ # by returning an appropriate error indicating the dialect is not
111
+ # supported").
112
+ SUPPORTED_DIALECTS = [DEFAULT_DIALECT, DRAFT_2019_09, DRAFT_07].freeze
113
+
114
+ # Resource bounds ("Composition-Keyword Resource Use": implementations
115
+ # SHOULD apply a maximum schema depth, a cap on the total number of
116
+ # subschemas, or a per-validation time budget). A schema comes from the
117
+ # remote server, so all of them apply.
118
+ MAX_SCHEMA_DEPTH = 64
119
+ MAX_SUBSCHEMAS = 2000
120
+ MAX_REF_DEPTH = 32
121
+ MAX_NODE_VISITS = 100_000
122
+ MAX_ERRORS = 100
123
+ MAX_VALUE_INSPECT = 64
124
+
125
+ # How deeply the walk may descend into the instance. The descent into a
126
+ # child value is the walk's only recursion, so this is what drives the
127
+ # interpreter's stack: the instance nests a level per array item or
128
+ # property, and a recursive `$ref` follows it down without bound (the hop
129
+ # budget restarts at every value, so that a recursive schema can describe
130
+ # deep data at all). Counting the descent lets the deepest instance abort
131
+ # with one error like any other exhausted budget.
132
+ #
133
+ # Only a step into a child value — an array item or a property value —
134
+ # counts, and only such a step costs a frame. The schemas a node applies
135
+ # to one value (a `$ref` hop, an allOf/anyOf/oneOf/not/if branch) neither
136
+ # count nor recurse: a recursive schema composed through a few `$defs`
137
+ # mixins applies several of them per instance level, and both counting
138
+ # and recursing on those would scale with the mixin count, so data a peer
139
+ # can legitimately send — its nesting under `JSON.parse`'s default limit
140
+ # of 100 — would abort. {.validate_node} applies them iteratively (see
141
+ # {Evaluation}), bounded by MAX_REF_DEPTH and MAX_NODE_VISITS, which is
142
+ # what already stops a schema recursing on one value forever.
143
+ #
144
+ # The bound therefore sits well above the deepest instance a peer can
145
+ # send, and below the number of levels the smallest stack this library
146
+ # runs on (a transport's reader thread) carries — a figure that no longer
147
+ # depends on how a schema is composed, since what one level costs is now
148
+ # fixed. {.validate} still catches SystemStackError, as a backstop for a
149
+ # stack smaller than any this has been measured on rather than for
150
+ # anything a schema can provoke.
151
+ MAX_NODE_DEPTH = 256
152
+
153
+ # JSON Schema keywords that affect validation but that this validator
154
+ # may not evaluate: the dynamic references where they are dynamic (a
155
+ # target the dynamic scope a validation was entered through could
156
+ # re-bind, which this validator does not track), and the two that only
157
+ # annotate in the default dialect (`format`, asserted by full validators
158
+ # in format-assertion mode, and `contentSchema`). Where one of them is
159
+ # left unevaluated, validation is partial: data may pass here that a full
160
+ # validator would reject.
161
+ #
162
+ # A dynamic reference is not in this position: it is evaluated, against
163
+ # the dynamic scope the validation records as it enters each resource
164
+ # ({References#dynamic_binding}).
165
+ #
166
+ # Every other standard keyword is evaluated — `unevaluatedItems` and
167
+ # `unevaluatedProperties` from the annotations {Evaluation} collects
168
+ # across a whole composition. A standard assertion left unevaluated is
169
+ # not a smaller report but a wrong verdict: it makes a composition
170
+ # branch undecided, and an undecided branch is accepted wherever the
171
+ # composition is monotonic (`allOf`, `anyOf`), so the instance passes a
172
+ # schema that rejects it.
173
+ UNSUPPORTED_KEYWORDS = %w[contentSchema format].freeze
174
+
175
+ # Unsupported keywords that are annotations, not assertions (`format`
176
+ # is annotation-only in the default 2020-12 vocabulary, `contentSchema`
177
+ # only annotates): their presence decides nothing about an instance.
178
+ ANNOTATION_KEYWORDS = %w[format contentSchema].freeze
179
+
180
+ # The references whose target may depend on the dynamic scope.
181
+ DYNAMIC_REFERENCE_KEYWORDS = %w[$dynamicRef $recursiveRef].freeze
182
+
183
+ # Wall-clock budget for a single validate call (pattern matching and the
184
+ # walk itself). Schemas come from the remote server, so an expensive
185
+ # expression or a huge composition must not be able to monopolize the
186
+ # calling thread.
41
187
  #
42
188
  # The budget is for the whole operation, not per match: a per-match limit
43
189
  # multiplies, since the server also controls how many strings it sends
@@ -48,75 +194,716 @@ module MCPClient
48
194
  # still makes progress rather than failing every remaining pattern.
49
195
  MIN_PATTERN_MATCH_TIMEOUT = 0.01
50
196
 
51
- # Keywords whose value is a single subschema to walk.
197
+ # The longest `pattern` (or `patternProperties` key) a schema may carry.
198
+ # A pattern is translated and compiled before it is matched, and both
199
+ # cost what the peer's text costs; the depth and subschema bounds say
200
+ # nothing about one string, so its length is bounded on its own.
201
+ MAX_PATTERN_LENGTH = 10_000
202
+
203
+ # Keywords whose value is a single subschema to walk (or, for `items`,
204
+ # an array of positional subschemas in draft-07 / 2019-09).
52
205
  SUBSCHEMA_KEYWORDS = %w[
53
- items contains additionalProperties propertyNames not if then else
54
- unevaluatedItems unevaluatedProperties
206
+ items contains additionalProperties additionalItems propertyNames not if then else
207
+ unevaluatedItems unevaluatedProperties contentSchema
55
208
  ].freeze
56
209
 
57
210
  # Keywords whose value is a map of name => subschema.
58
- SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
211
+ SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas dependencies].freeze
59
212
 
60
213
  # Keywords whose value is an array of subschemas.
61
214
  SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
62
215
 
63
- # List the unsupported JSON Schema keywords a schema uses (anywhere: at the
64
- # top level or nested in subschemas). Property names that merely look like
65
- # keywords (e.g. a property called 'not') are not reported, and
66
- # data-carrying keywords (enum/const/default/examples) are not scanned.
67
- # @param schema [Object] the JSON schema (string or symbol keys)
68
- # @return [Array<String>] unique unsupported keywords, in discovery order
69
- def self.unsupported_keywords(schema)
70
- found = []
71
- collect_unsupported_keywords(schema, found)
72
- found.uniq
73
- end
74
-
75
- # Recursively collect unsupported keywords from a schema.
76
- # @param schema [Object] a (sub)schema; non-Hash values are ignored
77
- # @param found [Array<String>] accumulator
216
+ # Keywords whose value is data, not schema: never walked, never re-keyed.
217
+ DATA_KEYWORDS = %w[enum const default examples].freeze
218
+
219
+ # Keywords that exist only in some dialects, with the dialects that
220
+ # define them. A keyword absent from this table exists in every
221
+ # supported dialect. Under a dialect that does not define a keyword the
222
+ # keyword is an unknown one: ignored, never shape-checked, walked,
223
+ # evaluated or reported as unsupported.
224
+ DIALECT_KEYWORDS = {
225
+ 'prefixItems' => [DEFAULT_DIALECT],
226
+ '$dynamicRef' => [DEFAULT_DIALECT],
227
+ '$dynamicAnchor' => [DEFAULT_DIALECT],
228
+ '$recursiveRef' => [DRAFT_2019_09],
229
+ '$recursiveAnchor' => [DRAFT_2019_09],
230
+ '$vocabulary' => [DEFAULT_DIALECT, DRAFT_2019_09],
231
+ 'additionalItems' => [DRAFT_2019_09, DRAFT_07],
232
+ 'dependencies' => [DRAFT_07],
233
+ 'dependentSchemas' => [DEFAULT_DIALECT, DRAFT_2019_09],
234
+ 'dependentRequired' => [DEFAULT_DIALECT, DRAFT_2019_09],
235
+ 'unevaluatedItems' => [DEFAULT_DIALECT, DRAFT_2019_09],
236
+ 'unevaluatedProperties' => [DEFAULT_DIALECT, DRAFT_2019_09],
237
+ 'contentSchema' => [DEFAULT_DIALECT, DRAFT_2019_09],
238
+ 'deprecated' => [DEFAULT_DIALECT, DRAFT_2019_09],
239
+ 'minContains' => [DEFAULT_DIALECT, DRAFT_2019_09],
240
+ 'maxContains' => [DEFAULT_DIALECT, DRAFT_2019_09],
241
+ '$anchor' => [DEFAULT_DIALECT, DRAFT_2019_09],
242
+ '$defs' => [DEFAULT_DIALECT, DRAFT_2019_09]
243
+ }.freeze
244
+
245
+ # Plain-name fragment syntax, per dialect: 2020-12 Core Section 8.2.2
246
+ # admits a leading underscore and no colon, while 2019-09 Core Section
247
+ # 8.2.3 and draft-07 Core Section 8.2.3 admit a colon and require a
248
+ # leading letter. A name legal in one dialect is not one in the other,
249
+ # and the dialect in force at the resource decides.
250
+ ANCHOR_NAME_BY_DIALECT = {
251
+ DEFAULT_DIALECT => /\A[A-Za-z_][-A-Za-z0-9._]*\z/,
252
+ DRAFT_2019_09 => /\A[A-Za-z][-A-Za-z0-9.:_]*\z/,
253
+ DRAFT_07 => /\A[A-Za-z][-A-Za-z0-9.:_]*\z/
254
+ }.freeze
255
+
256
+ # The 2020-12 plain-name syntax, the default when no dialect is known.
257
+ ANCHOR_NAME = ANCHOR_NAME_BY_DIALECT.fetch(DEFAULT_DIALECT)
258
+
259
+ # @param name [Object] the candidate plain name
260
+ # @param dialect [String, nil] the canonical dialect in force
261
+ # @return [Boolean] whether the dialect admits the name as a plain name
262
+ def self.anchor_name?(name, dialect)
263
+ name.is_a?(String) && name.match?(ANCHOR_NAME_BY_DIALECT.fetch(dialect, ANCHOR_NAME))
264
+ end
265
+
266
+ # @param keyword [String]
267
+ # @param dialect [String, nil] a canonical dialect, or nil for "any"
268
+ # @return [Boolean] whether the dialect defines the keyword
269
+ def self.keyword_known?(keyword, dialect)
270
+ dialect.nil? || !DIALECT_KEYWORDS.key?(keyword) || DIALECT_KEYWORDS[keyword].include?(dialect)
271
+ end
272
+
273
+ # Per-validation state. `scope` is the dynamic scope: the schema
274
+ # resources the evaluation has entered, outermost first, which is what a
275
+ # dynamic reference binds against (JSON Schema 2020-12 Core Section
276
+ # 8.2.3.2).
277
+ Context = Struct.new(:root, :deadline, :dialect, :visits, :errors, :speculative, :anchors, :undecided, :depth,
278
+ :scope, keyword_init: true)
279
+
280
+ # Per-preflight state: the document, the counter the walk accounts to,
281
+ # the problems it found, and the positions it has still to read (a
282
+ # stack, so the walk needs no interpreter frame of its own).
283
+ Walk = Struct.new(:root, :counter, :problems, :pending, keyword_init: true)
284
+
285
+ # Raised while normalizing a schema whose structure exceeds the bounds,
286
+ # so a peer-supplied document is never copied whole.
287
+ class TooLarge < StandardError; end
288
+
289
+ # Structural elements (schemas, the keyword maps holding them, and array
290
+ # members — boolean subschemas included) a schema document may contain
291
+ # before it is rejected unread. A usable schema has at most
292
+ # MAX_SUBSCHEMAS subschemas, each with a handful of keyword maps at
293
+ # most, so this bound only ever stops documents the preflight would
294
+ # reject anyway.
295
+ MAX_STRUCTURAL_OBJECTS = MAX_SUBSCHEMAS * 4
296
+
297
+ # Check that a schema can be used at all: it is an object or a boolean,
298
+ # its dialect is supported, it stays within the resource bounds, and
299
+ # every `$ref` resolves inside the document to a schema (a network or
300
+ # otherwise external reference is never dereferenced and makes the
301
+ # schema unusable rather than permissive).
302
+ # The check runs under a deadline like a validation does: the schema is
303
+ # the peer's, and reading it (translating and compiling its patterns
304
+ # above all) costs what the peer's text costs.
305
+ # @param schema [Object] the schema (string or symbol keys)
306
+ # @param counter [Hash] filled in with the preflight state, so a caller
307
+ # can tell one kind of problem from another (`:unsupported_dialect`)
308
+ # @param deadline [Float, nil] monotonic deadline for the whole check
309
+ # (PATTERN_MATCH_TIMEOUT from now when none is given)
310
+ # @return [Array<String>] problems (empty when the schema is usable)
311
+ def self.check_schema(schema, counter = {}, deadline: nil)
312
+ counter[:deadline] = deadline || (Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT)
313
+ check_normalized(normalize_schema(schema, deadline: counter[:deadline]), counter)
314
+ rescue TooLarge => e
315
+ [e.message]
316
+ rescue Aborted => e
317
+ ["validation aborted: #{e.message}"]
318
+ end
319
+
320
+ # The dialect a schema declares (at its root or at an embedded resource
321
+ # root) that this validator does not implement. MCP 2026-07-28 basic
322
+ # "Implementation Requirements": a client "MUST handle unsupported
323
+ # dialects gracefully by returning an appropriate error indicating the
324
+ # dialect is not supported", which a caller can only do once it can tell
325
+ # an unsupported dialect from every other reason a schema is unusable.
326
+ # @param schema [Object] the schema (string or symbol keys)
327
+ # @return [String, nil] the declared dialect, or nil when every dialect
328
+ # the document declares is supported
329
+ def self.unsupported_dialect(schema)
330
+ state = {}
331
+ check_schema(schema, state)
332
+ state[:unsupported_dialect]
333
+ end
334
+
335
+ # A bounded, string-keyed copy of a schema (booleans pass through).
336
+ # @param schema [Object]
337
+ # @param deadline [Float, nil] monotonic deadline the copy runs under
338
+ # @return [Object]
339
+ # @raise [TooLarge] when the document exceeds MAX_STRUCTURAL_OBJECTS
340
+ # @raise [Aborted] when the deadline passed during the copy
341
+ def self.normalize_schema(schema, deadline: nil)
342
+ schema.is_a?(Hash) ? deep_stringify(schema, 0, { objects: 0, deadline: deadline }) : schema
343
+ end
344
+
345
+ # {.check_schema} on an already normalized schema.
346
+ # @param counter [Hash] filled in with the preflight state (the memoized
347
+ # anchor index included), so a caller going on to validate reads the
348
+ # document the way the preflight read it
349
+ # @return [Array<String>] problems
350
+ def self.check_normalized(root, counter = {})
351
+ return [] if [true, false].include?(root)
352
+ return ["schema must be an object or a boolean, got #{json_type(root)}"] unless root.is_a?(Hash)
353
+
354
+ declared = dialect(root)
355
+ return ['$schema must be a non-empty string naming the dialect'] if declared.nil?
356
+
357
+ unless supported_dialect?(declared)
358
+ counter[:unsupported_dialect] = declared
359
+ return ["schema dialect #{clip(declared.inspect)} is not supported " \
360
+ "(supported: #{SUPPORTED_DIALECTS.join(', ')})"]
361
+ end
362
+
363
+ problems = []
364
+ counter.update(count: 0, dialect: canonical_dialect(declared), walked: {}.compare_by_identity,
365
+ depths: lexical_depths(root, canonical_dialect(declared)))
366
+ walk_schema(root, root, 0, counter, problems)
367
+ exhausted = problems.empty? && budget_exhausted?(counter[:deadline])
368
+ problems << 'validation aborted: validation time budget exhausted during the schema check' if exhausted
369
+ problems.concat(anchor_index_problems(root, counter)) if problems.empty?
370
+ problems.uniq
371
+ end
372
+
373
+ # Problems the anchor index reveals: anchor names must be unique within
374
+ # a schema resource (JSON Schema 2020-12 Core Section 8.2.2) and a
375
+ # resource URI unique within the document (Section 9.1.2) — either
376
+ # declared twice would bind a reference to whichever declaration the
377
+ # walk met first — and an index that stopped at its bound before
378
+ # reaching every object leaves references and resource dialects
379
+ # undecided, so either makes the schema unusable.
380
+ # @param resolver [Hash] holder of the memoized anchor index
381
+ # @return [Array<String>]
382
+ def self.anchor_index_problems(root, resolver)
383
+ index = (resolver[:anchors] ||= anchor_index(root, resolver[:dialect]))
384
+ problems = index[:duplicates].map do |name|
385
+ "anchor #{clip(name.inspect)} is declared more than once in a schema resource"
386
+ end
387
+ problems.concat(index[:duplicate_ids].uniq.map do |base|
388
+ "schema resource #{clip(base.inspect)} is declared more than once"
389
+ end)
390
+ if index[:truncated]
391
+ problems << 'schema has too many or too deeply nested objects to index its anchors ' \
392
+ "(more than #{MAX_SUBSCHEMAS}, or deeper than #{MAX_SCHEMA_DEPTH})"
393
+ end
394
+ problems
395
+ end
396
+
397
+ # Walk every subschema position, checking bounds and references. A
398
+ # schema object is walked once, however many positions or references
399
+ # lead to it, so a recursive schema stays within the bounds. The dialect
400
+ # follows the resource: an embedded resource declaring `$schema` is
401
+ # walked under its own dialect.
402
+ # @return [void]
403
+ def self.walk_schema(schema, root, depth, counter, problems, dialect = counter[:dialect])
404
+ walk = Walk.new(root: root, counter: counter, problems: problems,
405
+ pending: [[schema, depth, dialect]])
406
+ until walk.pending.empty?
407
+ return unless problems.empty?
408
+
409
+ walk_position(walk, *walk.pending.pop)
410
+ end
411
+ end
412
+
413
+ # Walk one schema position and queue the positions it leads to. The
414
+ # queue is a stack and children are pushed in reverse, so a document is
415
+ # read depth-first and in document order, exactly as a recursive walk
416
+ # would read it — but a `$ref` chain hundreds of schemas long costs
417
+ # queue entries rather than interpreter frames, so a shallow document
418
+ # whose references chain deeply can no longer overflow the (small) stack
419
+ # of the thread a transport reads on. A reference is followed before the
420
+ # position's own subschemas, so it is pushed last.
421
+ # @return [void]
422
+ def self.walk_position(walk, schema, depth, dialect)
423
+ return unless schema_value?(schema)
424
+ # The positions are as many as the peer's document holds, and each
425
+ # may cost a pattern's translation: the deadline is consulted before
426
+ # every one, and a check past it stops there ({.check_normalized}
427
+ # reports it).
428
+ return if budget_exhausted?(walk.counter[:deadline])
429
+ return unless admit_schema?(schema, depth, walk.counter, walk.problems)
430
+
431
+ if resource_root?(schema, dialect) && schema.key?('$schema')
432
+ problem = embedded_dialect_problem(schema)
433
+ return record_embedded_dialect_problem(walk, schema, problem) if problem
434
+
435
+ dialect = embedded_dialect(schema, dialect)
436
+ elsif schema.key?('$schema') && !schema.equal?(walk.root) && !resource_start?(schema)
437
+ # A `$schema` is read at a schema resource root only (JSON Schema
438
+ # 2020-12 Core Section 8.1.1); anywhere else it is a malformed
439
+ # keyword, not an absent one.
440
+ walk.problems << '$schema is only allowed at a schema resource root (the document root, or a ' \
441
+ 'subschema declaring an $id)'
442
+ return
443
+ end
444
+ referenced = schema.key?('$ref') ? check_ref(walk, schema, depth, dialect) : []
445
+ # draft-07: the $ref replaces its siblings, so the applicators next to
446
+ # it are never applied and are not preflighted either; definitions are
447
+ # a bag of reusable schemas, not applicators, and stay reachable
448
+ # through references.
449
+ return queue_definitions(walk, schema, depth, dialect, referenced) if dialect == DRAFT_07 && schema.key?('$ref')
450
+
451
+ referenced.concat(check_dynamic_refs(walk, schema, depth, dialect))
452
+ check_keyword_shapes(walk, schema, dialect)
453
+ queue_subschemas(walk, schema, depth, dialect, referenced)
454
+ end
455
+
456
+ # @return [void]
457
+ def self.record_embedded_dialect_problem(walk, schema, problem)
458
+ declared = dialect(schema)
459
+ walk.counter[:unsupported_dialect] ||= declared if declared && !supported_dialect?(declared)
460
+ walk.problems << problem
461
+ end
462
+
463
+ # Queue the reusable schemas beside a draft-07 `$ref`, then what the
464
+ # reference reaches (walked first, so pushed last).
465
+ # @return [void]
466
+ def self.queue_definitions(walk, schema, depth, dialect, referenced)
467
+ positions = []
468
+ each_definition(schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
469
+ queue_positions(walk, positions, referenced)
470
+ end
471
+
472
+ # Queue every subschema position under a schema object, then what its
473
+ # reference reaches.
474
+ # @return [void]
475
+ def self.queue_subschemas(walk, schema, depth, dialect, referenced)
476
+ positions = []
477
+ each_subschema(schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
478
+ queue_positions(walk, positions, referenced)
479
+ end
480
+
481
+ # @return [void]
482
+ def self.queue_positions(walk, positions, referenced)
483
+ walk.pending.concat(positions.reverse)
484
+ walk.pending.concat(referenced.reverse)
485
+ end
486
+
487
+ # Every check a schema object's own keywords get.
78
488
  # @return [void]
79
- def self.collect_unsupported_keywords(schema, found)
80
- return unless schema.is_a?(Hash)
489
+ def self.check_keyword_shapes(walk, schema, dialect)
490
+ problems = walk.problems
491
+ if dialect == DEFAULT_DIALECT && schema['items'].is_a?(Array)
492
+ problems << 'items must be a schema in JSON Schema 2020-12 (positional schemas go in prefixItems)'
493
+ end
494
+ check_applicator_shapes(schema, dialect, problems)
495
+ check_assertion_shapes(schema, dialect, problems)
496
+ check_exclusive_bounds(schema, dialect, problems)
497
+ check_identifier_shapes(schema, dialect, problems)
498
+ check_core_keyword_shapes(schema, dialect, problems)
499
+ check_pattern_shapes(schema, dialect, problems, walk.counter[:deadline])
500
+ end
501
+
502
+ # Account for a schema (object or boolean) about to be walked: once per
503
+ # object, and within the subschema and nesting bounds — a boolean is a
504
+ # subschema too and obeys the depth bound.
505
+ # @return [Boolean] whether the object's keywords are to be walked
506
+ def self.admit_schema?(schema, depth, counter, problems)
507
+ return false if schema.is_a?(Hash) && counter[:walked].key?(schema)
508
+
509
+ counter[:walked][schema] = true if schema.is_a?(Hash)
510
+ counter[:count] += 1
511
+ if counter[:count] > MAX_SUBSCHEMAS
512
+ problems << "schema has more than #{MAX_SUBSCHEMAS} subschemas"
513
+ return false
514
+ end
515
+ if depth > MAX_SCHEMA_DEPTH
516
+ problems << "schema nesting depth exceeds #{MAX_SCHEMA_DEPTH}"
517
+ return false
518
+ end
519
+ schema.is_a?(Hash)
520
+ end
81
521
 
82
- schema = schema.transform_keys(&:to_s)
83
- found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
522
+ # Yield every subschema directly under a schema object (skipping the
523
+ # keywords the dialect does not define; nil applies no dialect).
524
+ # @return [void]
525
+ def self.each_subschema(schema, dialect = nil, &block)
84
526
  schema.each do |keyword, value|
85
- if SUBSCHEMA_KEYWORDS.include?(keyword)
86
- collect_unsupported_keywords(value, found)
87
- elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
88
- value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
89
- elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
90
- value.each { |subschema| collect_unsupported_keywords(subschema, found) }
527
+ next if DATA_KEYWORDS.include?(keyword) || !keyword_known?(keyword, dialect)
528
+
529
+ subschemas_under(keyword, value).each(&block)
530
+ end
531
+ end
532
+
533
+ # The subschema positions one keyword holds.
534
+ # @return [Array<Object>]
535
+ def self.subschemas_under(keyword, value)
536
+ if SUBSCHEMA_KEYWORDS.include?(keyword)
537
+ value.is_a?(Array) ? value : [value]
538
+ elsif keyword == 'dependencies'
539
+ # Property-name arrays are data; only the schema entries are walked.
540
+ value.is_a?(Hash) ? value.values.select { |v| schema_value?(v) } : []
541
+ elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword)
542
+ value.is_a?(Hash) ? value.values : []
543
+ elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword)
544
+ value.is_a?(Array) ? value : []
545
+ else
546
+ []
547
+ end
548
+ end
549
+
550
+ # Yield the reusable schemas under the definition bag(s) the dialect
551
+ # defines: `definitions` everywhere (2020-12 Validation Appendix A keeps
552
+ # it as the deprecated spelling of `$defs`) and `$defs` in 2019-09 and
553
+ # 2020-12; both when no dialect is given.
554
+ # @return [void]
555
+ def self.each_definition(schema, dialect = nil, &block)
556
+ %w[$defs definitions].each do |keyword|
557
+ next unless keyword_known?(keyword, dialect)
558
+
559
+ schema[keyword].each_value(&block) if schema[keyword].is_a?(Hash)
560
+ end
561
+ end
562
+
563
+ # Check the `$ref` of a schema object and queue what it reaches: a
564
+ # pointer may lead into a bag the dialect does not walk (`$defs` under
565
+ # draft-07), and what a reference applies must be usable too.
566
+ # @return [Array<Array>] the positions the reference reaches
567
+ def self.check_ref(walk, schema, depth, dialect, keyword = '$ref')
568
+ root = walk.root
569
+ counter = walk.counter
570
+ problems = walk.problems
571
+ ref = schema[keyword]
572
+ unless ref.is_a?(String)
573
+ problems << "#{keyword} must be a string, got #{json_type(ref)}"
574
+ return []
575
+ end
576
+ if external_ref?(ref, root, counter[:dialect], counter, from: schema)
577
+ problems << "external #{keyword} #{clip(ref.inspect)} is not dereferenced (only references inside the " \
578
+ 'schema document are resolved; network $ref resolution is disabled)'
579
+ return []
580
+ end
581
+
582
+ positions = []
583
+ problem = ref_chain_problem(ref, root, counter[:dialect], counter, from: schema) do |target, hop, from|
584
+ position = referenced_target_position(walk, target, hop, from, depth, dialect)
585
+ positions << position if position
586
+ end
587
+ problems << problem.sub('$ref', keyword) if problem
588
+ positions
589
+ end
590
+
591
+ # The position a reference's target is walked at: under its own
592
+ # resource's dialect and at its own lexical depth. A boolean target
593
+ # needs no preflight and is charged once per distinct position (not per
594
+ # reference), and that position still obeys the depth bound.
595
+ # @return [Array, nil] the position to walk, or nil for a boolean target
596
+ def self.referenced_target_position(walk, target, hop, from, depth, dialect)
597
+ counter = walk.counter
598
+ position, opaque, visited = pointer_position(hop, walk.root, counter[:dialect], counter, from)
599
+ unless target.is_a?(Hash)
600
+ walk.problems << "schema nesting depth exceeds #{MAX_SCHEMA_DEPTH}" if position && position > MAX_SCHEMA_DEPTH
601
+ # A boolean in a position the walk visits was admitted there; one
602
+ # the walk never reaches (behind an opaque keyword, or beside a
603
+ # draft-07 $ref) is charged here, once per position.
604
+ charge_boolean_target(hop, walk.root, from, counter, walk.problems) unless visited
605
+ return nil
606
+ end
607
+
608
+ lexical = (counter[:depths] || {})[target]
609
+ target_depth = if opaque
610
+ [position, lexical].compact.max || (depth + 1)
611
+ else
612
+ lexical || position || (depth + 1)
613
+ end
614
+ [target, target_depth, indexed_dialect(target, counter) || dialect]
615
+ end
616
+
617
+ # Count a boolean a reference reaches toward the subschema bound, once
618
+ # per distinct position (resource and decoded pointer): a document may
619
+ # not hide thousands of applied schemas behind pointer-addressable
620
+ # booleans.
621
+ # @return [void]
622
+ def self.charge_boolean_target(hop, root, from, counter, problems)
623
+ index = (counter[:anchors] ||= anchor_index(root, counter[:dialect]))
624
+ resource, hop = pointer_origin(index, root, hop, from)
625
+ return unless resource
626
+
627
+ tokens = pointer_tokens(hop)
628
+ return unless tokens
629
+
630
+ key = [resource.object_id, tokens]
631
+ seen = (counter[:boolean_targets] ||= {})
632
+ return if seen.key?(key)
633
+
634
+ seen[key] = true
635
+ counter[:count] += 1
636
+ problems << "schema has more than #{MAX_SUBSCHEMAS} subschemas" if counter[:count] > MAX_SUBSCHEMAS
637
+ end
638
+
639
+ # The dynamic references of a schema object. One that names no dynamic
640
+ # anchor is the plain reference it resolves to and is checked (and what
641
+ # it reaches queued) exactly as a `$ref` is; a dynamic one is not
642
+ # evaluated, but one pointing outside the document would need a fetch,
643
+ # which never happens: the schema is unusable.
644
+ # @return [Array<Array>] the positions the plain ones reach
645
+ def self.check_dynamic_refs(walk, schema, depth, dialect)
646
+ DYNAMIC_REFERENCE_KEYWORDS.flat_map do |keyword|
647
+ next [] unless schema.key?(keyword) && keyword_known?(keyword, dialect)
648
+
649
+ ref = schema[keyword]
650
+ # A reference that is not a URI reference at all names nothing: it
651
+ # is not silently ignored (a malformed keyword is not an absent one).
652
+ unless ref.is_a?(String)
653
+ walk.problems << "#{keyword} must be a string, got #{json_type(ref)}"
654
+ next []
655
+ end
656
+ if external_ref?(ref, walk.root, walk.counter[:dialect], walk.counter, from: schema)
657
+ walk.problems << "external #{keyword} #{clip(ref.inspect)} is not dereferenced"
658
+ next []
659
+ end
660
+ check_ref(walk, schema, depth, dialect, keyword)
661
+ end
662
+ end
663
+
664
+ # Follow a local reference (and the references it leads to) at
665
+ # preflight: every hop must resolve to a schema, the chain must not
666
+ # cycle, and it must stay within MAX_REF_DEPTH. Once the whole chain
667
+ # checks out, every target it reached is yielded so the caller can
668
+ # preflight what the reference applies.
669
+ # @param resolver [Hash, Context] holder of the memoized anchor index
670
+ # @param from [Hash] the schema object holding the reference
671
+ # @return [String, nil] the problem, if any
672
+ def self.ref_chain_problem(ref, root, dialect, resolver, from:, &block)
673
+ targets = []
674
+ problem = follow_ref_chain(ref, root, dialect, resolver, from) { |*hop| targets << hop }
675
+ targets.each { |target, used, origin| block.call(target, used, origin) } if problem.nil? && block
676
+ problem
677
+ end
678
+
679
+ # @return [String, nil] the problem, if any; each resolved target is yielded
680
+ def self.follow_ref_chain(ref, root, dialect, resolver, from)
681
+ # A cycle is a chain returning to a schema it already reached: hops
682
+ # are told apart by where they land, not by their fragment text,
683
+ # since the same fragment means something else in another resource.
684
+ seen = {}.compare_by_identity
685
+ current = ref
686
+ loop do
687
+ return "$ref chain #{clip(ref.inspect)} exceeds #{MAX_REF_DEPTH} hops" if seen.size >= MAX_REF_DEPTH
688
+
689
+ target = resolve_reference(root, current, dialect, resolver, from: from)
690
+ return "unresolvable local $ref #{clip(current.inspect)}" if target.equal?(UNRESOLVED)
691
+ return "$ref #{clip(current.inspect)} does not point at a schema" unless schema_value?(target)
692
+ return "$ref chain #{clip(ref.inspect)} cycles" if target.is_a?(Hash) && seen.key?(target)
693
+
694
+ seen[target] = true if target.is_a?(Hash)
695
+ yield target, current, from
696
+ return nil unless target.is_a?(Hash) && target.key?('$ref')
697
+
698
+ from = target
699
+ current = target['$ref']
700
+ return "$ref must be a string, got #{json_type(current)}" unless current.is_a?(String)
701
+ if external_ref?(current, root, dialect, resolver, from: from)
702
+ return "external $ref #{clip(current.inspect)} is not dereferenced"
91
703
  end
92
704
  end
93
705
  end
94
706
 
95
- # Validate data against a JSON Schema subset.
707
+ # @param value [Object]
708
+ # @return [Boolean] whether the value is a schema (object or boolean)
709
+ def self.schema_value?(value)
710
+ value.is_a?(Hash) || value == true || value == false
711
+ end
712
+
713
+ # Marker for a pointer that does not resolve (nil is a valid schema
714
+ # value position, so it cannot serve as the marker).
715
+ UNRESOLVED = Object.new.freeze
716
+
717
+ # Validate data against a schema. An unusable schema (see
718
+ # {.check_schema}) is reported as validation errors, never as a pass,
719
+ # and so is a validation that hit a resource bound.
96
720
  # Schema and data hashes may use string or symbol keys.
97
721
  # @param data [Object] the value to validate
98
- # @param schema [Hash] the JSON schema
722
+ # @param schema [Hash, Boolean] the JSON schema
99
723
  # @param path [String] JSON-pointer-style location used in error messages
724
+ # @param deadline [Float, nil] monotonic deadline for the whole validation
100
725
  # @return [Array<String>] human-readable validation errors (empty if valid)
101
726
  def self.validate(data, schema, path: '#', deadline: nil)
102
- return [] unless schema.is_a?(Hash)
103
-
104
- # One deadline covers the entire (recursive) validation.
727
+ # One deadline covers the entire validation, the copy of the peer's
728
+ # document included.
105
729
  deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
730
+ root = normalize_schema(schema, deadline: deadline)
731
+ # `preflight` comes back holding the state the check read the schema
732
+ # under; the check runs under the validation's deadline.
733
+ problems = check_normalized(root, preflight = { deadline: deadline })
734
+ return problems.map { |problem| "#{path}: #{problem}" } unless problems.empty?
735
+
736
+ # The index the preflight built is the one this validation reads (the
737
+ # resources, dialects and anchors a schema was accepted under, and the
738
+ # subtrees its references adopted): a second one would make what a
739
+ # reference resolves to depend on the order this instance applies them.
740
+ ctx = Context.new(root: root, deadline: deadline, dialect: canonical_dialect(dialect(root)),
741
+ visits: 0, errors: 0, speculative: 0, anchors: preflight[:anchors], undecided: 0,
742
+ depth: 0, scope: [])
743
+ validate_node(data, root, path, ctx, 0)
744
+ rescue TooLarge => e
745
+ ["#{path}: #{e.message}"]
746
+ rescue Aborted => e
747
+ ["#{path}: validation aborted: #{e.message}"]
748
+ rescue SystemStackError
749
+ # A backstop, not a working bound: the `$ref` chain and the composition
750
+ # branches a schema spends on one value are applied iteratively, so
751
+ # what one instance level costs is fixed and MAX_NODE_DEPTH bounds the
752
+ # stack. Should some stack still be smaller than that — the walk holds
753
+ # no state outside `ctx` — the unwound stack leaves nothing behind, and
754
+ # the validation ends the way an exhausted budget does, as one error,
755
+ # never as a crash out of a tool call.
756
+ ["#{path}: validation aborted: schema too deeply recursive for this stack"]
757
+ end
106
758
 
107
- schema = schema.transform_keys(&:to_s)
108
- errors = []
109
- errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
110
- errors.concat(validate_enum(data, schema, path))
111
- case data
112
- when Hash then errors.concat(validate_object(data, schema, path, deadline))
113
- when Array then errors.concat(validate_array(data, schema, path, deadline))
114
- when String then errors.concat(validate_string(data, schema, path, deadline))
115
- when Numeric then errors.concat(validate_number(data, schema, path))
759
+ # Validate one value against one (sub)schema, and against every schema
760
+ # that one leads to for the same value: a `$ref` hop, an
761
+ # allOf/anyOf/oneOf/not/if branch, a then/else. Applying another schema
762
+ # to the same value does not nest the instance, so it is the `$ref` hop
763
+ # budget and the visit cap that bound it; {.validate_child} accounts for
764
+ # the steps that do nest.
765
+ #
766
+ # Those same-instance applications run on the `pending` list here rather
767
+ # than on the Ruby stack (see {Evaluation}): a schema composed through N
768
+ # `$defs` mixins applies N of them to every instance level, and spending
769
+ # a frame on each would make the stack grow with the product of the
770
+ # instance depth and the mixin count — so data a peer can legitimately
771
+ # send would overflow the small stack of a transport's reader thread. A
772
+ # frame is spent only on the step into a child value, which is what
773
+ # MAX_NODE_DEPTH bounds.
774
+ # @param data [Object] the value
775
+ # @param schema [Object] a subschema (Hash or boolean)
776
+ # @param path [String] location for error messages
777
+ # @param ctx [Context] the validation context
778
+ # @param ref_depth [Integer] $ref hops taken to reach this schema
779
+ # @return [Array<String>] validation errors
780
+ # @raise [Aborted] when a bound is hit
781
+ def self.validate_node(data, schema, path, ctx, ref_depth)
782
+ pending = []
783
+ # The dynamic scope grows and shrinks with these applications, exactly
784
+ # as it does with the Ruby frames a recursive validator would spend:
785
+ # entering a schema of another resource enters that resource, and the
786
+ # application that entered it is the one that leaves it.
787
+ entered = [entered_scope?(ctx, schema)]
788
+ step = start_node(data, schema, path, ctx, ref_depth)
789
+ loop do
790
+ if step[0] == :apply
791
+ pending << step
792
+ # A speculative application's errors are a verdict, not output, and
793
+ # do not count toward MAX_ERRORS while it runs.
794
+ ctx.speculative += 1 if step[3]
795
+ entered << entered_scope?(ctx, step[1])
796
+ step = start_node(data, step[1], path, ctx, step[2], collecting: step[5])
797
+ next
798
+ end
799
+
800
+ errors = step[1]
801
+ leave_scope(ctx, entered.pop)
802
+ return errors if pending.empty?
803
+
804
+ resumed = pending.pop
805
+ ctx.speculative -= 1 if resumed[3]
806
+ # The finished application hands back its verdict and what it
807
+ # evaluated of the value, for the applicator that applied it.
808
+ step = resumed[4].call(errors, step[2])
116
809
  end
810
+ ensure
811
+ entered&.each { |was_entered| leave_scope(ctx, was_entered) }
812
+ end
813
+
814
+ # Enter the schema resource a subschema belongs to, when applying it
815
+ # leaves the resource in force. A schema of the resource already
816
+ # innermost adds nothing a dynamic reference could read, so it is not
817
+ # pushed and its application has nothing to pop.
818
+ # @param ctx [Context] the validation context
819
+ # @param schema [Object] the subschema about to be applied
820
+ # @return [Boolean] whether the scope was pushed
821
+ def self.entered_scope?(ctx, schema)
822
+ return false unless schema.is_a?(Hash) && ctx.scope
823
+
824
+ ctx.anchors ||= anchor_index(ctx.root, ctx.dialect)
825
+ resource = ctx.anchors[:resources][schema] || ctx.root
826
+ return false if ctx.scope.last.equal?(resource)
827
+
828
+ ctx.scope.push(resource)
829
+ true
830
+ end
831
+
832
+ # @param ctx [Context] the validation context
833
+ # @param entered [Boolean] what {.entered_scope?} answered
834
+ # @return [void]
835
+ def self.leave_scope(ctx, entered)
836
+ ctx.scope.pop if entered && ctx.scope
837
+ end
838
+
839
+ # Validate a child of the value being validated — an array item or a
840
+ # property value — against the subschema for it. This is the one step
841
+ # that nests the instance, so it is where the walk's depth is counted and
842
+ # where the `$ref` hop budget starts over (see {.validate_object}).
843
+ # @param data [Object] the child value
844
+ # @param schema [Object] the subschema for it (Hash or boolean)
845
+ # @param path [String] location for error messages
846
+ # @param ctx [Context] the validation context
847
+ # @return [Array<String>] validation errors
848
+ # @raise [Aborted] when a bound is hit
849
+ def self.validate_child(data, schema, path, ctx)
850
+ ctx.depth += 1
851
+ raise Aborted, "instance nested deeper than #{MAX_NODE_DEPTH}" if ctx.depth > MAX_NODE_DEPTH
852
+
853
+ validate_node(data, schema, path, ctx, 0)
854
+ ensure
855
+ ctx.depth -= 1
856
+ end
857
+
858
+ # The dialect in force at a schema object during validation: the one
859
+ # recorded for its resource by the anchor index (built once per
860
+ # validation), else the root's.
861
+ # @return [String, nil]
862
+ def self.node_dialect(schema, ctx)
863
+ ctx.anchors ||= anchor_index(ctx.root, ctx.dialect)
864
+ indexed_dialect(schema, ctx) || ctx.dialect
865
+ end
866
+
867
+ # Account for one node visit (boolean schemas included, so a huge array
868
+ # under `items: true` still runs into the bounds).
869
+ # @raise [Aborted]
870
+ def self.count_visit(ctx)
871
+ ctx.visits += 1
872
+ raise Aborted, "more than #{MAX_NODE_VISITS} schema nodes visited" if ctx.visits > MAX_NODE_VISITS
873
+
874
+ check_deadline(ctx)
875
+ end
876
+
877
+ # Consult the validation-wide deadline without spending the node-visit
878
+ # allowance. A loop over the instance's own members (an object's
879
+ # properties, an array's items) is as long as the peer made it and may
880
+ # decide a member without visiting a node for it, so the loop itself has
881
+ # to reach a checkpoint: otherwise the budget is only consulted between
882
+ # the nodes such a sweep happens to visit, and a wide enough instance
883
+ # runs past it — reported, inside a speculative branch, as a pass.
884
+ # @raise [Aborted]
885
+ def self.check_deadline(ctx)
886
+ raise Aborted, 'validation time budget exhausted' if budget_exhausted?(ctx.deadline)
887
+ end
888
+
889
+ # Account for produced errors against MAX_ERRORS. Errors inside a
890
+ # speculative branch are a verdict, not output, and do not count.
891
+ # @return [Array<String>] the errors
892
+ # @raise [Aborted]
893
+ def self.count_errors(ctx, errors, already_counted: 0)
894
+ return errors if ctx.speculative.positive?
895
+
896
+ ctx.errors += errors.size - already_counted
897
+ raise Aborted, "more than #{MAX_ERRORS} validation errors (output truncated)" if ctx.errors > MAX_ERRORS
898
+
117
899
  errors
118
900
  end
119
901
 
902
+ # @return [Boolean] whether the validation-wide deadline has passed
903
+ def self.budget_exhausted?(deadline)
904
+ deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
905
+ end
906
+
120
907
  # Validate the JSON type of a value.
121
908
  # @param data [Object] the value
122
909
  # @param type [String, Symbol, Array<String, Symbol>] expected type(s)
@@ -126,7 +913,7 @@ module MCPClient
126
913
  types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
127
914
  return [] if types.any? { |t| type_match?(t, data) }
128
915
 
129
- ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
916
+ ["#{path}: expected type #{clip(types.join(' or '))}, got #{json_type(data)}"]
130
917
  end
131
918
 
132
919
  # Whether a value matches a JSON Schema type name.
@@ -174,7 +961,9 @@ module MCPClient
174
961
  end
175
962
  end
176
963
 
177
- # Validate enum/const membership.
964
+ # Validate enum/const membership. Symbol- and string-keyed objects are
965
+ # compared as given on both sides (the schema's data values are never
966
+ # re-keyed).
178
967
  # @param data [Object] the value
179
968
  # @param schema [Hash] string-keyed schema
180
969
  # @param path [String] location for error messages
@@ -182,148 +971,33 @@ module MCPClient
182
971
  def self.validate_enum(data, schema, path)
183
972
  errors = []
184
973
  if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
185
- errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}"
974
+ errors << "#{path}: value #{clip_value(data)} is not in enum #{clip_value(schema['enum'])}"
186
975
  end
187
976
  if schema.key?('const') && schema['const'] != data
188
- errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
189
- end
190
- errors
191
- end
192
-
193
- # Validate an object against required/properties.
194
- # @param data [Hash] the object
195
- # @param schema [Hash] string-keyed schema
196
- # @param path [String] location for error messages
197
- # @return [Array<String>] validation errors
198
- def self.validate_object(data, schema, path, deadline = nil)
199
- errors = []
200
- Array(schema['required']).each do |raw_name|
201
- name = raw_name.to_s
202
- errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
203
- end
204
- properties = schema['properties']
205
- return errors unless properties.is_a?(Hash)
206
-
207
- properties.each do |raw_name, prop_schema|
208
- next unless prop_schema.is_a?(Hash)
209
-
210
- name = raw_name.to_s
211
- key = if data.key?(name)
212
- name
213
- elsif data.key?(name.to_sym)
214
- name.to_sym
215
- end
216
- next if key.nil?
217
-
218
- errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline))
977
+ errors << "#{path}: value #{clip_value(data)} does not equal const #{clip_value(schema['const'])}"
219
978
  end
220
979
  errors
221
980
  end
222
981
 
223
- # Validate an array against items/minItems/maxItems.
224
- # @param data [Array] the array
225
- # @param schema [Hash] string-keyed schema
226
- # @param path [String] location for error messages
227
- # @return [Array<String>] validation errors
228
- def self.validate_array(data, schema, path, deadline = nil)
229
- errors = []
230
- min_items = schema['minItems']
231
- max_items = schema['maxItems']
232
- if min_items.is_a?(Numeric) && data.length < min_items
233
- errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
234
- end
235
- if max_items.is_a?(Numeric) && data.length > max_items
236
- errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
237
- end
238
- items = schema['items']
239
- if items.is_a?(Hash)
240
- data.each_with_index do |item, idx|
241
- errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline))
242
- end
243
- end
244
- errors
245
- end
246
-
247
- # Validate a string against minLength/maxLength/pattern.
248
- # @param data [String] the string
249
- # @param schema [Hash] string-keyed schema
250
- # @param path [String] location for error messages
251
- # @return [Array<String>] validation errors
252
- def self.validate_string(data, schema, path, deadline = nil)
253
- errors = []
254
- min_length = schema['minLength']
255
- max_length = schema['maxLength']
256
- if min_length.is_a?(Numeric) && data.length < min_length
257
- errors << "#{path}: string is shorter than minLength #{min_length}"
258
- end
259
- if max_length.is_a?(Numeric) && data.length > max_length
260
- errors << "#{path}: string is longer than maxLength #{max_length}"
261
- end
262
- errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
263
- errors
264
- end
265
-
266
- # Validate a string against a regular-expression pattern.
267
- # Invalid patterns are not enforced.
268
- #
269
- # The pattern comes from the tool's outputSchema, i.e. from the remote
270
- # server, so matching runs against the validation-wide deadline: neither a
271
- # single expensive expression nor many cheap-looking ones can pin the
272
- # calling thread. A match that exceeds the budget is reported as a
273
- # validation error rather than silently accepted — the value was never
274
- # shown to satisfy the schema.
275
- # @param data [String] the string
276
- # @param pattern [Object] the pattern keyword value
277
- # @param path [String] location for error messages
278
- # @param deadline [Float, nil] monotonic deadline for the whole validation
279
- # @return [Array<String>] validation errors
280
- def self.validate_pattern(data, pattern, path, deadline = nil)
281
- return [] unless pattern.is_a?(String)
282
-
283
- remaining = pattern_budget_remaining(deadline)
284
- return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero?
285
-
286
- return [] if data.match?(Regexp.new(pattern, timeout: remaining))
287
-
288
- ["#{path}: string does not match pattern #{pattern.inspect}"]
289
- rescue Regexp::TimeoutError
290
- ["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
291
- rescue RegexpError
292
- []
293
- end
294
-
295
- # Time left in the validation-wide pattern budget.
296
- # @param deadline [Float, nil] monotonic deadline, or nil for a lone match
297
- # @return [Float] seconds available for the next match; 0.0 when exhausted
298
- def self.pattern_budget_remaining(deadline)
299
- return PATTERN_MATCH_TIMEOUT unless deadline
300
-
301
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
302
- return 0.0 if remaining <= 0
303
-
304
- [remaining, MIN_PATTERN_MATCH_TIMEOUT].max
982
+ # Bound a piece of peer-derived text destined for a message.
983
+ # @param text [String]
984
+ # @return [String]
985
+ def self.clip(text)
986
+ text = text.to_s
987
+ text.length > MAX_VALUE_INSPECT ? "#{text[0, MAX_VALUE_INSPECT]}..." : text
305
988
  end
306
989
 
307
- # Validate a number against inclusive/exclusive bounds.
308
- # @param data [Numeric] the number
309
- # @param schema [Hash] string-keyed schema
310
- # @param path [String] location for error messages
311
- # @return [Array<String>] validation errors
312
- def self.validate_number(data, schema, path)
313
- errors = []
314
- minimum = schema['minimum']
315
- maximum = schema['maximum']
316
- exclusive_min = schema['exclusiveMinimum']
317
- exclusive_max = schema['exclusiveMaximum']
318
- errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
319
- errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
320
- if exclusive_min.is_a?(Numeric) && data <= exclusive_min
321
- errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
322
- end
323
- if exclusive_max.is_a?(Numeric) && data >= exclusive_max
324
- errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}"
990
+ # A short rendering of a value for a message that never inspects a
991
+ # large value whole.
992
+ # @param value [Object]
993
+ # @return [String]
994
+ def self.clip_value(value)
995
+ case value
996
+ when String then clip(value[0, MAX_VALUE_INSPECT].inspect)
997
+ when Array then "array(#{value.length} items)"
998
+ when Hash then "object(#{value.length} keys)"
999
+ else clip(value.inspect)
325
1000
  end
326
- errors
327
1001
  end
328
1002
  end
329
1003
  end