ruby-mcp-client 2.0.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +914 -49
  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 +1367 -311
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -36
  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 +851 -478
  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/elicitation_validator.rb +56 -13
  35. data/lib/mcp_client/errors.rb +481 -6
  36. data/lib/mcp_client/header_params.rb +320 -0
  37. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  38. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  39. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  40. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  41. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  42. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  43. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  44. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  45. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  46. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  47. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  48. data/lib/mcp_client/http_transport_base.rb +669 -100
  49. data/lib/mcp_client/input_round_trips.rb +128 -0
  50. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  51. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  52. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  53. data/lib/mcp_client/json_rpc_common.rb +967 -11
  54. data/lib/mcp_client/oauth_client.rb +14 -5
  55. data/lib/mcp_client/prompt.rb +4 -0
  56. data/lib/mcp_client/request_authorization.rb +128 -0
  57. data/lib/mcp_client/request_meta_scope.rb +77 -0
  58. data/lib/mcp_client/request_metadata.rb +287 -0
  59. data/lib/mcp_client/resource.rb +4 -2
  60. data/lib/mcp_client/resource_content.rb +20 -0
  61. data/lib/mcp_client/resource_link.rb +0 -2
  62. data/lib/mcp_client/resource_template.rb +4 -2
  63. data/lib/mcp_client/result_caching.rb +999 -0
  64. data/lib/mcp_client/result_completeness.rb +34 -0
  65. data/lib/mcp_client/root.rb +6 -0
  66. data/lib/mcp_client/round_trip_marker.rb +28 -0
  67. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  68. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  69. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  70. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  71. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  72. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  73. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  74. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  75. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  76. data/lib/mcp_client/schema_validator/references.rb +610 -0
  77. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  78. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  79. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  80. data/lib/mcp_client/schema_validator.rb +893 -175
  81. data/lib/mcp_client/server_base.rb +233 -5
  82. data/lib/mcp_client/server_factory.rb +12 -3
  83. data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
  84. data/lib/mcp_client/server_http.rb +307 -90
  85. data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
  86. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  87. data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
  88. data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
  89. data/lib/mcp_client/server_sse.rb +310 -76
  90. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  91. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
  92. data/lib/mcp_client/server_stdio.rb +778 -186
  93. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
  94. data/lib/mcp_client/server_streamable_http.rb +547 -157
  95. data/lib/mcp_client/session_pin.rb +119 -0
  96. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  97. data/lib/mcp_client/subscription.rb +852 -0
  98. data/lib/mcp_client/subscription_support.rb +715 -0
  99. data/lib/mcp_client/task.rb +286 -14
  100. data/lib/mcp_client/tool.rb +31 -5
  101. data/lib/mcp_client/version.rb +21 -6
  102. data/lib/mcp_client.rb +115 -19
  103. metadata +71 -7
@@ -1,106 +1,909 @@
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)
64
+ #
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
15
69
  #
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.
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
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
37
182
 
38
- # Keywords whose value is a single subschema to walk.
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.
187
+ #
188
+ # The budget is for the whole operation, not per match: a per-match limit
189
+ # multiplies, since the server also controls how many strings it sends
190
+ # (N array items under one pathological items.pattern costs N x limit).
191
+ PATTERN_MATCH_TIMEOUT = 1.0
192
+
193
+ # Floor for an individual match's timeout, so a nearly-exhausted budget
194
+ # still makes progress rather than failing every remaining pattern.
195
+ MIN_PATTERN_MATCH_TIMEOUT = 0.01
196
+
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).
39
205
  SUBSCHEMA_KEYWORDS = %w[
40
- items contains additionalProperties propertyNames not if then else
41
- unevaluatedItems unevaluatedProperties
206
+ items contains additionalProperties additionalItems propertyNames not if then else
207
+ unevaluatedItems unevaluatedProperties contentSchema
42
208
  ].freeze
43
209
 
44
210
  # Keywords whose value is a map of name => subschema.
45
- SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
211
+ SUBSCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas dependencies].freeze
46
212
 
47
213
  # Keywords whose value is an array of subschemas.
48
214
  SUBSCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
49
215
 
50
- # List the unsupported JSON Schema keywords a schema uses (anywhere: at the
51
- # top level or nested in subschemas). Property names that merely look like
52
- # keywords (e.g. a property called 'not') are not reported, and
53
- # data-carrying keywords (enum/const/default/examples) are not scanned.
54
- # @param schema [Object] the JSON schema (string or symbol keys)
55
- # @return [Array<String>] unique unsupported keywords, in discovery order
56
- def self.unsupported_keywords(schema)
57
- found = []
58
- collect_unsupported_keywords(schema, found)
59
- found.uniq
60
- end
61
-
62
- # Recursively collect unsupported keywords from a schema.
63
- # @param schema [Object] a (sub)schema; non-Hash values are ignored
64
- # @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.
65
421
  # @return [void]
66
- def self.collect_unsupported_keywords(schema, found)
67
- return unless schema.is_a?(Hash)
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)
68
430
 
69
- schema = schema.transform_keys(&:to_s)
70
- found.concat(schema.keys & UNSUPPORTED_KEYWORDS)
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.
488
+ # @return [void]
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
521
+
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)
71
526
  schema.each do |keyword, value|
72
- if SUBSCHEMA_KEYWORDS.include?(keyword)
73
- collect_unsupported_keywords(value, found)
74
- elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash)
75
- value.each_value { |subschema| collect_unsupported_keywords(subschema, found) }
76
- elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array)
77
- 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 []
78
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)
79
661
  end
80
662
  end
81
663
 
82
- # Validate data against a JSON Schema subset.
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"
703
+ end
704
+ end
705
+ end
706
+
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.
83
720
  # Schema and data hashes may use string or symbol keys.
84
721
  # @param data [Object] the value to validate
85
- # @param schema [Hash] the JSON schema
722
+ # @param schema [Hash, Boolean] the JSON schema
86
723
  # @param path [String] JSON-pointer-style location used in error messages
724
+ # @param deadline [Float, nil] monotonic deadline for the whole validation
87
725
  # @return [Array<String>] human-readable validation errors (empty if valid)
88
- def self.validate(data, schema, path: '#')
89
- return [] unless schema.is_a?(Hash)
726
+ def self.validate(data, schema, path: '#', deadline: nil)
727
+ # One deadline covers the entire validation, the copy of the peer's
728
+ # document included.
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?
90
735
 
91
- schema = schema.transform_keys(&:to_s)
92
- errors = []
93
- errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
94
- errors.concat(validate_enum(data, schema, path))
95
- case data
96
- when Hash then errors.concat(validate_object(data, schema, path))
97
- when Array then errors.concat(validate_array(data, schema, path))
98
- when String then errors.concat(validate_string(data, schema, path))
99
- when Numeric then errors.concat(validate_number(data, schema, path))
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
758
+
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])
100
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
+
101
899
  errors
102
900
  end
103
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
+
104
907
  # Validate the JSON type of a value.
105
908
  # @param data [Object] the value
106
909
  # @param type [String, Symbol, Array<String, Symbol>] expected type(s)
@@ -110,7 +913,7 @@ module MCPClient
110
913
  types = (type.is_a?(Array) ? type : [type]).map(&:to_s)
111
914
  return [] if types.any? { |t| type_match?(t, data) }
112
915
 
113
- ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"]
916
+ ["#{path}: expected type #{clip(types.join(' or '))}, got #{json_type(data)}"]
114
917
  end
115
918
 
116
919
  # Whether a value matches a JSON Schema type name.
@@ -158,7 +961,9 @@ module MCPClient
158
961
  end
159
962
  end
160
963
 
161
- # 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).
162
967
  # @param data [Object] the value
163
968
  # @param schema [Hash] string-keyed schema
164
969
  # @param path [String] location for error messages
@@ -166,120 +971,33 @@ module MCPClient
166
971
  def self.validate_enum(data, schema, path)
167
972
  errors = []
168
973
  if schema['enum'].is_a?(Array) && !schema['enum'].include?(data)
169
- 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'])}"
170
975
  end
171
976
  if schema.key?('const') && schema['const'] != data
172
- errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}"
173
- end
174
- errors
175
- end
176
-
177
- # Validate an object against required/properties.
178
- # @param data [Hash] the object
179
- # @param schema [Hash] string-keyed schema
180
- # @param path [String] location for error messages
181
- # @return [Array<String>] validation errors
182
- def self.validate_object(data, schema, path)
183
- errors = []
184
- Array(schema['required']).each do |raw_name|
185
- name = raw_name.to_s
186
- errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym)
187
- end
188
- properties = schema['properties']
189
- return errors unless properties.is_a?(Hash)
190
-
191
- properties.each do |raw_name, prop_schema|
192
- next unless prop_schema.is_a?(Hash)
193
-
194
- name = raw_name.to_s
195
- key = if data.key?(name)
196
- name
197
- elsif data.key?(name.to_sym)
198
- name.to_sym
199
- end
200
- next if key.nil?
201
-
202
- errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}"))
203
- end
204
- errors
205
- end
206
-
207
- # Validate an array against items/minItems/maxItems.
208
- # @param data [Array] the array
209
- # @param schema [Hash] string-keyed schema
210
- # @param path [String] location for error messages
211
- # @return [Array<String>] validation errors
212
- def self.validate_array(data, schema, path)
213
- errors = []
214
- min_items = schema['minItems']
215
- max_items = schema['maxItems']
216
- if min_items.is_a?(Numeric) && data.length < min_items
217
- errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
218
- end
219
- if max_items.is_a?(Numeric) && data.length > max_items
220
- errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
221
- end
222
- items = schema['items']
223
- if items.is_a?(Hash)
224
- data.each_with_index { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) }
225
- end
226
- errors
227
- end
228
-
229
- # Validate a string against minLength/maxLength/pattern.
230
- # @param data [String] the string
231
- # @param schema [Hash] string-keyed schema
232
- # @param path [String] location for error messages
233
- # @return [Array<String>] validation errors
234
- def self.validate_string(data, schema, path)
235
- errors = []
236
- min_length = schema['minLength']
237
- max_length = schema['maxLength']
238
- if min_length.is_a?(Numeric) && data.length < min_length
239
- errors << "#{path}: string is shorter than minLength #{min_length}"
977
+ errors << "#{path}: value #{clip_value(data)} does not equal const #{clip_value(schema['const'])}"
240
978
  end
241
- if max_length.is_a?(Numeric) && data.length > max_length
242
- errors << "#{path}: string is longer than maxLength #{max_length}"
243
- end
244
- errors.concat(validate_pattern(data, schema['pattern'], path))
245
979
  errors
246
980
  end
247
981
 
248
- # Validate a string against a regular-expression pattern.
249
- # Invalid patterns are not enforced.
250
- # @param data [String] the string
251
- # @param pattern [Object] the pattern keyword value
252
- # @param path [String] location for error messages
253
- # @return [Array<String>] validation errors
254
- def self.validate_pattern(data, pattern, path)
255
- return [] unless pattern.is_a?(String)
256
- return [] if data.match?(Regexp.new(pattern))
257
-
258
- ["#{path}: string does not match pattern #{pattern.inspect}"]
259
- rescue RegexpError
260
- []
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
261
988
  end
262
989
 
263
- # Validate a number against inclusive/exclusive bounds.
264
- # @param data [Numeric] the number
265
- # @param schema [Hash] string-keyed schema
266
- # @param path [String] location for error messages
267
- # @return [Array<String>] validation errors
268
- def self.validate_number(data, schema, path)
269
- errors = []
270
- minimum = schema['minimum']
271
- maximum = schema['maximum']
272
- exclusive_min = schema['exclusiveMinimum']
273
- exclusive_max = schema['exclusiveMaximum']
274
- errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum
275
- errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum
276
- if exclusive_min.is_a?(Numeric) && data <= exclusive_min
277
- errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}"
278
- end
279
- if exclusive_max.is_a?(Numeric) && data >= exclusive_max
280
- 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)
281
1000
  end
282
- errors
283
1001
  end
284
1002
  end
285
1003
  end