Skip to content

Commit 9d4dcbf

Browse files
committed
Speed Up Tool::Schema Validation by 5x to 100x
## Motivation and Context `MCP::Tool::Schema` previously used the pure-Ruby `json-schema` gem for both construction-time metaschema validation and runtime argument / result validation. For deep schemas this cost ~32-100ms per construction (see issue modelcontextprotocol#364). `json_schemer` (https://rubygems.org/gems/json_schemer, v2.5.0) is an actively maintained alternative that is much faster on this workload. Switching reduces cold-start warming pressure in horizontally scaled multi-tenant deployments (the scenario raised in modelcontextprotocol#364) and brings runtime data validation down to sub-millisecond per call once the per-instance schemer is memoized. The implementation preserves the legacy behavior of the `json-schema` based code. Metaschema is pinned to draft-04 (`meta_schema:` option); the `$schema` dialect URI is still emitted in `to_h` as 2020-12 but runtime validation continues to use draft-04, exactly as before. Moving the runtime validator itself to 2020-12 is out of scope for this PR and warrants its own discussion. `format` keywords are not enforced (`format: false` option), matching what `json-schema`'s draft-04 path did. Malformed schemas (e.g. an invalid `pattern` regex) continue to surface as `ArgumentError, "Invalid JSON Schema: ..."`; the `RegexpError` `json_schemer` raises during construction is wrapped. `Symbol` values in argument data are coerced to strings inside the internal `stringify` helper so they validate against `type: "string"` the same way they did before. The public `Schema#schema` accessor is dropped. It was a refactor artifact from modelcontextprotocol#198 (the consolidation of `InputSchema`'s `properties` / `required` readers into a single hash) with no callers in `lib/`; tests that needed the merged hash now read it through `to_h`. The accessor's only remaining role would have been to expose a mutation path that could desynchronize the memoized `@schemer`, which this change removes. Runtime dependency delta: drops `json-schema` (and `addressable`), adds `json_schemer`, `hana`, `regexp_parser`, and `simpleidn`. Each added gem is a single-purpose library tied to JSON Schema spec compliance. Closes modelcontextprotocol#364. ## How Has This Been Tested? Side-by-side benchmark on representative schemas (simple, with `$ref` / `additionalProperties`, nested, depth 20, depth 40): construction-time metaschema validation is 4.7x to ~100x faster; runtime data validation with the memoized schemer is sub-millisecond across all sizes. Four existing tests had their stub targets or message assertions updated to track the new validator while preserving the original test intent. The "unexpected errors bubble up" tests in `input_schema_test.rb` and `output_schema_test.rb` now stub `JSONSchemer::Schema#validate` instead of `JSON::Validator.fully_validate`. The cache tests in `schema_test.rb` now stub `JSONSchemer::Schema#validate_schema`. The two "detailed error message" tests in `tool_test.rb` switched from asserting `json-schema`'s exact wording to format-agnostic substrings (`"properties/count/minimum"`, `"number"`). The "required arguments are converted to strings" test in `input_schema_test.rb` now reads the result through `to_h[:required]` instead of the removed `schema` accessor. Regression tests added for the legacy-behavior preservations above: `format` keyword is not enforced, invalid `pattern` raises `ArgumentError` (not `RegexpError`), and `Symbol` values validate against `type: "string"`. ## Breaking Changes `Schema#schema` is no longer a public method. The accessor had no callers outside the test suite, but anyone reading the internal representation directly (rather than through `to_h`) will need to switch. `Schema` / `InputSchema` / `OutputSchema` otherwise keep their constructor signatures, `to_h` output, `ValidationError` / `ArgumentError` raise points, and the `$schema` dialect emission. The validator error message wording inside `ArgumentError` and `ValidationError` now comes from `json_schemer` and still includes the JSON pointer path and a description of the mismatch.
1 parent cf44475 commit 9d4dcbf

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)