Module: MCPClient::SchemaValidator::KeywordScan

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

Overview

The bounded scan for keywords the validator does not evaluate, so the client can say when its validation is only partial. Extended into SchemaValidator, so the methods are its own.

Instance Method Summary collapse

Instance Method Details

#queue_scan(scan, schema, depth, dialect, referenced, walker) ⇒ void

This method returns an undefined value.

Queue the positions under a schema object (in document order, the stack being read from its end), then what its references reach.

Parameters:

  • walker (Symbol) —

    :each_subschema or :each_definition



85
86
87
88
89
90
# File 'lib/mcp_client/schema_validator/keyword_scan.rb', line 85

def queue_scan(scan, schema, depth, dialect, referenced, walker)
  positions = []
  send(walker, schema, dialect) { |sub| positions << [sub, depth + 1, dialect] }
  scan[:pending].concat(positions.reverse)
  scan[:pending].concat(referenced.reverse)
end

#referenced_position(schema, root, depth, scan, dialect, keyword = '$ref') ⇒ Array?

The position what a local reference applies is scanned at (unresolvable or external references are the preflight's business).

Parameters:

  • keyword (String) (defaults to: '$ref') —

    $ref, or a dynamic reference applied as one

Returns:

  • (Array, nil)


96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/mcp_client/schema_validator/keyword_scan.rb', line 96

def referenced_position(schema, root, depth, scan, dialect, keyword = '$ref')
  ref = schema[keyword]
  return nil unless ref.is_a?(String)
  return nil if external_ref?(ref, root, scan[:dialect], scan, from: schema)

  target = resolve_reference(root, ref, scan[:dialect], scan, from: schema)
  return nil if target.equal?(UNRESOLVED)

  # What a reference reaches is scanned under its own resource's dialect
  # and at its own lexical depth, so member order and reference fan-out
  # cannot hide its keywords. A target that is not a schema object has
  # no lexical depth at all — nil, never `false`, so the depth the
  # opaque-pointer branch compares stays a number.
  scan[:depths] ||= lexical_depths(root, scan[:dialect])
  lexical = scan[:depths][target] if target.is_a?(Hash)
  position, opaque = pointer_position(ref, root, scan[:dialect], scan, schema)
  target_depth = if opaque
                   [position, lexical].compact.max || (depth + 1)
                 else
                   lexical || position || (depth + 1)
                 end
  [target, target_depth, (target.is_a?(Hash) && indexed_dialect(target, scan)) || dialect]
end

#scan_position(schema, root, found, depth, scan, dialect = ) ⇒ void

This method returns an undefined value.

Collect the unsupported keywords one schema position uses (keywords the dialect does not define are unknown, not unsupported) and queue what it leads to. What a local $ref applies is scanned too, wherever it lives (a definition bag the dialect does not walk included); each object is scanned once.

Parameters:

  • schema (Object) —

    a (sub)schema; non-Hash values are ignored

  • root (Hash) —

    the normalized root schema

  • found (Array<String>) —

    accumulator

  • scan (Hash) —

    :count of subschemas seen so far, the canonical :dialect, the :walked objects, the memoized :anchors index and the :pending positions



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/mcp_client/schema_validator/keyword_scan.rb', line 57

def scan_position(schema, root, found, depth, scan, dialect = scan[:dialect])
  return unless schema.is_a?(Hash) && depth <= MAX_SCHEMA_DEPTH
  return if scan[:walked].key?(schema)

  scan[:walked][schema] = true
  scan[:count] += 1
  return if scan[:count] > MAX_SUBSCHEMAS

  # An embedded resource declaring its own $schema is scanned under it.
  dialect = embedded_dialect(schema, dialect) || dialect
  if dialect == DRAFT_07 && schema.key?('$ref')
    # Nothing beside the $ref is applied; the definitions stay reachable.
    referenced = [referenced_position(schema, root, depth, scan, dialect)].compact
    return queue_scan(scan, schema, depth, dialect, referenced, :each_definition)
  end

  # A dynamic reference is applied like a `$ref` — where it binds is
  # decided during evaluation — so it is scanned like one.
  dynamic = DYNAMIC_REFERENCE_KEYWORDS.select { |k| schema.key?(k) && keyword_known?(k, dialect) }
  found.concat((schema.keys & UNSUPPORTED_KEYWORDS).select { |k| keyword_known?(k, dialect) })
  referenced = (['$ref'] + dynamic).filter_map { |k| referenced_position(schema, root, depth, scan, dialect, k) }
  queue_scan(scan, schema, depth, dialect, referenced, :each_subschema)
end

#scan_positions(root, found, scan) ⇒ void

This method returns an undefined value.

Read every queued position. Like the preflight walk, the scan runs on a stack of its own rather than on the interpreter's: a shallow document whose $refs chain through hundreds of schemas is walked in constant stack space, so scanning one cannot overflow the thread a transport reads on.



38
39
40
41
42
43
# File 'lib/mcp_client/schema_validator/keyword_scan.rb', line 38

def scan_positions(root, found, scan)
  until scan[:pending].empty?
    schema, depth, dialect = scan[:pending].pop
    scan_position(schema, root, found, depth, scan, dialect)
  end
end

#unsupported_keywords(schema) ⇒ Array<String>

List the unsupported JSON Schema keywords a schema uses (anywhere: at the top level or nested in subschemas). Property names that merely look like keywords (e.g. a property called 'not') are not reported, and data-carrying keywords (enum/const/default/examples) are not scanned. The scan stops at the same bounds as MCPClient::SchemaValidator.check_schema (a schema beyond them is unusable anyway), so a huge server-supplied schema is never walked whole.

Parameters:

  • schema (Object) —

    the JSON schema (string or symbol keys)

Returns:

  • (Array<String>) —

    unique unsupported keywords, in discovery order



18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/mcp_client/schema_validator/keyword_scan.rb', line 18

def unsupported_keywords(schema)
  found = []
  root = normalize_schema(schema)
  declared = dialect(root)
  canonical = declared && canonical_dialect(declared)
  scan = { count: 0, dialect: canonical, walked: {}.compare_by_identity, anchors: nil,
           pending: [[root, 0, canonical]] }
  scan_positions(root, found, scan)
  found.uniq
rescue TooLarge
  # Unusable anyway: check_schema reports it.
  found.uniq
end