Merge pull request #198 from ebeigarts/feature/schema-additional-properties
Refactor schema validation to reduce duplication and add additionalProperties support
This commit is contained in:
commit
4eab486d56
4 changed files with 240 additions and 162 deletions
|
|
@ -75,9 +75,7 @@ module MCP
|
|||
if value == NOT_SET
|
||||
input_schema_value
|
||||
elsif value.is_a?(Hash)
|
||||
properties = value[:properties] || value["properties"] || {}
|
||||
required = value[:required] || value["required"] || []
|
||||
@input_schema_value = InputSchema.new(properties:, required:)
|
||||
@input_schema_value = InputSchema.new(value)
|
||||
elsif value.is_a?(InputSchema)
|
||||
@input_schema_value = value
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,78 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "json-schema"
|
||||
require_relative "schema"
|
||||
|
||||
module MCP
|
||||
class Tool
|
||||
class InputSchema
|
||||
class InputSchema < Schema
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
attr_reader :properties, :required
|
||||
|
||||
def initialize(properties: {}, required: [])
|
||||
@properties = properties
|
||||
@required = required.map(&:to_sym)
|
||||
validate_schema!
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
other.is_a?(InputSchema) && properties == other.properties && required == other.required
|
||||
end
|
||||
|
||||
def to_h
|
||||
{ type: "object" }.tap do |hsh|
|
||||
hsh[:properties] = properties if properties.any?
|
||||
hsh[:required] = required if required.any?
|
||||
end
|
||||
end
|
||||
|
||||
def missing_required_arguments?(arguments)
|
||||
missing_required_arguments(arguments).any?
|
||||
end
|
||||
|
||||
def missing_required_arguments(arguments)
|
||||
(required - arguments.keys.map(&:to_sym))
|
||||
return [] unless schema[:required].is_a?(Array)
|
||||
|
||||
(schema[:required] - arguments.keys.map(&:to_s))
|
||||
end
|
||||
|
||||
def validate_arguments(arguments)
|
||||
errors = JSON::Validator.fully_validate(to_h, arguments)
|
||||
errors = fully_validate(arguments)
|
||||
if errors.any?
|
||||
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_schema!
|
||||
check_for_refs!
|
||||
schema = to_h
|
||||
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
||||
schema_reader = JSON::Schema::Reader.new(
|
||||
accept_uri: false,
|
||||
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
||||
)
|
||||
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
||||
# Converts metaschema to a file URI for cross-platform compatibility
|
||||
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
||||
metaschema = metaschema_uri.to_s
|
||||
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
||||
if errors.any?
|
||||
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
def check_for_refs!(obj = properties)
|
||||
case obj
|
||||
when Hash
|
||||
if obj.key?("$ref") || obj.key?(:$ref)
|
||||
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas"
|
||||
end
|
||||
|
||||
obj.each_value { |value| check_for_refs!(value) }
|
||||
when Array
|
||||
obj.each { |item| check_for_refs!(item) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,70 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "json-schema"
|
||||
require_relative "schema"
|
||||
|
||||
module MCP
|
||||
class Tool
|
||||
class OutputSchema
|
||||
class OutputSchema < Schema
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
attr_reader :schema
|
||||
|
||||
def initialize(schema = {})
|
||||
@schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
|
||||
@schema[:type] ||= "object"
|
||||
validate_schema!
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
other.is_a?(OutputSchema) && schema == other.schema
|
||||
end
|
||||
|
||||
def to_h
|
||||
@schema
|
||||
end
|
||||
|
||||
def validate_result(result)
|
||||
errors = JSON::Validator.fully_validate(to_h, result)
|
||||
errors = fully_validate(result)
|
||||
if errors.any?
|
||||
raise ValidationError, "Invalid result: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def deep_transform_keys(schema, &block)
|
||||
case schema
|
||||
when Hash
|
||||
schema.each_with_object({}) do |(key, value), result|
|
||||
if key.casecmp?("$ref")
|
||||
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas"
|
||||
end
|
||||
|
||||
result[yield(key)] = deep_transform_keys(value, &block)
|
||||
end
|
||||
when Array
|
||||
schema.map { |e| deep_transform_keys(e, &block) }
|
||||
else
|
||||
schema
|
||||
end
|
||||
end
|
||||
|
||||
def validate_schema!
|
||||
schema = to_h
|
||||
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
||||
schema_reader = JSON::Schema::Reader.new(
|
||||
accept_uri: false,
|
||||
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
||||
)
|
||||
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
||||
# Converts metaschema to a file URI for cross-platform compatibility
|
||||
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
||||
metaschema = metaschema_uri.to_s
|
||||
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
||||
if errors.any?
|
||||
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
65
lib/mcp/tool/schema.rb
Normal file
65
lib/mcp/tool/schema.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "json-schema"
|
||||
|
||||
module MCP
|
||||
class Tool
|
||||
class Schema
|
||||
attr_reader :schema
|
||||
|
||||
def initialize(schema = {})
|
||||
@schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
|
||||
@schema[:type] ||= "object"
|
||||
validate_schema!
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
other.is_a?(self.class) && schema == other.schema
|
||||
end
|
||||
|
||||
def to_h
|
||||
@schema
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fully_validate(data)
|
||||
JSON::Validator.fully_validate(to_h, data)
|
||||
end
|
||||
|
||||
def deep_transform_keys(schema, &block)
|
||||
case schema
|
||||
when Hash
|
||||
schema.each_with_object({}) do |(key, value), result|
|
||||
if key.casecmp?("$ref")
|
||||
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool schemas"
|
||||
end
|
||||
|
||||
result[yield(key)] = deep_transform_keys(value, &block)
|
||||
end
|
||||
when Array
|
||||
schema.map { |e| deep_transform_keys(e, &block) }
|
||||
else
|
||||
schema
|
||||
end
|
||||
end
|
||||
|
||||
def validate_schema!
|
||||
schema = to_h
|
||||
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
||||
schema_reader = JSON::Schema::Reader.new(
|
||||
accept_uri: false,
|
||||
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
||||
)
|
||||
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
||||
# Converts metaschema to a file URI for cross-platform compatibility
|
||||
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
||||
metaschema = metaschema_uri.to_s
|
||||
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
||||
if errors.any?
|
||||
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue