Skip to content

Commit 39676bc

Browse files
authored
Merge pull request #369 from koic/switch_to_json_schemer
Speed Up `Tool::Schema` Validation by 5x to 100x
2 parents cf27c5d + 9d4dcbf commit 39676bc

7 files changed

Lines changed: 81 additions & 42 deletions

File tree

lib/mcp/tool/input_schema.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ def missing_required_arguments?(arguments)
1212
end
1313

1414
def missing_required_arguments(arguments)
15-
return [] unless schema[:required].is_a?(Array)
15+
return [] unless @schema[:required].is_a?(Array)
1616

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

2020
def validate_arguments(arguments)

lib/mcp/tool/schema.rb

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
require "digest"
4-
require "json-schema"
4+
require "json_schemer"
55

66
module MCP
77
class Tool
@@ -38,11 +38,10 @@ def clear
3838

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

45-
attr_reader :schema
44+
DRAFT4_META_SCHEMA_URI = "http://json-schema.org/draft-04/schema#"
4645

4746
def initialize(schema = {})
4847
@schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
@@ -51,7 +50,7 @@ def initialize(schema = {})
5150
end
5251

5352
def ==(other)
54-
other.is_a?(self.class) && schema == other.schema
53+
other.is_a?(self.class) && @schema == other.instance_variable_get(:@schema)
5554
end
5655

5756
def to_h
@@ -62,8 +61,38 @@ def to_h
6261

6362
private
6463

64+
def stringify(obj)
65+
case obj
66+
when Hash
67+
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
68+
when Array
69+
obj.map { |v| stringify(v) }
70+
when Symbol
71+
obj.to_s
72+
else
73+
obj
74+
end
75+
end
76+
77+
# Lazily built so a cache hit in `validate_schema!` avoids the schemer construction cost.
78+
# Memoized per Schema instance because schema content is fixed at construction,
79+
# so the compiled schemer is reusable across many `fully_validate` calls.
80+
#
81+
# `format: false` preserves the legacy behavior of the previous `json-schema` based implementation,
82+
# which did not enforce `format` keywords. `RegexpError` from a malformed `pattern` is re-raised as
83+
# `ArgumentError` so callers see the same exception class they used to.
84+
def schemer
85+
@schemer ||= JSONSchemer.schema(
86+
stringify(schema_for_validation),
87+
meta_schema: DRAFT4_META_SCHEMA_URI,
88+
format: false,
89+
)
90+
rescue RegexpError => e
91+
raise ArgumentError, "Invalid JSON Schema: #{e.message}"
92+
end
93+
6594
def fully_validate(data)
66-
JSON::Validator.fully_validate(schema_for_validation, data)
95+
schemer.validate(stringify(data)).map { |validation_error| validation_error.fetch("error") }
6796
end
6897

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

78-
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
79-
schema_reader = JSON::Schema::Reader.new(
80-
accept_uri: false,
81-
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
82-
)
83-
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
84-
# Converts metaschema to a file URI for cross-platform compatibility
85-
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
86-
metaschema = metaschema_uri.to_s
87-
errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader)
107+
errors = schemer.validate_schema.map { |validation_error| validation_error.fetch("error") }
88108
if errors.any?
89109
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
90110
end
91111

92112
VALIDATION_CACHE.store(key)
93113
end
94114

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

mcp.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ Gem::Specification.new do |spec|
3030
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
3131
spec.require_paths = ["lib"]
3232

33-
spec.add_dependency("json-schema", ">= 4.1")
33+
spec.add_dependency("json_schemer", ">= 2.4")
3434
end

test/mcp/tool/input_schema_test.rb

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class Tool
77
class InputSchemaTest < ActiveSupport::TestCase
88
test "required arguments are converted to strings" do
99
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
10-
assert_equal ["message"], input_schema.schema[:required]
10+
assert_equal ["message"], input_schema.to_h[:required]
1111
end
1212

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

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

@@ -200,6 +200,26 @@ class InputSchemaTest < ActiveSupport::TestCase
200200
schema6 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false)
201201
refute_equal schema1, schema6
202202
end
203+
204+
test "format keyword is not enforced (legacy behavior)" do
205+
schema = InputSchema.new(
206+
properties: { email: { type: "string", format: "email" } },
207+
required: ["email"],
208+
)
209+
assert_nil(schema.validate_arguments(email: "not_an_email"))
210+
end
211+
212+
test "invalid pattern raises ArgumentError, not RegexpError" do
213+
error = assert_raises(ArgumentError) do
214+
InputSchema.new(properties: { id: { type: "string", pattern: "[" } })
215+
end
216+
assert_includes error.message, "Invalid JSON Schema"
217+
end
218+
219+
test "Symbol values in arguments are treated as strings" do
220+
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
221+
assert_nil(schema.validate_arguments(foo: :bar))
222+
end
203223
end
204224
end
205225
end

test/mcp/tool/output_schema_test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ class OutputSchemaTest < ActiveSupport::TestCase
110110
test "unexpected errors bubble up from validate_result" do
111111
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
112112

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

test/mcp/tool/schema_test.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ class SchemaTest < ActiveSupport::TestCase
1010
end
1111

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

1515
schema = { properties: { validates_once: { type: "string" } } }
1616
InputSchema.new(schema)
1717
InputSchema.new(schema)
1818
end
1919

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

2323
InputSchema.new(properties: { distinct_a: { type: "string" } })
2424
InputSchema.new(properties: { distinct_b: { type: "string" } })
@@ -64,11 +64,11 @@ class SchemaTest < ActiveSupport::TestCase
6464
break
6565
end
6666

67-
JSON::Validator.stub(:fully_validate, []) do
68-
assert_nothing_raised do
69-
InputSchema.new(schema)
70-
InputSchema.new(schema)
71-
end
67+
JSONSchemer::Schema.any_instance.stubs(:validate_schema).returns([])
68+
69+
assert_nothing_raised do
70+
InputSchema.new(schema)
71+
InputSchema.new(schema)
7272
end
7373
end
7474

test/mcp/tool_test.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ class InputSchemaTool < Tool
167167
end
168168

169169
assert_includes error.message, "Invalid JSON Schema"
170-
assert_includes error.message, "#/properties/count/minimum"
171-
assert_includes error.message, "string did not match the following type: number"
170+
assert_includes error.message, "properties/count/minimum"
171+
assert_includes error.message, "number"
172172
end
173173

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

433433
assert_includes error.message, "Invalid JSON Schema"
434-
assert_includes error.message, "#/properties/count/minimum"
435-
assert_includes error.message, "string did not match the following type: number"
434+
assert_includes error.message, "properties/count/minimum"
435+
assert_includes error.message, "number"
436436
end
437437

438438
test "output_schema accepts $ref in schema" do

0 commit comments

Comments
 (0)