Module: MCPClient::SchemaValidator
- Defined in:
- lib/mcp_client/schema_validator.rb
Overview
Self-contained JSON Schema validator used to check a tool call result's structuredContent against the tool's declared outputSchema (MCP 2025-11-25 server/tools spec: "Clients SHOULD validate structured results against this schema"; the default schema dialect is JSON Schema 2020-12 per SEP-1613).
Only the common JSON Schema keywords are supported:
- type (single value or array of values), enum, const
- properties, required (objects)
- items, minItems, maxItems (arrays)
- minLength, maxLength, pattern (strings)
- minimum, maximum, exclusiveMinimum, exclusiveMaximum (numbers)
The full JSON Schema 2020-12 vocabulary ($ref/$defs, allOf/anyOf/oneOf/not, conditional keywords, additionalProperties, format assertions, ...) is out of scope: unrecognized keywords are ignored rather than misapplied, so validation is best-effort — it may accept data a full validator would reject, but it does not reject data that conforms to the schema. So that this gap is never silent, SchemaValidator.unsupported_keywords reports which unapplied validation keywords a schema uses; callers surface them as a warning.
Constant Summary collapse
- UNSUPPORTED_KEYWORDS =
JSON Schema 2020-12 keywords that affect validation but that this validator does not evaluate: applicator/reference keywords, assertion keywords (multipleOf, uniqueItems, contains bounds, property-count bounds, dependentRequired), and format (asserted by full validators in format-assertion mode). Their presence means validation is partial: data may pass here that a full validator would reject.
%w[ $ref $dynamicRef $defs allOf anyOf oneOf not if then else additionalProperties patternProperties propertyNames dependentSchemas prefixItems contains minContains maxContains uniqueItems multipleOf format dependentRequired minProperties maxProperties unevaluatedProperties unevaluatedItems ].freeze
- PATTERN_MATCH_TIMEOUT =
Wall-clock budget for ALL pattern matching in a single validate call. Schemas come from the remote server, so an expensive expression must not be able to monopolize the calling thread.
The budget is for the whole operation, not per match: a per-match limit multiplies, since the server also controls how many strings it sends (N array items under one pathological items.pattern costs N x limit).
1.0- MIN_PATTERN_MATCH_TIMEOUT =
Floor for an individual match's timeout, so a nearly-exhausted budget still makes progress rather than failing every remaining pattern.
0.01- SUBSCHEMA_KEYWORDS =
Keywords whose value is a single subschema to walk.
%w[ items contains additionalProperties propertyNames not if then else unevaluatedItems unevaluatedProperties ].freeze
- SUBSCHEMA_MAP_KEYWORDS =
Keywords whose value is a map of name => subschema.
%w[properties patternProperties $defs definitions dependentSchemas].freeze
- SUBSCHEMA_ARRAY_KEYWORDS =
Keywords whose value is an array of subschemas.
%w[allOf anyOf oneOf prefixItems].freeze
Class Method Summary collapse
-
.collect_unsupported_keywords(schema, found) ⇒ void
Recursively collect unsupported keywords from a schema.
-
.integer?(data) ⇒ Boolean
Whether a value is a JSON Schema integer.
-
.json_type(data) ⇒ String
The JSON type name of a Ruby value (for error messages).
-
.pattern_budget_remaining(deadline) ⇒ Float
Time left in the validation-wide pattern budget.
-
.type_match?(type, data) ⇒ Boolean
Whether a value matches a JSON Schema type name.
-
.unsupported_keywords(schema) ⇒ Array<String>
List the unsupported JSON Schema keywords a schema uses (anywhere: at the top level or nested in subschemas).
-
.validate(data, schema, path: '#', deadline: nil) ⇒ Array<String>
Validate data against a JSON Schema subset.
-
.validate_array(data, schema, path, deadline = nil) ⇒ Array<String>
Validate an array against items/minItems/maxItems.
-
.validate_enum(data, schema, path) ⇒ Array<String>
Validate enum/const membership.
-
.validate_number(data, schema, path) ⇒ Array<String>
Validate a number against inclusive/exclusive bounds.
-
.validate_object(data, schema, path, deadline = nil) ⇒ Array<String>
Validate an object against required/properties.
-
.validate_pattern(data, pattern, path, deadline = nil) ⇒ Array<String>
Validate a string against a regular-expression pattern.
-
.validate_string(data, schema, path, deadline = nil) ⇒ Array<String>
Validate a string against minLength/maxLength/pattern.
-
.validate_type(data, type, path) ⇒ Array<String>
Validate the JSON type of a value.
Class Method Details
.collect_unsupported_keywords(schema, found) ⇒ void
This method returns an undefined value.
Recursively collect unsupported keywords from a schema.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/mcp_client/schema_validator.rb', line 79 def self.collect_unsupported_keywords(schema, found) return unless schema.is_a?(Hash) schema = schema.transform_keys(&:to_s) found.concat(schema.keys & UNSUPPORTED_KEYWORDS) schema.each do |keyword, value| if SUBSCHEMA_KEYWORDS.include?(keyword) collect_unsupported_keywords(value, found) elsif SUBSCHEMA_MAP_KEYWORDS.include?(keyword) && value.is_a?(Hash) value.each_value { |subschema| collect_unsupported_keywords(subschema, found) } elsif SUBSCHEMA_ARRAY_KEYWORDS.include?(keyword) && value.is_a?(Array) value.each { |subschema| collect_unsupported_keywords(subschema, found) } end end end |
.integer?(data) ⇒ Boolean
Whether a value is a JSON Schema integer. Per JSON Schema 2020-12 a number with a zero fractional part (e.g. 2.0) is a valid integer.
154 155 156 157 158 159 |
# File 'lib/mcp_client/schema_validator.rb', line 154 def self.integer?(data) return true if data.is_a?(Integer) return false unless data.is_a?(Numeric) (data % 1).zero? end |
.json_type(data) ⇒ String
The JSON type name of a Ruby value (for error messages).
164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/mcp_client/schema_validator.rb', line 164 def self.json_type(data) case data when nil then 'null' when true, false then 'boolean' when Integer then 'integer' when Numeric then 'number' when String then 'string' when Array then 'array' when Hash then 'object' else data.class.name end end |
.pattern_budget_remaining(deadline) ⇒ Float
Time left in the validation-wide pattern budget.
298 299 300 301 302 303 304 305 |
# File 'lib/mcp_client/schema_validator.rb', line 298 def self.pattern_budget_remaining(deadline) return PATTERN_MATCH_TIMEOUT unless deadline remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) return 0.0 if remaining <= 0 [remaining, MIN_PATTERN_MATCH_TIMEOUT].max end |
.type_match?(type, data) ⇒ Boolean
Whether a value matches a JSON Schema type name. Unknown type names are not enforced (returns true).
137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/mcp_client/schema_validator.rb', line 137 def self.type_match?(type, data) case type when 'object' then data.is_a?(Hash) when 'array' then data.is_a?(Array) when 'string' then data.is_a?(String) when 'boolean' then data.equal?(true) || data.equal?(false) when 'null' then data.nil? when 'number' then data.is_a?(Numeric) when 'integer' then integer?(data) else true 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.
69 70 71 72 73 |
# File 'lib/mcp_client/schema_validator.rb', line 69 def self.unsupported_keywords(schema) found = [] collect_unsupported_keywords(schema, found) found.uniq end |
.validate(data, schema, path: '#', deadline: nil) ⇒ Array<String>
Validate data against a JSON Schema subset. Schema and data hashes may use string or symbol keys.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/mcp_client/schema_validator.rb', line 101 def self.validate(data, schema, path: '#', deadline: nil) return [] unless schema.is_a?(Hash) # One deadline covers the entire (recursive) validation. deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT schema = schema.transform_keys(&:to_s) errors = [] errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type') errors.concat(validate_enum(data, schema, path)) case data when Hash then errors.concat(validate_object(data, schema, path, deadline)) when Array then errors.concat(validate_array(data, schema, path, deadline)) when String then errors.concat(validate_string(data, schema, path, deadline)) when Numeric then errors.concat(validate_number(data, schema, path)) end errors end |
.validate_array(data, schema, path, deadline = nil) ⇒ Array<String>
Validate an array against items/minItems/maxItems.
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/mcp_client/schema_validator.rb', line 228 def self.validate_array(data, schema, path, deadline = 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 items = schema['items'] if items.is_a?(Hash) data.each_with_index do |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline)) end end errors end |
.validate_enum(data, schema, path) ⇒ Array<String>
Validate enum/const membership.
182 183 184 185 186 187 188 189 190 191 |
# File 'lib/mcp_client/schema_validator.rb', line 182 def self.validate_enum(data, schema, path) errors = [] if schema['enum'].is_a?(Array) && !schema['enum'].include?(data) errors << "#{path}: value #{data.inspect} is not in enum #{schema['enum'].inspect}" end if schema.key?('const') && schema['const'] != data errors << "#{path}: value #{data.inspect} does not equal const #{schema['const'].inspect}" end errors end |
.validate_number(data, schema, path) ⇒ Array<String>
Validate a number against inclusive/exclusive bounds.
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/mcp_client/schema_validator.rb', line 312 def self.validate_number(data, schema, path) errors = [] minimum = schema['minimum'] maximum = schema['maximum'] exclusive_min = schema['exclusiveMinimum'] exclusive_max = schema['exclusiveMaximum'] errors << "#{path}: value #{data} is less than minimum #{minimum}" if minimum.is_a?(Numeric) && data < minimum errors << "#{path}: value #{data} is greater than maximum #{maximum}" if maximum.is_a?(Numeric) && data > maximum if exclusive_min.is_a?(Numeric) && data <= exclusive_min errors << "#{path}: value #{data} must be greater than exclusiveMinimum #{exclusive_min}" end if exclusive_max.is_a?(Numeric) && data >= exclusive_max errors << "#{path}: value #{data} must be less than exclusiveMaximum #{exclusive_max}" end errors end |
.validate_object(data, schema, path, deadline = nil) ⇒ Array<String>
Validate an object against required/properties.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/mcp_client/schema_validator.rb', line 198 def self.validate_object(data, schema, path, deadline = nil) errors = [] Array(schema['required']).each do |raw_name| name = raw_name.to_s errors << "#{path}: missing required property '#{name}'" unless data.key?(name) || data.key?(name.to_sym) end properties = schema['properties'] return errors unless properties.is_a?(Hash) properties.each do |raw_name, prop_schema| next unless prop_schema.is_a?(Hash) name = raw_name.to_s key = if data.key?(name) name elsif data.key?(name.to_sym) name.to_sym end next if key.nil? errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline)) end errors end |
.validate_pattern(data, pattern, path, deadline = nil) ⇒ Array<String>
Validate a string against a regular-expression pattern. Invalid patterns are not enforced.
The pattern comes from the tool's outputSchema, i.e. from the remote server, so matching runs against the validation-wide deadline: neither a single expensive expression nor many cheap-looking ones can pin the calling thread. A match that exceeds the budget is reported as a validation error rather than silently accepted — the value was never shown to satisfy the schema.
280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/mcp_client/schema_validator.rb', line 280 def self.validate_pattern(data, pattern, path, deadline = nil) return [] unless pattern.is_a?(String) remaining = pattern_budget_remaining(deadline) return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero? return [] if data.match?(Regexp.new(pattern, timeout: remaining)) ["#{path}: string does not match pattern #{pattern.inspect}"] rescue Regexp::TimeoutError ["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"] rescue RegexpError [] end |
.validate_string(data, schema, path, deadline = nil) ⇒ Array<String>
Validate a string against minLength/maxLength/pattern.
252 253 254 255 256 257 258 259 260 261 262 263 264 |
# File 'lib/mcp_client/schema_validator.rb', line 252 def self.validate_string(data, schema, path, deadline = nil) errors = [] min_length = schema['minLength'] max_length = schema['maxLength'] if min_length.is_a?(Numeric) && data.length < min_length errors << "#{path}: string is shorter than minLength #{min_length}" end if max_length.is_a?(Numeric) && data.length > max_length errors << "#{path}: string is longer than maxLength #{max_length}" end errors.concat(validate_pattern(data, schema['pattern'], path, deadline)) errors end |
.validate_type(data, type, path) ⇒ Array<String>
Validate the JSON type of a value.
125 126 127 128 129 130 |
# File 'lib/mcp_client/schema_validator.rb', line 125 def self.validate_type(data, type, path) types = (type.is_a?(Array) ? type : [type]).map(&:to_s) return [] if types.any? { |t| type_match?(t, data) } ["#{path}: expected type #{types.join(' or ')}, got #{json_type(data)}"] end |