Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/mcp/tool/input_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ def missing_required_arguments?(arguments)
end

def missing_required_arguments(arguments)
return [] unless schema[:required].is_a?(Array)
return [] unless @schema[:required].is_a?(Array)

(schema[:required] - arguments.keys.map(&:to_s))
(@schema[:required] - arguments.keys.map(&:to_s))
end

def validate_arguments(arguments)
Expand Down
57 changes: 38 additions & 19 deletions lib/mcp/tool/schema.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "digest"
require "json-schema"
require "json_schemer"

module MCP
class Tool
Expand Down Expand Up @@ -38,11 +38,10 @@ def clear

# JSON Schema 2020-12 is the default dialect for MCP schema definitions
# per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
# is still performed against the JSON Schema draft-04 metaschema because
# the `json-schema` gem does not yet support 2020-12.
# is still performed against the JSON Schema draft-04 metaschema.
JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"

attr_reader :schema
DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#"

def initialize(schema = {})
@schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
Expand All @@ -51,7 +50,7 @@ def initialize(schema = {})
end

def ==(other)
other.is_a?(self.class) && schema == other.schema
other.is_a?(self.class) && @schema == other.instance_variable_get(:@schema)
end

def to_h
Expand All @@ -62,8 +61,38 @@ def to_h

private

def stringify(obj)
case obj
when Hash
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
when Array
obj.map { |v| stringify(v) }
when Symbol
obj.to_s
else
obj
end
end

# Lazily built so a cache hit in `validate_schema!` avoids the schemer construction cost.
# Memoized per Schema instance because schema content is fixed at construction,
# so the compiled schemer is reusable across many `fully_validate` calls.
#
# `format: false` preserves the legacy behavior of the previous `json-schema` based implementation,
# which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as
# `ArgumentError` so callers see the same exception class they used to.
def schemer
@schemer ||= JSONSchemer.schema(
stringify(schema_for_validation),
meta_schema: DRAFT4_META_SCHEMA_URI,
format: false,
)
rescue RegexpError => e
raise ArgumentError, "Invalid JSON Schema: #{e.message}"
end

def fully_validate(data)
JSON::Validator.fully_validate(schema_for_validation, data)
schemer.validate(stringify(data)).map { |validation_error| validation_error.fetch("error") }
end

def validate_schema!
Expand All @@ -75,26 +104,16 @@ def validate_schema!
key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false))
return if VALIDATION_CACHE.validated?(key)

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, target, schema_reader: schema_reader)
errors = schemer.validate_schema.map { |validation_error| validation_error.fetch("error") }
if errors.any?
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
end

VALIDATION_CACHE.store(key)
end

# The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema`
# dialect URIs. Strip the top-level `$schema` before validation so a dialect URI
# (whether SDK-injected by `to_h` or user-supplied) does not break the validator.
# `json_schemer` is pinned to the draft-04 metaschema, so strip top-level `$schema` before validation:
# this preserves the legacy behavior of ignoring the advertised dialect URI when the SDK validates schemas.
def schema_for_validation
return @schema unless @schema.key?(:"$schema")

Expand Down
2 changes: 1 addition & 1 deletion mcp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]

spec.add_dependency("json-schema", ">= 4.1")
spec.add_dependency("json_schemer", ">= 2.4")
end
30 changes: 25 additions & 5 deletions test/mcp/tool/input_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class Tool
class InputSchemaTest < ActiveSupport::TestCase
test "required arguments are converted to strings" do
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
assert_equal ["message"], input_schema.schema[:required]
assert_equal ["message"], input_schema.to_h[:required]
end

test "to_h returns a hash representation of the input schema" do
Expand Down Expand Up @@ -139,10 +139,10 @@ class InputSchemaTest < ActiveSupport::TestCase
test "unexpected errors bubble up from validate_arguments" do
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])

JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
assert_raises(RuntimeError) do
schema.validate_arguments({ foo: "bar" })
end
JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error")

assert_raises(RuntimeError) do
schema.validate_arguments(foo: "bar")
end
end

Expand Down Expand Up @@ -200,6 +200,26 @@ class InputSchemaTest < ActiveSupport::TestCase
schema6 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false)
refute_equal schema1, schema6
end

test "format keyword is not enforced (legacy behavior)" do
schema = InputSchema.new(
properties: { email: { type: "string", format: "email" } },
required: ["email"],
)
assert_nil(schema.validate_arguments(email: "not_an_email"))
end

test "invalid pattern raises ArgumentError, not RegexpError" do
error = assert_raises(ArgumentError) do
InputSchema.new(properties: { id: { type: "string", pattern: "[" } })
end
assert_includes error.message, "Invalid JSON Schema"
end

test "Symbol values in arguments are treated as strings" do
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
assert_nil(schema.validate_arguments(foo: :bar))
end
end
end
end
8 changes: 4 additions & 4 deletions test/mcp/tool/output_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,10 @@ class OutputSchemaTest < ActiveSupport::TestCase
test "unexpected errors bubble up from validate_result" do
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])

JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
assert_raises(RuntimeError) do
schema.validate_result({ foo: "bar" })
end
JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error")

assert_raises(RuntimeError) do
schema.validate_result(foo: "bar")
end
end

Expand Down
14 changes: 7 additions & 7 deletions test/mcp/tool/schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ class SchemaTest < ActiveSupport::TestCase
end

test "validates a schema once and reuses the result for identical schemas" do
JSON::Validator.expects(:fully_validate).once.returns([])
JSONSchemer::Schema.any_instance.expects(:validate_schema).once.returns([])

schema = { properties: { validates_once: { type: "string" } } }
InputSchema.new(schema)
InputSchema.new(schema)
end

test "validates distinct schemas separately" do
JSON::Validator.expects(:fully_validate).twice.returns([])
JSONSchemer::Schema.any_instance.expects(:validate_schema).twice.returns([])

InputSchema.new(properties: { distinct_a: { type: "string" } })
InputSchema.new(properties: { distinct_b: { type: "string" } })
Expand Down Expand Up @@ -64,11 +64,11 @@ class SchemaTest < ActiveSupport::TestCase
break
end

JSON::Validator.stub(:fully_validate, []) do
assert_nothing_raised do
InputSchema.new(schema)
InputSchema.new(schema)
end
JSONSchemer::Schema.any_instance.stubs(:validate_schema).returns([])

assert_nothing_raised do
InputSchema.new(schema)
InputSchema.new(schema)
end
end

Expand Down
8 changes: 4 additions & 4 deletions test/mcp/tool_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ class InputSchemaTool < Tool
end

assert_includes error.message, "Invalid JSON Schema"
assert_includes error.message, "#/properties/count/minimum"
assert_includes error.message, "string did not match the following type: number"
assert_includes error.message, "properties/count/minimum"
assert_includes error.message, "number"
end

test ".define allows definition of simple tools with a block" do
Expand Down Expand Up @@ -431,8 +431,8 @@ class OutputSchemaObjectTool < Tool
end

assert_includes error.message, "Invalid JSON Schema"
assert_includes error.message, "#/properties/count/minimum"
assert_includes error.message, "string did not match the following type: number"
assert_includes error.message, "properties/count/minimum"
assert_includes error.message, "number"
end

test "output_schema accepts $ref in schema" do
Expand Down