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
- 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).
-
.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: '#') ⇒ Array<String>
Validate data against a JSON Schema subset.
-
.validate_array(data, schema, path) ⇒ 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) ⇒ Array<String>
Validate an object against required/properties.
-
.validate_pattern(data, pattern, path) ⇒ Array<String>
Validate a string against a regular-expression pattern.
-
.validate_string(data, schema, path) ⇒ 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.
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/mcp_client/schema_validator.rb', line 66 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.
138 139 140 141 142 143 |
# File 'lib/mcp_client/schema_validator.rb', line 138 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).
148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/mcp_client/schema_validator.rb', line 148 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 |
.type_match?(type, data) ⇒ Boolean
Whether a value matches a JSON Schema type name. Unknown type names are not enforced (returns true).
121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/mcp_client/schema_validator.rb', line 121 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.
56 57 58 59 60 |
# File 'lib/mcp_client/schema_validator.rb', line 56 def self.unsupported_keywords(schema) found = [] collect_unsupported_keywords(schema, found) found.uniq end |
.validate(data, schema, path: '#') ⇒ Array<String>
Validate data against a JSON Schema subset. Schema and data hashes may use string or symbol keys.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/mcp_client/schema_validator.rb', line 88 def self.validate(data, schema, path: '#') return [] unless schema.is_a?(Hash) 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)) when Array then errors.concat(validate_array(data, schema, path)) when String then errors.concat(validate_string(data, schema, path)) when Numeric then errors.concat(validate_number(data, schema, path)) end errors end |
.validate_array(data, schema, path) ⇒ Array<String>
Validate an array against items/minItems/maxItems.
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/mcp_client/schema_validator.rb', line 212 def self.validate_array(data, schema, path) 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 { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) } end errors end |
.validate_enum(data, schema, path) ⇒ Array<String>
Validate enum/const membership.
166 167 168 169 170 171 172 173 174 175 |
# File 'lib/mcp_client/schema_validator.rb', line 166 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.
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/mcp_client/schema_validator.rb', line 268 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) ⇒ Array<String>
Validate an object against required/properties.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/mcp_client/schema_validator.rb', line 182 def self.validate_object(data, schema, path) 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}")) end errors end |
.validate_pattern(data, pattern, path) ⇒ Array<String>
Validate a string against a regular-expression pattern. Invalid patterns are not enforced.
254 255 256 257 258 259 260 261 |
# File 'lib/mcp_client/schema_validator.rb', line 254 def self.validate_pattern(data, pattern, path) return [] unless pattern.is_a?(String) return [] if data.match?(Regexp.new(pattern)) ["#{path}: string does not match pattern #{pattern.inspect}"] rescue RegexpError [] end |
.validate_string(data, schema, path) ⇒ Array<String>
Validate a string against minLength/maxLength/pattern.
234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/mcp_client/schema_validator.rb', line 234 def self.validate_string(data, schema, path) 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)) errors end |
.validate_type(data, type, path) ⇒ Array<String>
Validate the JSON type of a value.
109 110 111 112 113 114 |
# File 'lib/mcp_client/schema_validator.rb', line 109 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 |