Module: MCPClient::SchemaValidator::Instances

Included in:
MCPClient::SchemaValidator
Defined in:
lib/mcp_client/schema_validator/instances.rb

Overview

The keywords that apply to one instance value's own type: the object and array vocabularies, and the bookkeeping they need. Extended into SchemaValidator, so the methods are its own; Evaluation calls them once per schema position and Composition reads what they left undecided.

Everything here is decided by the instance and this schema object alone, which is why it can be evaluated at all. What the keywords evaluate of the value is recorded on the application's Evaluated as they go, and unevaluatedItems / unevaluatedProperties read that record once every applicator has run (Evaluation#apply_unevaluated).

Instance Method Summary collapse

Instance Method Details

#comparable_value(value, depth, ctx) ⇒ Object

A value in the form JSON equality compares: numbers as exact rationals (so 1 and 1.0 agree), objects as their members sorted by name and with either Ruby key form read as the same name.

The form carries the JSON type, because JSON equality begins with it: an object is never equal to an array, however their members line up (JSON Schema 2020-12 Core Section 4.2.2). Encoding both as a bare Ruby Array made [{}, []] and [{"a": 1}, [["a", 1]]] read as duplicates, so :strict rejected a conforming result — and, through not, accepted one the schema rejects.

Canonicalizing a value walks all of it, and the value came from the peer, so every node is accounted for like any other the walk visits.

Parameters:

  • value (Object) —

    the instance value

  • depth (Integer) —

    how far into the value this is

  • ctx (Context) —

    the validation context

Returns:

  • (Object) —

    a value that hashes and compares as JSON equality does

Raises:

  • (Aborted) —

    when the value nests beyond the bound, or a budget is hit



357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/mcp_client/schema_validator/instances.rb', line 357

def comparable_value(value, depth, ctx)
  raise Aborted, "instance nested deeper than #{MAX_NODE_DEPTH}" if depth > MAX_NODE_DEPTH

  count_visit(ctx)
  case value
  when Hash then [:object, value.map { |k, v| [k.to_s, comparable_value(v, depth + 1, ctx)] }.sort_by(&:first)]
  when Array then [:array, value.map { |v| comparable_value(v, depth + 1, ctx) }]
  when Numeric then [:number, exact_number(value)]
  when String then [:string, value]
  else value
  end
end

#contains_annotates?(dialect) ⇒ Boolean

Whether the dialect gives contains the item annotation that unevaluatedItems consumes: 2020-12 does, 2019-09 does not, and draft-07 has neither keyword.

Parameters:

  • dialect (String, nil) —

    the dialect in force

Returns:

  • (Boolean)


422
423
424
# File 'lib/mcp_client/schema_validator/instances.rb', line 422

def contains_annotates?(dialect)
  dialect != DRAFT_2019_09 && dialect != DRAFT_07
end

#count_contains_matches(data, sub, path, ctx, evaluated = nil) ⇒ Array(Integer, Integer)

Match a contains schema against every item. The matches are a verdict, not output, so they are evaluated speculatively and what they could not evaluate is not left behind as this node's uncertainty.

Returns:

  • (Array(Integer, Integer)) —

    the items that matched, and those the validator could not decide



431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/mcp_client/schema_validator/instances.rb', line 431

def count_contains_matches(data, sub, path, ctx, evaluated = nil)
  hits = 0
  unsure = 0
  data.each_with_index do |item, idx|
    before = ctx.undecided
    errors = speculatively(ctx) { validate_child(item, sub, "#{path}/#{idx}", ctx) }
    if errors.empty? && ctx.undecided > before
      unsure += 1
    elsif errors.empty?
      hits += 1
      evaluated&.index!(idx)
    end
    ctx.undecided = before
  end
  [hits, unsure]
end

#exact_number(value) ⇒ Object

Returns the number as an exact rational, or as written when no rational describes it (an infinity a Ruby caller passed in).

Returns:

  • (Object) —

    the number as an exact rational, or as written when no rational describes it (an infinity a Ruby caller passed in)



372
373
374
375
376
# File 'lib/mcp_client/schema_validator/instances.rb', line 372

def exact_number(value)
  value.to_r
rescue RangeError, NoMethodError
  value
end

#note_evaluated_items(evaluated, positional, rest, data) ⇒ void

This method returns an undefined value.

The tuple evaluates the leading items it covers; a schema for the rest evaluates every item (their annotations).



308
309
310
311
312
313
# File 'lib/mcp_client/schema_validator/instances.rb', line 308

def note_evaluated_items(evaluated, positional, rest, data)
  return unless evaluated

  evaluated.prefix!([positional.length, data.length].min)
  evaluated.all! if schema_value?(rest)
end

#property_errors(name, value, path, ctx, evaluated, names:, patterns:, additional:, named:) ⇒ Array<String>

One member's errors under the property applicators. A member a pattern matched or additionalProperties applied to is evaluated by that keyword (its annotation); propertyNames evaluates the name, never the member.

Returns:

  • (Array<String>) —

    validation errors



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/mcp_client/schema_validator/instances.rb', line 167

def property_errors(name, value, path, ctx, evaluated, names:, patterns:, additional:, named:)
  errors = names.nil? ? [] : property_name_errors(name, names, path, ctx)
  matched = false
  patterns&.each do |pattern, sub|
    next unless schema_value?(sub) && pattern_matches?(pattern.to_s, name, ctx.deadline)

    matched = true
    evaluated&.name!(name)
    errors.concat(validate_child(value, sub, "#{path}/#{name}", ctx))
  end
  return errors if matched || named.include?(name) || additional.nil?

  # A rejected member costs an error rather than a descent, so it is
  # charged here: the count is what stops a peer-sized object.
  if additional == false
    count_visit(ctx)
    return errors.push("#{path}: property '#{clip(name)}' is not allowed (additionalProperties is false)")
  end

  evaluated&.name!(name)
  errors.concat(validate_child(value, additional, "#{path}/#{name}", ctx))
end

#property_name_errors(name, sub, path, ctx) ⇒ Array<String>

propertyNames applies its schema to each property name (a string), so what it rejects is reported as one error about the name rather than as a type error about a value the instance does not hold there.

Returns:

  • (Array<String>) —

    validation errors



194
195
196
197
198
199
# File 'lib/mcp_client/schema_validator/instances.rb', line 194

def property_name_errors(name, sub, path, ctx)
  errors = speculatively(ctx) { validate_child(name, sub, "#{path}/#{name}", ctx) }
  return [] if errors.empty?

  ["#{path}: property name '#{clip(name)}' does not satisfy propertyNames (#{clip(errors.first.to_s)})"]
end

#speculatively(ctx) ⇒ Object

Run a block whose errors are a verdict rather than output, so they are not charged to MAX_ERRORS (only what the caller reports is).

Returns:

  • (Object) —

    the block's value



204
205
206
207
208
209
# File 'lib/mcp_client/schema_validator/instances.rb', line 204

def speculatively(ctx)
  ctx.speculative += 1
  yield
ensure
  ctx.speculative -= 1
end

#unevaluated_errors(value, sub, path, ctx, refusal) ⇒ Array<String>

What one member or item left unevaluated costs: the keyword's schema applied to it, or — where that schema is false — one error.

Returns:

  • (Array<String>) —

    validation errors



64
65
66
67
68
69
70
71
# File 'lib/mcp_client/schema_validator/instances.rb', line 64

def unevaluated_errors(value, sub, path, ctx, refusal)
  if sub == false
    count_visit(ctx)
    return [refusal]
  end

  validate_child(value, sub, path, ctx)
end

#unevaluated_item_errors(app, sub) ⇒ Array<String>

unevaluatedItems (JSON Schema 2020-12 Core Section 11.2): the keyword's schema applies to every item nothing applied to the value evaluated — not the tuple keywords, not contains, not any applicator that passed.

Parameters:

Returns:

  • (Array<String>) —

    validation errors



244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/mcp_client/schema_validator/instances.rb', line 244

def unevaluated_item_errors(app, sub)
  data = app.data
  ctx = app.ctx
  path = app.path
  errors = []
  data.each_index do |idx|
    check_deadline(ctx)
    next if app.evaluated.item?(idx)

    errors.concat(unevaluated_errors(data[idx], sub, "#{path}/#{idx}", ctx,
                                     "#{path}: item #{idx} is not allowed (unevaluatedItems is false)"))
  end
  errors
end

#unevaluated_property_errors(app, sub) ⇒ Array<String>

unevaluatedProperties (JSON Schema 2020-12 Core Section 11.3): the keyword's schema applies to every member nothing applied to the value evaluated — not this node's own property keywords, not any applicator that passed. The members are as many as the peer sent, so the sweep consults the deadline as it goes.

Parameters:

Returns:

  • (Array<String>) —

    validation errors



47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/mcp_client/schema_validator/instances.rb', line 47

def unevaluated_property_errors(app, sub)
  data = app.data
  ctx = app.ctx
  path = app.path
  data.flat_map do |key, value|
    check_deadline(ctx)
    next [] if app.evaluated.property?(key)

    name = key.to_s
    unevaluated_errors(value, sub, "#{path}/#{name}", ctx,
                       "#{path}: property '#{clip(name)}' is not allowed (unevaluatedProperties is false)")
  end
end

#validate_array(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil) ⇒ Array<String>

Validate an array against items/prefixItems/minItems/maxItems. 2020-12 puts positional schemas in prefixItems and the rest under items; draft-07 and 2019-09 put positional schemas in an items array and send what follows the tuple to additionalItems. Both forms are honoured.

Parameters:

  • data (Array) —

    the array

  • schema (Hash) —

    string-keyed schema

  • path (String) —

    location for error messages

  • evaluated (Evaluated, nil) (defaults to: nil) —

    where the items the keywords evaluate are recorded

Returns:

  • (Array<String>) —

    validation errors



222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/mcp_client/schema_validator/instances.rb', line 222

def validate_array(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil)
  errors = []
  min_items = schema['minItems']
  max_items = schema['maxItems']
  if min_items.is_a?(Numeric) && data.length < min_items
    errors << "#{path}: expected at least #{min_items} items, got #{data.length}"
  end
  if max_items.is_a?(Numeric) && data.length > max_items
    errors << "#{path}: expected at most #{max_items} items, got #{data.length}"
  end
  errors.concat(validate_unique_items(data, schema, path, ctx))
  errors.concat(validate_items(data, schema, path, ctx, dialect, evaluated))
  errors.concat(validate_contains(data, schema, path, ctx, dialect, evaluated))
end

#validate_contains(data, schema, path, ctx, dialect, evaluated = nil) ⇒ Array<String>

contains (JSON Schema 2020-12 Validation Sections 6.4.4-6.4.5): the items are matched one by one and counted, and the count must lie between minContains (1 by default) and maxContains. An item the validator could only partly evaluate is neither a match nor a non-match: it widens the range the count may lie in, and where the bounds do not settle the keyword either way the node's verdict is partial, exactly as an unevaluated assertion makes it.

Parameters:

  • data (Array) —

    the instance

  • schema (Hash) —

    string-keyed schema

  • path (String) —

    location for error messages

  • dialect (String, nil) —

    the dialect in force

  • evaluated (Evaluated, nil) (defaults to: nil) —

    where the items contains matched are recorded (its annotation, 2020-12 Core Section 10.3.1.3)

Returns:

  • (Array<String>) —

    validation errors



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/mcp_client/schema_validator/instances.rb', line 392

def validate_contains(data, schema, path, ctx, dialect, evaluated = nil)
  return [] unless keyword_known?('contains', dialect) && schema_value?(schema['contains'])

  min = contains_min(schema, dialect)
  max = contains_max(schema, dialect)
  # Bounds that cannot overlap admit no count at all, so the keyword
  # fails before an item is ever matched against the schema.
  if max && min > max
    return ["#{path}: contains requires between #{min} and #{max} matching items, which no count satisfies"]
  end

  # 2020-12 Core Section 10.3.1.3 gives `contains` the item annotation
  # `unevaluatedItems` reads; 2019-09 does not (its Section 9.3.1.3
  # gives `unevaluatedItems` the annotations of `items` and
  # `additionalItems` alone), so there a matched item stays unevaluated
  # and the keyword still has to answer for it.
  annotating = evaluated if contains_annotates?(dialect)
  hits, unsure = count_contains_matches(data, schema['contains'], path, ctx, annotating)
  errors = []
  errors << "#{path}: expected at least #{min} items matching contains, got #{hits}" if hits + unsure < min
  errors << "#{path}: expected at most #{max} items matching contains, got #{hits}" if max && hits > max
  ctx.undecided += 1 if errors.empty? && unsure.positive? && (hits < min || (max && hits + unsure > max))
  errors
end

#validate_dependent_required(data, schema, path, dialect) ⇒ Array<String>

The required half of a dependency: dependentRequired in 2019-09 and 2020-12, and the property-name arrays of draft-07's dependencies (JSON Schema 2020-12 Validation Section 6.5.4, draft-07 Section 6.5.7).

Returns:

  • (Array<String>) —

    validation errors



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/mcp_client/schema_validator/instances.rb', line 93

def validate_dependent_required(data, schema, path, dialect)
  errors = []
  %w[dependentRequired dependencies].each do |keyword|
    map = schema[keyword] if keyword_known?(keyword, dialect)
    next unless map.is_a?(Hash)

    map.each do |trigger, dependents|
      next unless dependents.is_a?(Array) && property_present?(data, trigger)

      dependents.each do |name|
        next if property_present?(data, name)

        errors << "#{path}: property '#{clip(trigger.to_s)}' requires property '#{clip(name.to_s)}'"
      end
    end
  end
  errors
end

#validate_items(data, schema, path, ctx, dialect, evaluated = nil) ⇒ Array<String>

The item schemas: the positional ones first, then the schema that covers what follows them. The tuple evaluates the leading items it covers and a schema for the rest evaluates every item (their annotations).

Returns:

  • (Array<String>) —

    validation errors



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/mcp_client/schema_validator/instances.rb', line 264

def validate_items(data, schema, path, ctx, dialect, evaluated = nil)
  items = schema['items']
  # 2020-12 puts positional schemas in prefixItems (items must be a
  # schema); draft-07 and 2019-09 put them in an items array and know no
  # prefixItems.
  positional = if dialect == DEFAULT_DIALECT
                 schema['prefixItems'].is_a?(Array) ? schema['prefixItems'] : []
               else
                 items.is_a?(Array) ? items : []
               end
  # Past a draft-07 / 2019-09 tuple it is `additionalItems` that applies.
  rest = if items.is_a?(Array)
           schema['additionalItems'] if keyword_known?('additionalItems', dialect)
         else
           items
         end
  return [] if positional.empty? && !schema_value?(rest)

  note_evaluated_items(evaluated, positional, rest, data)
  errors = []
  data.each_with_index do |item, idx|
    # As in {.validate_other_properties}: the array's length is the
    # peer's, and an item the tuple tail rejects visits no node.
    check_deadline(ctx)
    item_schema = idx < positional.length ? positional[idx] : rest
    next unless schema_value?(item_schema)

    if item_schema == false && idx >= positional.length && items.is_a?(Array)
      count_visit(ctx)
      errors << "#{path}: item #{idx} is not allowed (additionalItems is false)"
      next
    end

    # An item is a smaller instance: the hop budget starts over and the
    # depth bound counts the step, so a recursive schema describes data
    # of any depth up to it (see {.validate_named_properties}).
    errors.concat(validate_child(item, item_schema, "#{path}/#{idx}", ctx))
  end
  errors
end

#validate_named_properties(data, schema, path, ctx, evaluated = nil) ⇒ Array<String>

The properties schemas, applied in the order the schema names them. Each member the keyword names is evaluated by it (its annotation).

Returns:

  • (Array<String>) —

    validation errors



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/mcp_client/schema_validator/instances.rb', line 115

def validate_named_properties(data, schema, path, ctx, evaluated = nil)
  properties = schema['properties']
  return [] unless properties.is_a?(Hash)

  errors = []
  properties.each do |raw_name, prop_schema|
    next unless schema_value?(prop_schema)

    name = raw_name.to_s
    key = data.key?(name) ? name : (name.to_sym if data.key?(name.to_sym))
    next if key.nil?

    evaluated&.name!(name)
    # A property is a smaller instance, so the hops taken to reach this
    # schema cannot repeat forever below it: the budget counts a chain
    # of references applied to one value, not how deep the data nests.
    # The step down is what the depth bound counts.
    errors.concat(validate_child(data[key], prop_schema, "#{path}/#{name}", ctx))
  end
  errors
end

#validate_object(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil) ⇒ Array<String>

Validate an object against the keywords that apply to it: required, the property-count bounds, the required half of a dependency, the properties schemas, and the schemas that decide the members those do not name (patternProperties, additionalProperties, propertyNames).

Parameters:

  • data (Hash) —

    the object

  • schema (Hash) —

    string-keyed schema

  • path (String) —

    location for error messages

  • evaluated (Evaluated, nil) (defaults to: nil) —

    where the members the keywords evaluate are recorded

Returns:

  • (Array<String>) —

    validation errors



27
28
29
30
31
32
33
34
35
36
37
# File 'lib/mcp_client/schema_validator/instances.rb', line 27

def validate_object(data, schema, path, ctx, dialect = ctx.dialect, evaluated = nil)
  errors = []
  Array(schema['required']).each do |raw_name|
    name = raw_name.to_s
    errors << "#{path}: missing required property '#{clip(name)}'" unless property_present?(data, name)
  end
  errors.concat(validate_property_counts(data, schema, path))
  errors.concat(validate_dependent_required(data, schema, path, dialect))
  errors.concat(validate_named_properties(data, schema, path, ctx, evaluated))
  errors.concat(validate_other_properties(data, schema, path, ctx, dialect, evaluated))
end

#validate_other_properties(data, schema, path, ctx, dialect, evaluated = nil) ⇒ Array<String>

The property applicators that decide members properties does not name: every name is checked against propertyNames, a member whose name matches a patternProperties pattern is validated against each matching schema, and one left over by both goes to additionalProperties (JSON Schema 2020-12 Core Sections 10.3.2.1-3).

Returns:

  • (Array<String>) —

    validation errors



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/mcp_client/schema_validator/instances.rb', line 143

def validate_other_properties(data, schema, path, ctx, dialect, evaluated = nil)
  patterns = schema['patternProperties'] if keyword_known?('patternProperties', dialect)
  patterns = nil unless patterns.is_a?(Hash)
  names = schema_value?(schema['propertyNames']) ? schema['propertyNames'] : nil
  additional = schema['additionalProperties'] if schema_value?(schema['additionalProperties'])
  return [] if patterns.nil? && names.nil? && additional.nil?

  named = schema['properties'].is_a?(Hash) ? schema['properties'].keys.map(&:to_s) : []
  data.flat_map do |key, value|
    # How wide this sweep is the peer's choice, not the schema's, and a
    # member the applicators decide without descending into it visits no
    # node of its own: the deadline is consulted here so a huge object
    # cannot run the walk past the budget between two nodes it visits.
    check_deadline(ctx)
    property_errors(key.to_s, value, path, ctx, evaluated,
                    names: names, patterns: patterns, additional: additional, named: named)
  end
end

#validate_property_counts(data, schema, path) ⇒ Array<String>

minProperties / maxProperties (JSON Schema 2020-12 Validation Sections 6.5.1-6.5.2).

Returns:

  • (Array<String>) —

    validation errors



76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/mcp_client/schema_validator/instances.rb', line 76

def validate_property_counts(data, schema, path)
  errors = []
  min = schema['minProperties']
  max = schema['maxProperties']
  if min.is_a?(Numeric) && data.size < min
    errors << "#{path}: object has #{data.size} properties, fewer than minProperties #{min}"
  end
  if max.is_a?(Numeric) && data.size > max
    errors << "#{path}: object has #{data.size} properties, more than maxProperties #{max}"
  end
  errors
end

#validate_unique_items(data, schema, path, ctx) ⇒ Array<String>

uniqueItems (JSON Schema 2020-12 Validation Section 6.4.3). Equality is JSON's, not Ruby's: 1 and 1.0 are the same number and two objects with the same members are equal whatever order they were written in, so the items are compared by a canonical form rather than by identity or by eql?.

Returns:

  • (Array<String>) —

    validation errors

Raises:

  • (Aborted) —

    when a bound is hit



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/mcp_client/schema_validator/instances.rb', line 322

def validate_unique_items(data, schema, path, ctx)
  return [] unless schema['uniqueItems'] == true

  seen = {}
  data.each_with_index do |item, idx|
    count_visit(ctx)
    key = comparable_value(item, 0, ctx)
    first = seen[key]
    unless first.nil?
      return ["#{path}: items #{first} and #{idx} are equal, but uniqueItems requires every item to differ"]
    end

    seen[key] = idx
  end
  []
end