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
26 changes: 22 additions & 4 deletions lib/mcp/tool/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
module MCP
class Tool
class Schema
# 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.
JSON_SCHEMA_2020_12_URI = "https://json-schema.org/draft/2020-12/schema"

attr_reader :schema

def initialize(schema = {})
Expand All @@ -18,17 +24,18 @@ def ==(other)
end

def to_h
@schema
return @schema if @schema.key?(:"$schema")

{ "$schema": JSON_SCHEMA_2020_12_URI }.merge(@schema)
end

private

def fully_validate(data)
JSON::Validator.fully_validate(to_h, data)
JSON::Validator.fully_validate(schema_for_validation, data)
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,
Expand All @@ -38,11 +45,22 @@ def validate_schema!
# 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)
errors = JSON::Validator.fully_validate(metaschema, schema_for_validation, schema_reader: schema_reader)
if errors.any?
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
end
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.
def schema_for_validation
return @schema unless @schema.key?(:"$schema")

copy = @schema.dup
copy.delete(:"$schema")
copy
end
end
end
end
22 changes: 21 additions & 1 deletion test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ class ServerTest < ActiveSupport::TestCase
assert_equal "test_tool", result[:tools][0][:name]
assert_equal "Test tool", result[:tools][0][:title]
assert_equal "A test tool", result[:tools][0][:description]
assert_equal({ type: "object" }, result[:tools][0][:inputSchema])
assert_equal(
{ "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, result[:tools][0][:inputSchema]
)
assert_equal({ foo: "bar" }, result[:tools][0][:_meta])
assert_instrumentation_data({ method: "tools/list" })
end
Expand All @@ -284,6 +286,24 @@ class ServerTest < ActiveSupport::TestCase
assert_equal({ foo: "bar" }, result[:tools][0][:_meta])
end

test "#handle tools/list emits 2020-12 $schema on inputSchema and outputSchema" do
tool_with_output = Tool.define(
name: "tool_with_output",
description: "tool with output schema",
input_schema: { properties: { msg: { type: "string" } } },
output_schema: { properties: { result: { type: "string" } } },
) do
Tool::Response.new([{ type: "text", content: "OK" }])
end
server = Server.new(name: "test_server", tools: [tool_with_output])

response = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })
tool = response[:result][:tools][0]

assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:inputSchema][:"$schema"]
assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:outputSchema][:"$schema"]
end

test "#handle tools/call executes tool and returns result" do
tool_name = "test_tool"
tool_args = { arg: "value" }
Expand Down
60 changes: 57 additions & 3 deletions test/mcp/tool/input_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,65 @@ class InputSchemaTest < ActiveSupport::TestCase
test "to_h returns a hash representation of the input schema" do
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
assert_equal(
{ type: "object", properties: { message: { type: "string" } }, required: ["message"] },
{
"$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(
{ type: "object", properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false },
{
"$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({})
Expand All @@ -38,7 +84,15 @@ class InputSchemaTest < ActiveSupport::TestCase

test "valid schema initialization" do
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h)
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
Expand Down
45 changes: 42 additions & 3 deletions test/mcp/tool/output_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,38 @@ class OutputSchemaTest < ActiveSupport::TestCase
test "to_h returns a hash representation of the output schema" do
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
assert_equal(
{ type: "object", properties: { result: { type: "string" } }, required: ["result"] },
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: { type: "string" } },
required: ["result"],
},
output_schema.to_h,
)
end

test "to_h preserves user-supplied $schema dialect" do
output_schema = OutputSchema.new(
"$schema": "https://json-schema.org/draft/2019-09/schema",
properties: { result: { type: "string" } },
)
assert_equal "https://json-schema.org/draft/2019-09/schema", output_schema.to_h[:"$schema"]
end

test "validate_result works when user supplies a 2020-12 $schema" do
output_schema = OutputSchema.new(
"$schema": "https://json-schema.org/draft/2020-12/schema",
properties: { result: { type: "string" } },
required: ["result"],
)
assert_nothing_raised do
output_schema.validate_result({ result: "success" })
end
assert_raises(OutputSchema::ValidationError) do
output_schema.validate_result({ result: 123 })
end
end

test "validate_result validates result against the schema" do
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
assert_nothing_raised do
Expand Down Expand Up @@ -57,7 +84,15 @@ class OutputSchemaTest < ActiveSupport::TestCase

test "valid schema initialization" do
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h)
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
Expand Down Expand Up @@ -136,7 +171,10 @@ class OutputSchemaTest < ActiveSupport::TestCase

test "empty schema is valid" do
schema = OutputSchema.new
assert_equal({ type: "object" }, schema.to_h)
assert_equal(
{ "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" },
schema.to_h,
)
end

test "validates complex nested schemas" do
Expand Down Expand Up @@ -187,6 +225,7 @@ class OutputSchemaTest < ActiveSupport::TestCase
})
assert_equal(
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "array",
items: {
properties: { foo: { type: "string" } },
Expand Down
65 changes: 54 additions & 11 deletions test/mcp/tool_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def call(message:, server_context: nil)
title: "Mock Tool",
description: "a mock tool for testing",
icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }],
inputSchema: { type: "object" },
inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" },
}
tool = Tool.define(
name: "mock_tool",
Expand Down Expand Up @@ -109,7 +109,15 @@ class MockTool < Tool
tool = MockTool
assert_equal "my_mock_tool", tool.name_value
assert_equal "a mock tool for testing", tool.description
assert_equal({ type: "object", properties: { message: { type: "string" } }, required: ["message"] }, tool.input_schema.to_h)
assert_equal(
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
},
tool.input_schema.to_h,
)
end

test "defaults to class name as tool name" do
Expand All @@ -126,7 +134,7 @@ class NoInputSchemaTool < Tool; end

tool = NoInputSchemaTool

expected = { type: "object" }
expected = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
assert_equal expected, tool.input_schema.to_h
end

Expand All @@ -137,7 +145,12 @@ class InputSchemaTool < Tool

tool = InputSchemaTool

expected = { type: "object", properties: { message: { type: "string" } }, required: ["message"] }
expected = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
}
assert_equal expected, tool.input_schema.to_h
end

Expand Down Expand Up @@ -342,8 +355,13 @@ def call(message, server_context: nil)
title: "Mock Tool",
description: "a mock tool for testing",
icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }],
inputSchema: { type: "object" },
outputSchema: { type: "object", properties: { result: { type: "string" } }, required: ["result"] },
inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" },
outputSchema: {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: { type: "string" } },
required: ["result"],
},
}
tool = Tool.define(
name: "mock_tool",
Expand Down Expand Up @@ -376,7 +394,12 @@ class HashOutputSchemaTool < Tool
end

tool = HashOutputSchemaTool
expected = { type: "object", properties: { result: { type: "string" } }, required: ["result"] }
expected = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: { type: "string" } },
required: ["result"],
}
assert_equal expected, tool.output_schema.to_h
end

Expand All @@ -386,7 +409,12 @@ class OutputSchemaObjectTool < Tool
end

tool = OutputSchemaObjectTool
expected = { type: "object", properties: { result: { type: "string" } }, required: ["result"] }
expected = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: { type: "string" } },
required: ["result"],
}
assert_equal expected, tool.output_schema.to_h
end

Expand Down Expand Up @@ -436,7 +464,12 @@ class OutputSchemaObjectTool < Tool
assert_equal "mock_tool", tool.name_value
assert_equal "a mock tool for testing", tool.description
assert_instance_of Tool::OutputSchema, tool.output_schema
expected_output_schema = { type: "object", properties: { result: { type: "string" } }, required: ["result"] }
expected_output_schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: { type: "string" } },
required: ["result"],
}
assert_equal expected_output_schema, tool.output_schema.to_h
end

Expand All @@ -458,10 +491,20 @@ def call(message:, server_context: nil)
assert_equal "test_tool_with_output", tool.name_value
assert_equal "a test tool with output schema", tool.description

expected_input = { type: "object", properties: { message: { type: "string" } }, required: ["message"] }
expected_input = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
}
assert_equal expected_input, tool.input_schema.to_h

expected_output = { type: "object", properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] }
expected_output = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: { result: { type: "string" }, success: { type: "boolean" } },
required: ["result", "success"],
}
assert_equal expected_output, tool.output_schema.to_h
end

Expand Down