## 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 #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 #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 #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 #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.
225 lines
8.7 KiB
Ruby
225 lines
8.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
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.to_h[:required]
|
|
end
|
|
|
|
test "to_h returns a hash representation of the input schema" do
|
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
|
assert_equal(
|
|
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
},
|
|
input_schema.to_h,
|
|
)
|
|
end
|
|
|
|
test "to_h returns a hash representation of the input schema with additionalProperties set to false" do
|
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false)
|
|
assert_equal(
|
|
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
},
|
|
input_schema.to_h,
|
|
)
|
|
end
|
|
|
|
test "to_h preserves user-supplied $schema dialect" do
|
|
input_schema = InputSchema.new(
|
|
"$schema": "https://json-schema.org/draft/2019-09/schema",
|
|
properties: { message: { type: "string" } },
|
|
)
|
|
assert_equal "https://json-schema.org/draft/2019-09/schema", input_schema.to_h[:"$schema"]
|
|
end
|
|
|
|
test "validate_arguments works when user supplies a 2020-12 $schema" do
|
|
input_schema = InputSchema.new(
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
properties: { foo: { type: "string" } },
|
|
required: ["foo"],
|
|
)
|
|
assert_nil(input_schema.validate_arguments(foo: "bar"))
|
|
assert_raises(InputSchema::ValidationError) do
|
|
input_schema.validate_arguments({ foo: 123 })
|
|
end
|
|
end
|
|
|
|
test "to_h preserves user-supplied $schema given via string key" do
|
|
# The initializer normalizes input through `JSON.parse(...,
|
|
# symbolize_names: true)`, so a string-keyed `"$schema"` should
|
|
# arrive at `schema_for_validation` the same as a symbol-keyed one.
|
|
input_schema = InputSchema.new(
|
|
{
|
|
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
"properties" => { "foo" => { "type" => "string" } },
|
|
"required" => ["foo"],
|
|
},
|
|
)
|
|
assert_equal "https://json-schema.org/draft/2020-12/schema", input_schema.to_h[:"$schema"]
|
|
assert_nil(input_schema.validate_arguments(foo: "bar"))
|
|
end
|
|
|
|
test "missing_required_arguments returns an array of missing required arguments" do
|
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
|
assert_equal ["message"], input_schema.missing_required_arguments({})
|
|
end
|
|
|
|
test "missing_required_arguments returns an empty array if no required arguments are missing" do
|
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
|
assert_equal([], input_schema.missing_required_arguments({ message: "Hello, world!" }))
|
|
end
|
|
|
|
test "valid schema initialization" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
assert_equal(
|
|
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
properties: { foo: { type: "string" } },
|
|
required: ["foo"],
|
|
},
|
|
schema.to_h,
|
|
)
|
|
end
|
|
|
|
test "invalid schema raises argument error" do
|
|
assert_raises(ArgumentError) do
|
|
InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: ["foo"])
|
|
end
|
|
end
|
|
|
|
test "schema without required arguments is valid" do
|
|
assert_nothing_raised do
|
|
InputSchema.new(properties: { foo: { type: "string" } })
|
|
end
|
|
end
|
|
|
|
test "validate arguments with valid data" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
assert_nil(schema.validate_arguments({ foo: "bar" }))
|
|
end
|
|
|
|
test "validate arguments with invalid data" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
assert_raises(InputSchema::ValidationError) do
|
|
schema.validate_arguments({ foo: 123 })
|
|
end
|
|
end
|
|
|
|
test "validate arguments with valid data when additionalProperties set to nil (default)" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
assert_nil(schema.validate_arguments({ foo: "bar", extra: 123 }))
|
|
end
|
|
|
|
test "validate arguments with valid data when additionalProperties set to true (default)" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: true)
|
|
assert_nil(schema.validate_arguments({ foo: "bar", extra: 123 }))
|
|
end
|
|
|
|
test "validate arguments with invalid data when additionalProperties set to false" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false)
|
|
assert_raises(InputSchema::ValidationError) do
|
|
schema.validate_arguments({ foo: "bar", extra: 123 })
|
|
end
|
|
end
|
|
|
|
test "unexpected errors bubble up from validate_arguments" do
|
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
|
|
JSONSchemer::Schema.any_instance.stubs(:validate).raises("unexpected error")
|
|
|
|
assert_raises(RuntimeError) do
|
|
schema.validate_arguments(foo: "bar")
|
|
end
|
|
end
|
|
|
|
test "accepts schemas with $ref references" do
|
|
schema = InputSchema.new(
|
|
properties: {
|
|
foo: { type: "string" },
|
|
},
|
|
definitions: {
|
|
bar: { type: "string" },
|
|
},
|
|
required: ["foo"],
|
|
)
|
|
assert_includes schema.to_h.keys, :definitions
|
|
end
|
|
|
|
test "accepts schemas with $ref string key and includes $ref in to_h" do
|
|
schema = InputSchema.new({
|
|
"properties" => {
|
|
"foo" => { "$ref" => "#/definitions/bar" },
|
|
},
|
|
"definitions" => {
|
|
"bar" => { "type" => "string" },
|
|
},
|
|
})
|
|
assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref]
|
|
end
|
|
|
|
test "accepts schemas with $ref symbol key and includes $ref in to_h" do
|
|
schema = InputSchema.new({
|
|
properties: {
|
|
foo: { :$ref => "#/definitions/bar" },
|
|
},
|
|
definitions: {
|
|
bar: { type: "string" },
|
|
},
|
|
})
|
|
assert_equal "#/definitions/bar", schema.to_h[:properties][:foo][:$ref]
|
|
end
|
|
|
|
test "== compares two input schemas with the same properties, required fields" do
|
|
schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
|
assert_equal schema1, schema2
|
|
|
|
schema3 = InputSchema.new(properties: { bar: { type: "string" } }, required: ["bar"])
|
|
refute_equal schema1, schema3
|
|
|
|
schema4 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["bar"])
|
|
refute_equal schema1, schema4
|
|
|
|
schema5 = InputSchema.new(properties: { bar: { type: "string" } }, required: ["foo"])
|
|
refute_equal schema1, schema5
|
|
|
|
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
|