Module: MCPClient::SchemaValidator::References

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

Overview

Resolution of local $ref values: JSON pointer fragments (RFC 6901) and plain-name fragments naming an anchor. Nothing here ever fetches: a reference outside the document is reported as external. Extended into SchemaValidator, so the methods are its own.

Plain names are scoped to their schema resource (JSON Schema 2020-12 Core Sections 8.2.1 and 8.2.2): a subschema whose $id is a URI starts a new resource, and #name names an anchor of the resource the referencing schema belongs to — never one of an embedded resource, and never one of the enclosing document from inside an embedded resource.

Instance Method Summary collapse

Instance Method Details

#adopt_reached_target(target, resource, index, raw: false) ⇒ Object

A schema a pointer reaches outside every indexed position (inside default, another data keyword or a vendor member) is still part of the resource it was reached from: it (and what it holds) is indexed on arrival, so a $ref written anywhere in it resolves within that resource and its nested schemas follow the dialect it adopts, and what the document holds as data is normalized like everywhere else (values are kept as given for equality; a subtree resolved as a schema gets one string-keyed copy, memoized by identity).

Parameters:

  • raw (Boolean) (defaults to: false) —

    whether the document holds the target as data, so it still needs its string-keyed copy

Returns:

  • (Object) —

    the target (or its normalized copy)



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/mcp_client/schema_validator/references.rb', line 180

def adopt_reached_target(target, resource, index, raw: false)
  return target unless target.is_a?(Hash)

  # Copied under the same structural budget as the root document,
  # whatever key form it arrived in (over the wire every key is a
  # string): a data keyword may not hide an unbounded map behind a
  # pointer.
  target = normalized_copy(target, index) if raw && !index[:resources].key?(target)
  # An indexed position was already walked, charged and attributed.
  return target if index[:resources].key?(target)

  # The adopted target is indexed like any schema position, so its own
  # `$id` / `$schema` and the positions below it are seen: within the
  # bounds the index already runs under. It declares no names of its
  # own — `resource_root?` turns naming on where an `$id` really starts
  # a resource — since a data keyword is not a schema position and an
  # `$anchor` written inside one names nothing in the document (Core
  # Sections 8.2.2 and 4.3.1).
  index_positions(index, [[target, resource, 0, index[:dialects][resource], false]])
  target
end

#adopt_step(node, resource, index, mode) ⇒ Array(Object, Hash, Symbol)

Adopt the object a pointer is about to step through when the document holds it as data, and follow the resource the index attributes it to.

Returns:

  • (Array(Object, Hash, Symbol)) —

    the node to step through, the resource it belongs to, and the mode it is read in



159
160
161
162
163
164
165
166
167
# File 'lib/mcp_client/schema_validator/references.rb', line 159

def adopt_step(node, resource, index, mode)
  return [node, resource, mode] unless node.is_a?(Hash)

  if mode == :data
    node = adopt_reached_target(node, resource, index, raw: true)
    mode = :schema
  end
  [node, index[:resources][node] || resource, mode]
end

#anchor_index(root, dialect) ⇒ Hash

Every plain-name anchor in the document, per schema resource: $anchor (and $dynamicAnchor) in 2019-09 / 2020-12, $id: "#name" in draft-07. The walk is bounded like the preflight walk and follows the dialect of each resource (an embedded resource may declare its own $schema). Identifiers are taken only from schema positions the dialect walks (JSON Schema 2020-12 Core Sections 8.2.2 and 4.3.1: an identifier belongs to a schema object, and the value of a keyword the dialect does not define is not a schema): a definition bag the dialect does not define stays reachable through JSON pointers, and its objects are attributed to their lexical resource, but it is never a source of names — nor is anything beside a draft-07 $ref apart from its definitions.

Returns:

  • (Hash) —

    :resources (schema object => its resource root, by identity), :anchors (resource root => name => subschema, first occurrence, by identity), :dialects (resource root => dialect) and :duplicates (names declared more than once within one resource)



227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/mcp_client/schema_validator/references.rb', line 227

def anchor_index(root, dialect)
  index = { resources: {}.compare_by_identity, anchors: {}.compare_by_identity,
            dialects: {}.compare_by_identity, bases: {}.compare_by_identity, by_base: {},
            duplicates: [], duplicate_ids: [], visited: 0, truncated: false }
  index[:dialects][root] = dialect
  # The document's own base: the root's `$id` where it declares one,
  # else the empty reference. Relative references resolve against each
  # other either way, which is what a bundled document needs.
  register_resource_base(index, root, '', declared: root.is_a?(Hash) && resource_root?(root, dialect))
  index_positions(index, [[root, root, 0, dialect, true]])
  index
end

#anchor_names(schema, dialect) ⇒ Array<String>

Returns the plain names a schema object declares.

Returns:

  • (Array<String>) —

    the plain names a schema object declares



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
# File 'lib/mcp_client/schema_validator/references.rb', line 445

def anchor_names(schema, dialect)
  names = if dialect == DRAFT_07
            # draft-07 Core Section 8.2.3: only an $id that is exactly a
            # fragment is a plain-name identifier. An $id is a URI
            # reference, so its fragment is percent-decoded (RFC 3986
            # Section 2.1) exactly as a $ref's is: `$id: "#foo%2Dbar"`
            # declares the name "foo-bar", which is what
            # `$ref: "#foo%2Dbar"` — decoded the same way — looks for.
            id = schema['$id']
            [id.is_a?(String) && id.start_with?('#') ? decoded_fragment(id) : nil]
          else
            %w[$anchor $dynamicAnchor].map { |k| schema[k] if keyword_known?(k, dialect) }
          end
  names.select { |name| anchor_name?(name, dialect) }
end

#decode_component(component) ⇒ String?

Percent-decode one component.

Parameters:

  • component (String)

Returns:

  • (String, nil) —

    nil when an escape is malformed ("a%ZZ", "a%")



109
110
111
112
113
# File 'lib/mcp_client/schema_validator/references.rb', line 109

def decode_component(component)
  URI.decode_uri_component(component)
rescue ArgumentError
  nil
end

#decoded_fragment(ref) ⇒ String?

The decoded fragment of a reference (RFC 3986 Section 2.1), or nil when what the peer wrote does not decode to readable text: a malformed escape ("a%ZZ") and escapes that are not valid UTF-8 name nothing in this document, and reading them must never raise out of the validation. The undecoded text is never substituted -- it would make "#/$defs/a%ZZ" resolve onto a literal "a%ZZ" member and pass a reference the peer never wrote off as valid.

Parameters:

  • ref (String) —

    the $ref value

Returns:

  • (String, nil)


101
102
103
104
# File 'lib/mcp_client/schema_validator/references.rb', line 101

def decoded_fragment(ref)
  decoded = decode_component(ref.delete_prefix('#'))
  decoded if decoded&.valid_encoding?
end

#dynamic_binding(schema, keyword, root, dialect, resolver) ⇒ Array

How a dynamic reference binds (JSON Schema 2020-12 Core Section 8.2.3.2; 2019-09 Section 8.2.4.2.2). A reference whose initial target declares no matching dynamic anchor is the plain reference it resolves to (:plain). One that does re-binds to the declaration in the OUTERMOST resource of the dynamic scope — the resources the evaluation entered on its way here, which MCPClient::SchemaValidator.entered_scope? records as they are entered (:bound, with the target). What the document holds elsewhere decides nothing: a resource the instance never entered is not in the scope, so a duplicate anchor there is neither ambiguity nor a reason to leave the reference unevaluated. Outside a validation there is no scope to read, and every reference is the plain one it resolves to — which is what the preflight, whose job is that the reference resolves at all, needs.

Parameters:

  • schema (Hash) —

    the schema object holding the reference

  • keyword (String) —

    "$dynamicRef" or "$recursiveRef"

  • root (Hash) —

    the root schema

  • dialect (String, nil) —

    the canonical root dialect

  • resolver (Hash, Context) —

    holder of the memoized anchor index, and — during a validation — of the dynamic scope

Returns:

  • (Array) —

    [:plain] or [:bound, target]



389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/mcp_client/schema_validator/references.rb', line 389

def dynamic_binding(schema, keyword, root, dialect, resolver)
  ref = schema[keyword]
  return [:plain] unless ref.is_a?(String) && !external_ref?(ref, root, dialect, resolver, from: schema)

  target = resolve_reference(root, ref, dialect, resolver, from: schema)
  return [:plain] if target.equal?(UNRESOLVED)
  return [:plain] unless target.is_a?(Hash)

  index = resolver[:anchors]
  scope = resolver[:scope]
  bound = if keyword == '$recursiveRef'
            return [:plain] unless target['$recursiveAnchor'] == true

            outermost_recursive_anchor(index, scope, dialect)
          else
            fragment = ref.include?('#') ? decoded_fragment(ref[ref.index('#')..]) : nil
            return [:plain] if fragment.nil? || fragment.empty? || fragment.start_with?('/')
            return [:plain] unless target['$dynamicAnchor'] == fragment

            outermost_dynamic_anchor(index, scope, fragment)
          end
  bound ? [:bound, bound] : [:plain]
end

#each_foreign_definition(schema, dialect, &block) ⇒ void

This method returns an undefined value.

Yield the definitions held in the bag the dialect does not define ($defs under draft-07, which predates it): unknown to the dialect, but pointer-addressable all the same.



601
602
603
604
605
606
607
# File 'lib/mcp_client/schema_validator/references.rb', line 601

def each_foreign_definition(schema, dialect, &block)
  %w[$defs definitions].each do |keyword|
    next if keyword_known?(keyword, dialect)

    schema[keyword].each_value(&block) if schema[keyword].is_a?(Hash)
  end
end

#each_walked_position(schema, dialect) ⇒ void

This method returns an undefined value.

Yield the schema positions the dialect walks under a schema object: under a draft-07 $ref only the definitions bag, else every subschema (the dialect's definition bag included).



326
327
328
329
330
331
332
# File 'lib/mcp_client/schema_validator/references.rb', line 326

def each_walked_position(schema, dialect, &)
  if dialect == DRAFT_07 && schema.key?('$ref')
    each_definition(schema, dialect, &)
  else
    each_subschema(schema, dialect, &)
  end
end

#enter_resource(index, schema, resource, dialect, named) ⇒ Array(Hash, String, Boolean)

Enter the schema resource a position starts, if it starts one: a resource is a schema wherever it sits (one reached through a bag the dialect does not walk names its own anchors, though nothing outside it can see them), it may declare its own dialect, and its $id establishes the base its references resolve against.

Returns:

  • (Array(Hash, String, Boolean)) —

    the resource in force, its dialect, and whether the position may declare names



300
301
302
303
304
305
306
307
308
# File 'lib/mcp_client/schema_validator/references.rb', line 300

def enter_resource(index, schema, resource, dialect, named)
  return [resource, dialect, named] unless resource_root?(schema, dialect)

  parent_base = index[:bases][resource] || ''
  dialect = embedded_dialect(schema, dialect) || dialect
  index[:dialects][schema] = dialect
  register_resource_base(index, schema, parent_base)
  [schema, dialect, true]
end

#external_ref?(ref, root = nil, dialect = nil, resolver = nil, from: nil) ⇒ Boolean

A reference that does not point inside this document, so using it would need a retrieval that never happens. A bare fragment is always local; anything else is resolved against the base URI of the resource holding it (RFC 3986 Section 5.2) and is local when the document bundles a resource whose $id is that URI (JSON Schema 2020-12 Core Section 9.3.1) -- the empty reference, which names the base itself, included. Without the document and its index only the syntactic answer is available, and a reference outside the fragment space is external.

Parameters:

  • ref (String)
  • root (Hash, nil) (defaults to: nil) —

    the normalized root schema

  • dialect (String, nil) (defaults to: nil) —

    the canonical dialect

  • resolver (Hash, Context, nil) (defaults to: nil) —

    holder of the memoized index

  • from (Hash, nil) (defaults to: nil) —

    the schema object holding the reference

Returns:

  • (Boolean)


33
34
35
36
37
38
39
40
41
# File 'lib/mcp_client/schema_validator/references.rb', line 33

def external_ref?(ref, root = nil, dialect = nil, resolver = nil, from: nil)
  return false if ref.start_with?('#')
  return true unless root.is_a?(Hash) && resolver

  index = (resolver[:anchors] ||= anchor_index(root, dialect))
  return true if from.is_a?(Hash) && !index[:resources].key?(from)

  retarget_reference(index, (from && index[:resources][from]) || root, ref).first.nil?
end

#index_positions(index, pending) ⇒ void

This method returns an undefined value.

Index every schema position reachable from the pending seeds, within the visit and depth bounds the whole index runs under (an adopted pointer target is seeded here too, so it shares them).

Parameters:

  • index (Hash) —

    the index being built

  • pending (Array<Array>) —

    seeds: schema, resource, depth, dialect, whether the position may declare names



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/mcp_client/schema_validator/references.rb', line 268

def index_positions(index, pending)
  until pending.empty? || index[:visited] >= MAX_SUBSCHEMAS
    schema, resource, depth, dialect, named = pending.shift
    next unless schema.is_a?(Hash)
    next if index[:resources].key?(schema)
    # A schema below the depth bound is left unindexed just like one
    # beyond the visit bound: a reference from it (or an `$id` there)
    # would otherwise resolve under guesses.
    (index[:truncated] = true) && next if depth > MAX_SCHEMA_DEPTH

    index[:visited] += 1
    resource, dialect, named = enter_resource(index, schema, resource, dialect, named)
    index[:resources][schema] = resource
    if named && !(dialect == DRAFT_07 && schema.key?('$ref'))
      record_anchor_names(index, resource, schema,
                          dialect)
    end
    each_walked_position(schema, dialect) { |sub| pending << [sub, resource, depth + 1, dialect, named] }
    each_foreign_definition(schema, dialect) { |sub| pending << [sub, resource, depth + 1, dialect, false] }
  end
  # Objects left unindexed at the bound would resolve and validate
  # under guesses; the index says so and the schema is unusable.
  index[:truncated] ||= pending.any? { |schema, *| schema.is_a?(Hash) && !index[:resources].key?(schema) }
end

#indexed_dialect(schema, resolver) ⇒ String?

The dialect the memoized anchor index recorded for a schema object's resource, or nil when the object was not indexed.

Parameters:

  • schema (Hash)
  • resolver (Hash, Context) —

    holder of the memoized anchor index

Returns:

  • (String, nil)


339
340
341
342
343
344
345
# File 'lib/mcp_client/schema_validator/references.rb', line 339

def indexed_dialect(schema, resolver)
  index = resolver[:anchors]
  return nil unless index

  resource = index[:resources][schema]
  resource && index[:dialects][resource]
end

#lexical_depths(root, dialect) ⇒ Hash{Hash => Integer}

The lexical nesting depth of every schema object reachable from the root (subschema positions, definition bags of any dialect), so a referenced target is bounded by where it is written, not by where it is referenced from: neither member order nor reference fan-out can change the verdict.

Returns:

  • (Hash{Hash => Integer}) —

    identity-keyed



467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/mcp_client/schema_validator/references.rb', line 467

def lexical_depths(root, dialect)
  depths = {}.compare_by_identity
  pending = [[root, 0, dialect]]
  while (schema, depth, current = pending.shift)
    next unless schema.is_a?(Hash) && !depths.key?(schema)

    depths[schema] = depth
    break if depths.size > MAX_SUBSCHEMAS * 2

    # An embedded resource's positions follow its own dialect.
    current = embedded_dialect(schema, current) || current
    each_subschema(schema, current) { |sub| pending << [sub, depth + 1, current] }
    each_foreign_definition(schema, current) { |sub| pending << [sub, depth + 1, current] }
  end
  depths
end

#normalized_copy(target, index) ⇒ Hash

The one string-keyed copy of a subtree the document holds as data, memoized by the identity of the object it holds.

Returns:

  • (Hash)


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

def normalized_copy(target, index)
  copies = (index[:normalized] ||= {}.compare_by_identity)
  index[:budget] ||= { objects: 0, deadline: nil }
  copies[target] ||= deep_stringify(target, 0, index[:budget])
end

#outermost_dynamic_anchor(index, scope, name) ⇒ Hash?

The schema declaring $dynamicAnchor: name in the outermost resource of the dynamic scope that declares it — the scope being the resources the evaluation actually entered, outermost first. A resource the instance never entered declares nothing for this reference, however many of them the document holds; and where no entered resource declares the name, the caller keeps the target the reference resolved to on its own, which is what the specification's "otherwise behave as $ref" says.

Parameters:

  • index (Hash) —

    the anchor index

  • scope (Array<Hash>, nil) —

    the dynamic scope, outermost first

  • name (String) —

    the anchor name

Returns:

  • (Hash, nil)


425
426
427
428
429
430
431
# File 'lib/mcp_client/schema_validator/references.rb', line 425

def outermost_dynamic_anchor(index, scope, name)
  Array(scope).each do |resource|
    declaring = index[:anchors][resource]&.[](name)
    return declaring if declaring.is_a?(Hash) && declaring['$dynamicAnchor'] == name
  end
  nil
end

#outermost_recursive_anchor(index, scope, dialect) ⇒ Hash?

The outermost resource of the dynamic scope whose $recursiveAnchor is true (2019-09 Core Section 8.2.4.2.2); the target of a $recursiveRef is the resource root itself.

Returns:

  • (Hash, nil)


437
438
439
440
441
442
# File 'lib/mcp_client/schema_validator/references.rb', line 437

def outermost_recursive_anchor(index, scope, dialect)
  Array(scope).find do |resource|
    resource.is_a?(Hash) && resource['$recursiveAnchor'] == true &&
      (index[:dialects][resource] || dialect) != DRAFT_07
  end
end

#pointer_child(node, token) ⇒ Object

Returns the member a pointer token selects, or UNRESOLVED.

Returns:

  • (Object) —

    the member a pointer token selects, or UNRESOLVED



575
576
577
578
579
580
581
# File 'lib/mcp_client/schema_validator/references.rb', line 575

def pointer_child(node, token)
  case node
  when Hash then node.key?(token) ? node[token] : UNRESOLVED
  when Array then token.match?(/\A(0|[1-9]\d*)\z/) && token.to_i < node.length ? node[token.to_i] : UNRESOLVED
  else UNRESOLVED
  end
end

#pointer_origin(index, root, ref, from) ⇒ Array(Hash, String), Array(nil, nil)

The resource a reference's pointer is read inside, and the bare fragment that applies there. A reference written as an absolute URI into the bundled document (urn:root#/x/y) addresses the very position its bare spelling (#/x/y) does, so the pointer is followed — and the target accounted for — the same way whichever the peer wrote (JSON Schema 2020-12 Core Section 9.3.1).

Returns:

  • (Array(Hash, String), Array(nil, nil))


558
559
560
561
562
563
# File 'lib/mcp_client/schema_validator/references.rb', line 558

def pointer_origin(index, root, ref, from)
  resource = (from && index[:resources][from]) || root
  return [resource, ref] if ref.start_with?('#')

  retarget_reference(index, resource, ref)
end

#pointer_position(ref, root, dialect, counter, from) ⇒ Array(Integer, Boolean)?

Returns the depth and whether the pointer crossed an opaque keyword; nil when it cannot be followed.

Returns:

  • (Array(Integer, Boolean), nil) —

    the depth and whether the pointer crossed an opaque keyword; nil when it cannot be followed



505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
# File 'lib/mcp_client/schema_validator/references.rb', line 505

def pointer_position(ref, root, dialect, counter, from)
  index = (counter[:anchors] ||= anchor_index(root, dialect))
  node, ref = pointer_origin(index, root, ref, from)
  return nil unless node

  tokens = pointer_tokens(ref)
  return nil unless tokens

  depths = counter[:depths] || {}
  walk = { index: index, depths: depths, dialect: index[:dialects][node] || dialect,
           depth: depths[node] || 0, mode: :schema, opaque: false, visited: true }
  tokens.each do |token|
    child = pointer_child(node, token)
    return nil if child.equal?(UNRESOLVED)

    pointer_step(walk, node, token)
    node = child
  end
  [walk[:depth], walk[:opaque], walk[:visited]]
end

#pointer_step(walk, node, token) ⇒ void

This method returns an undefined value.

Advance one pointer token: in schema mode the node's own dialect and known depth apply and the keyword decides how the next token counts; inside a map or array keyword the member is the step; under an opaque keyword every token is a step.



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
# File 'lib/mcp_client/schema_validator/references.rb', line 531

def pointer_step(walk, node, token)
  unless walk[:mode] == :schema
    walk[:depth] += 1
    walk[:mode] = :schema if %i[map array].include?(walk[:mode])
    return
  end
  if node.is_a?(Hash)
    walk[:depth] = walk[:depths][node] if !walk[:opaque] && walk[:depths].key?(node)
    walk[:dialect] = walk[:index][:dialects][node] || embedded_dialect(node, walk[:dialect]) || walk[:dialect]
  end
  walk[:mode] = pointer_step_mode(node, token, walk[:dialect])
  walk[:opaque] ||= walk[:mode] == :opaque
  # The preflight walk does not descend into an opaque keyword, nor
  # into anything beside a draft-07 $ref but its definitions.
  walk[:visited] &&= walk[:mode] != :opaque &&
                     !(walk[:dialect] == DRAFT_07 && node.is_a?(Hash) && node.key?('$ref') &&
                       token != 'definitions')
  walk[:depth] += 1 unless %i[map array].include?(walk[:mode])
end

#pointer_step_mode(node, token, dialect) ⇒ Symbol

How a keyword of a schema object holds what its pointer token reaches. A keyword the dialect in force does not define (prefixItems under draft-07, additionalItems under 2020-12) is opaque data there.

Returns:

  • (Symbol) —

    :schema (one subschema), :map, :array, or :opaque (data or unknown keyword: every token below is a step)



588
589
590
591
592
593
594
595
# File 'lib/mcp_client/schema_validator/references.rb', line 588

def pointer_step_mode(node, token, dialect)
  return :opaque unless node.is_a?(Hash) && keyword_known?(token, dialect)
  return :map if SUBSCHEMA_MAP_KEYWORDS.include?(token)
  return :array if SUBSCHEMA_ARRAY_KEYWORDS.include?(token) || (token == 'items' && node[token].is_a?(Array))
  return :schema if SUBSCHEMA_KEYWORDS.include?(token)

  :opaque
end

#pointer_tokens(ref) ⇒ Array<String>?

The decoded RFC 6901 tokens of a fragment pointer.

Returns:

  • (Array<String>, nil) —

    nil unless the reference is a pointer



567
568
569
570
571
572
# File 'lib/mcp_client/schema_validator/references.rb', line 567

def pointer_tokens(ref)
  fragment = decoded_fragment(ref)
  return nil unless fragment&.start_with?('/')

  fragment.split('/', -1).drop(1).map { |token| token.gsub('~1', '/').gsub('~0', '~') }
end

#record_anchor_names(index, resource, schema, dialect) ⇒ void

This method returns an undefined value.

Record the plain names a schema object declares for its resource; a name already bound to another object of the same resource is a duplicate (anchor names are unique within a resource).



314
315
316
317
318
319
320
# File 'lib/mcp_client/schema_validator/references.rb', line 314

def record_anchor_names(index, resource, schema, dialect)
  names = (index[:anchors][resource] ||= {})
  anchor_names(schema, dialect).each do |name|
    index[:duplicates] << name if names.key?(name) && !names[name].equal?(schema)
    names[name] ||= schema
  end
end

#referenced_position_depth(ref, root, dialect, counter, from) ⇒ Integer?

The lexical depth of the value a pointer reference reaches, counted in schema steps along the (percent-decoded) pointer from its resource root: a keyword holding one subschema is one step, a map or array of subschemas is one step per member (#/properties/b and #/allOf/0 are both one below the enclosing schema), and every token under a data or unknown keyword is a step, so a document hidden inside default, enum, const, examples or a vendor keyword obeys the same bound as one written in a schema position. Keywords are classified by the dialect in force at each node (an embedded resource's own $schema takes over when the pointer enters it). A schema object whose depth the lexical index knows resets the count to that depth — unless the pointer already passed through an opaque keyword, after which every token counts (the index placed such an object under another dialect's grammar).

Returns:

  • (Integer, nil) —

    nil when the pointer cannot be followed



499
500
501
# File 'lib/mcp_client/schema_validator/references.rb', line 499

def referenced_position_depth(ref, root, dialect, counter, from)
  pointer_position(ref, root, dialect, counter, from)&.first
end

#register_resource_base(index, resource, parent_base, declared: true) ⇒ void

This method returns an undefined value.

Record the base URI a schema resource establishes (RFC 3986 Section 5.1.1: an $id is resolved against the base in force where it is written), and the resource that base names. The first declaration of a base wins, as the first declaration of an anchor name does.

Parameters:

  • declared (Boolean) (defaults to: true) —

    whether the resource declares an $id



246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/mcp_client/schema_validator/references.rb', line 246

def register_resource_base(index, resource, parent_base, declared: true)
  id = declared && resource.is_a?(Hash) ? resource['$id'] : nil
  base = (id.is_a?(String) ? merge_uri(parent_base, id) : parent_base) || parent_base
  index[:bases][resource] = base
  known = index[:by_base][base]
  return index[:by_base][base] = resource if known.nil?

  # Two resources answering to one URI: which one a reference lands on
  # would depend on the order the walk met them in, so the document is
  # unusable rather than resolved by luck (JSON Schema 2020-12 Core
  # Section 9.1.2). Only a declared `$id` can collide; the base a
  # resource merely inherits is its parent's, already registered.
  index[:duplicate_ids] << base if id.is_a?(String) && !known.equal?(resource)
end

#resolve_adopted_pointer(resource, ref, index) ⇒ Object

Resolve a JSON pointer within a schema resource, adopting on the way whatever subtree the pointer enters through a data keyword (default and the rest): that subtree is normalized and indexed once — memoized by the identity of the object the document holds — and the remaining tokens are walked through the copy. So every pointer into it lands on the objects the index already knows, with the resource, dialect and subschema charge it gave them: a nested pointer is not a second copy attributed to the referrer (JSON Schema 2020-12 Core Sections 8.1.1 and 8.2.1: a schema belongs to the resource of its nearest $id ancestor, wherever a reference reached it from).

Parameters:

  • resource (Hash) —

    the resource root the pointer starts at

  • ref (String) —

    the $ref value

  • index (Hash) —

    the anchor index

Returns:

  • (Object) —

    the referenced value, or UNRESOLVED



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/mcp_client/schema_validator/references.rb', line 129

def resolve_adopted_pointer(resource, ref, index)
  fragment = decoded_fragment(ref)
  return UNRESOLVED unless fragment
  return adopt_reached_target(resource, resource, index) if fragment.empty?
  return UNRESOLVED unless fragment.start_with?('/')

  node = resource
  mode = :schema
  # RFC 6901 Section 5: the pointer "/" is the member named "", so the
  # leading separator is dropped rather than split off ("" splits to no
  # tokens at all, which would read "#/" as the whole document).
  fragment.split('/', -1).drop(1).each do |token|
    # RFC 6901 Section 3: "~" is only ever followed by "0" or "1".
    return UNRESOLVED if token.match?(/~(?![01])/)

    node, resource, mode = adopt_step(node, resource, index, mode)
    token = token.gsub('~1', '/').gsub('~0', '~')
    child = pointer_child(node, token)
    return UNRESOLVED if child.equal?(UNRESOLVED)

    mode = member_mode(token, child, mode)
    node = child
  end
  adopt_reached_target(node, resource, index, raw: mode == :data)
end

#resolve_reference(root, ref, dialect, resolver, from: nil) ⇒ Object

Resolve a local reference: a JSON pointer fragment, or a plain-name fragment naming an anchor. Both are relative to the schema resource the referencing schema belongs to (JSON Schema 2020-12 Core Section 8.2.1): inside an embedded resource # is that resource and #/$defs/x its own definitions, never the enclosing document's.

Parameters:

  • root (Hash) —

    the root schema (string keys)

  • ref (String) —

    the $ref value

  • dialect (String, nil) —

    the canonical dialect

  • resolver (Hash, Context) —

    holder of the memoized anchor index

  • from (Hash, nil) (defaults to: nil) —

    the schema object holding the reference

Returns:

  • (Object) —

    the referenced value, or UNRESOLVED



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/mcp_client/schema_validator/references.rb', line 67

def resolve_reference(root, ref, dialect, resolver, from: nil)
  index = (resolver[:anchors] ||= anchor_index(root, dialect))
  # A referring schema the index never reached has no known resource:
  # resolving against the document root would apply the wrong `#`.
  return UNRESOLVED if from.is_a?(Hash) && !index[:resources].key?(from)

  resource = (from && index[:resources][from]) || root
  # A reference outside the fragment space names a resource by URI:
  # the document may bundle it, and then the fragment applies there.
  unless ref.start_with?('#')
    resource, ref = retarget_reference(index, resource, ref)
    return UNRESOLVED unless resource
  end
  raw = ref.delete_prefix('#')
  return resolve_adopted_pointer(resource, ref, index) if raw.empty? || raw.start_with?('/', '%2F', '%2f')

  # A plain-name fragment is percent-decoded like a pointer fragment
  # (RFC 3986 Section 2.1): "#foo%2Dbar" names the anchor "foo-bar".
  # What counts as a name is the target resource's dialect's business.
  fragment = decoded_fragment(ref)
  return UNRESOLVED unless anchor_name?(fragment, index[:dialects][resource] || dialect)

  index[:anchors].fetch(resource, {}).fetch(fragment, UNRESOLVED)
end

#resource_root?(schema, dialect) ⇒ Boolean

#resource_start? in the dialect in force: under draft-07 a $ref replaces its whole schema object, $id and $schema included, so nothing beside it starts a resource.

Parameters:

  • schema (Hash)
  • dialect (String, nil)

Returns:

  • (Boolean)


363
364
365
366
367
# File 'lib/mcp_client/schema_validator/references.rb', line 363

def resource_root?(schema, dialect)
  return false if dialect == DRAFT_07 && schema.key?('$ref')

  resource_start?(schema)
end

#resource_start?(schema) ⇒ Boolean

Whether a schema object starts a new schema resource: its $id is a URI rather than a bare fragment (a draft-07 $id: "#name" is a plain-name identifier, not a base).

Parameters:

  • schema (Hash)

Returns:

  • (Boolean)


352
353
354
355
# File 'lib/mcp_client/schema_validator/references.rb', line 352

def resource_start?(schema)
  id = schema['$id']
  id.is_a?(String) && !id.empty? && !id.start_with?('#')
end

#retarget_reference(index, resource, ref) ⇒ Array(Hash, String), Array(nil, nil)

The bundled resource a reference outside the fragment space names, with the bare fragment left to resolve inside it.

Parameters:

  • index (Hash) —

    the anchor index

  • resource (Hash) —

    the resource the reference is written in

  • ref (String) —

    the $ref value

Returns:

  • (Array(Hash, String), Array(nil, nil))


49
50
51
52
53
54
# File 'lib/mcp_client/schema_validator/references.rb', line 49

def retarget_reference(index, resource, ref)
  uri, fragment = ref.split('#', 2)
  base = merge_uri(index[:bases][resource] || '', uri.to_s)
  target = base && index[:by_base][base]
  target ? [target, "##{fragment}"] : [nil, nil]
end