## Motivation and Context MCP 2025-11-25 (SEP-1613) establishes JSON Schema 2020-12 as the default dialect for MCP schema definitions. https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1613 The Ruby SDK was emitting tool input and output schemas with no `$schema` field, leaving the dialect implicit and inconsistent with the TypeScript SDK (`packages/core/src/util/standardSchema.ts`, which emits with `target: 'draft-2020-12'`) and the Python SDK (which preserves `$schema` in its types and conformance fixtures). `Tool::Schema#to_h` now includes `"$schema": "https://json-schema.org/draft/2020-12/schema"` unless the user supplied a different `$schema` value, so clients see the canonical dialect URI on the wire. Runtime metaschema and argument validation remain on draft-04 because the `json-schema` gem does not yet support 2020-12. Switching the runtime validator is left as a separate change because it requires either replacing the gem or waiting for upstream 2020-12 support. ## How Has This Been Tested? Updated `Tool::Schema#to_h` expectations across `input_schema_test`, `output_schema_test`, `tool_test`, and the `tools/list` server tests to reflect the new `$schema` field. Added focused regression tests: - `to_h` preserves a user-supplied `$schema` URI rather than overwriting it (covered for both symbol-keyed input and the string-keyed Hash form that goes through the initializer's `symbolize_names` normalization) - `validate_arguments` / `validate_result` continue to work when the user supplies a 2020-12 `$schema` URI on the schema (the validator strips the unknown top-level dialect URI before invoking the draft-04 validator, instead of raising `JSON::Schema::SchemaError`) - `tools/list` JSON-RPC response includes the 2020-12 `$schema` on both `inputSchema` and `outputSchema` (transport-boundary assertion) `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes Additive change. Tolerant consumers of the `tools/list` payload are unaffected. Consumers that strictly assert hash equality on the emitted `inputSchema` / `outputSchema` need to account for the new `$schema` key, but no schema semantics change. Schemas using 2020-12-only keywords (e.g. `prefixItems`, nested `$schema` on subschemas) are still rejected by the draft-04 metaschema validator, as before. Lifting that restriction depends on adopting a 2020-12-capable validator and is out of scope for this change.
552 lines
18 KiB
Ruby
552 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class ToolTest < ActiveSupport::TestCase
|
|
class TestTool < Tool
|
|
tool_name "test_tool"
|
|
description "a test tool for testing"
|
|
icons [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")]
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
annotations(
|
|
destructive_hint: false,
|
|
idempotent_hint: true,
|
|
open_world_hint: false,
|
|
read_only_hint: true,
|
|
title: "Test Tool",
|
|
)
|
|
meta(
|
|
foo: "bar",
|
|
)
|
|
|
|
class << self
|
|
def call(message:, server_context: nil)
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
end
|
|
end
|
|
|
|
test "#to_h returns a hash including name, description, icons, and inputSchema" do
|
|
expected = {
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }],
|
|
inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" },
|
|
}
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")],
|
|
)
|
|
|
|
assert_equal(expected, tool.to_h)
|
|
end
|
|
|
|
test "#to_h does not have `:title` key when title is omitted" do
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
description: "a mock tool for testing",
|
|
)
|
|
refute tool.to_h.key?(:title)
|
|
end
|
|
|
|
test "#to_h does not have `:icons` key when icons is empty" do
|
|
tool = Tool.define(
|
|
name: "tool_without_icons",
|
|
description: "a tool without icons",
|
|
)
|
|
|
|
refute tool.to_h.key?(:icons)
|
|
end
|
|
|
|
test "#to_h does not have `:icons` key when icons is nil" do
|
|
tool = Tool.define(
|
|
name: "tool_without_icons",
|
|
description: "a tool without icons",
|
|
icons: nil,
|
|
)
|
|
|
|
refute tool.to_h.key?(:icons)
|
|
end
|
|
|
|
test "#to_h includes annotations when present" do
|
|
tool = TestTool
|
|
expected_annotations = {
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: false,
|
|
readOnlyHint: true,
|
|
title: "Test Tool",
|
|
}
|
|
assert_equal expected_annotations, tool.to_h[:annotations]
|
|
end
|
|
|
|
test "#to_h includes meta when present" do
|
|
tool = TestTool
|
|
expected_meta = {
|
|
foo: "bar",
|
|
}
|
|
assert_equal expected_meta, tool.to_h[:_meta]
|
|
end
|
|
|
|
test "#call invokes the tool block and returns the response" do
|
|
tool = TestTool
|
|
response = tool.call(message: "test")
|
|
assert_equal [{ type: "text", content: "OK" }], response.content
|
|
refute response.error?
|
|
end
|
|
|
|
test "allows declarative definition of tools as classes" do
|
|
class MockTool < Tool
|
|
tool_name "my_mock_tool"
|
|
description "a mock tool for testing"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
end
|
|
|
|
tool = MockTool
|
|
assert_equal "my_mock_tool", tool.name_value
|
|
assert_equal "a mock tool for testing", tool.description
|
|
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
|
|
class DefaultNameTool < Tool
|
|
end
|
|
|
|
tool = DefaultNameTool
|
|
|
|
assert_equal "default_name_tool", tool.tool_name
|
|
end
|
|
|
|
test "input schema defaults to an empty hash" do
|
|
class NoInputSchemaTool < Tool; end
|
|
|
|
tool = NoInputSchemaTool
|
|
|
|
expected = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
|
|
assert_equal expected, tool.input_schema.to_h
|
|
end
|
|
|
|
test "accepts input schema as an InputSchema object" do
|
|
class InputSchemaTool < Tool
|
|
input_schema InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
|
end
|
|
|
|
tool = InputSchemaTool
|
|
|
|
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
|
|
|
|
test "raises detailed error message for invalid schema" do
|
|
error = assert_raises(ArgumentError) do
|
|
Class.new(MCP::Tool) do
|
|
input_schema(
|
|
properties: {
|
|
count: { type: "integer", minimum: "not a number" },
|
|
},
|
|
required: ["count"],
|
|
)
|
|
end
|
|
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"
|
|
end
|
|
|
|
test ".define allows definition of simple tools with a block" do
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
) do
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
|
|
assert_equal "mock_tool", tool.name_value
|
|
assert_equal "a mock tool for testing", tool.description
|
|
assert_equal Tool::InputSchema.new, tool.input_schema
|
|
end
|
|
|
|
test ".define allows definition of tools with annotations" do
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
annotations: {
|
|
read_only_hint: true,
|
|
title: "Mock Tool",
|
|
},
|
|
) do
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
|
|
assert_equal "mock_tool", tool.name_value
|
|
assert_equal "Mock Tool", tool.title
|
|
assert_equal "a mock tool for testing", tool.description
|
|
assert_equal tool.input_schema, Tool::InputSchema.new
|
|
assert_equal({ destructiveHint: true, idempotentHint: false, openWorldHint: true, readOnlyHint: true, title: "Mock Tool" }, tool.annotations_value.to_h)
|
|
end
|
|
|
|
test ".define allows definition of tools with meta" do
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
meta: { foo: "bar" },
|
|
) do |_|
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
|
|
assert_equal "mock_tool", tool.name_value
|
|
assert_equal "Mock Tool", tool.title
|
|
assert_equal "a mock tool for testing", tool.description
|
|
assert_equal tool.input_schema, Tool::InputSchema.new
|
|
assert_equal({ foo: "bar" }, tool.meta_value)
|
|
end
|
|
|
|
test "Tool class method annotations can be set and retrieved" do
|
|
class AnnotationsTestTool < Tool
|
|
tool_name "annotations_test"
|
|
annotations(
|
|
read_only_hint: true,
|
|
title: "Annotations Test",
|
|
)
|
|
end
|
|
|
|
tool = AnnotationsTestTool
|
|
assert_instance_of Tool::Annotations, tool.annotations_value
|
|
assert_equal "Annotations Test", tool.annotations_value.title
|
|
assert tool.annotations_value.read_only_hint
|
|
end
|
|
|
|
test "Tool class method annotations can be updated" do
|
|
class UpdatableAnnotationsTool < Tool
|
|
tool_name "updatable_annotations"
|
|
end
|
|
|
|
tool = UpdatableAnnotationsTool
|
|
tool.annotations(title: "Initial")
|
|
assert_equal "Initial", tool.annotations_value.title
|
|
|
|
tool.annotations(title: "Updated")
|
|
assert_equal "Updated", tool.annotations_value.title
|
|
end
|
|
|
|
test "Tool class method meta can be set and retrieved" do
|
|
class MetaTestTool < Tool
|
|
tool_name "meta_test"
|
|
meta(foo: "bar")
|
|
end
|
|
|
|
tool = MetaTestTool
|
|
assert_instance_of Hash, tool.meta_value
|
|
assert_equal "bar", tool.meta_value[:foo]
|
|
end
|
|
|
|
test "Tool class method meta can be updated" do
|
|
class UpdatableMetaTool < Tool
|
|
tool_name "updatable_meta"
|
|
end
|
|
|
|
tool = UpdatableMetaTool
|
|
tool.meta(foo: "baz")
|
|
assert_equal({ foo: "baz" }, tool.meta_value)
|
|
|
|
tool.meta(foo: "qux")
|
|
assert_equal({ foo: "qux" }, tool.meta_value)
|
|
end
|
|
|
|
test "#call with Sorbet typed tools invokes the tool block and returns the response" do
|
|
skip "sorbet-static-and-runtime requires Ruby 3.0+." if RUBY_VERSION < "3.0"
|
|
|
|
class TypedTestTool < Tool
|
|
tool_name "test_tool"
|
|
description "a test tool for testing"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
extend T::Sig
|
|
|
|
sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) }
|
|
def call(message:, server_context: nil)
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
end
|
|
end
|
|
|
|
tool = TypedTestTool
|
|
response = tool.call(message: "test")
|
|
assert_equal [{ type: "text", content: "OK" }], response.content
|
|
refute response.error?
|
|
end
|
|
|
|
class TestToolWithoutServerContext < Tool
|
|
tool_name "test_tool_without_server_context"
|
|
description "a test tool for testing without server context"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
def call(message:)
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
end
|
|
end
|
|
|
|
class TestToolWithoutRequired < Tool
|
|
tool_name "test_tool_without_required"
|
|
description "a test tool for testing without required server context"
|
|
|
|
class << self
|
|
def call(message, server_context: nil)
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
end
|
|
end
|
|
|
|
test "tool call without server context" do
|
|
tool = TestToolWithoutServerContext
|
|
response = tool.call(message: "test")
|
|
assert_equal [{ type: "text", content: "OK" }], response.content
|
|
end
|
|
|
|
test "tool call with server context and without required" do
|
|
tool = TestToolWithoutRequired
|
|
response = tool.call("test", server_context: { foo: "bar" })
|
|
assert_equal [{ type: "text", content: "OK" }], response.content
|
|
end
|
|
|
|
test "input_schema accepts $ref in schema" do
|
|
schema_with_ref = {
|
|
properties: {
|
|
foo: { "$ref" => "#/definitions/bar" },
|
|
},
|
|
required: ["foo"],
|
|
definitions: {
|
|
bar: { type: "string" },
|
|
},
|
|
}
|
|
tool_class = Class.new(MCP::Tool) do
|
|
input_schema schema_with_ref
|
|
end
|
|
assert_equal "#/definitions/bar", tool_class.input_schema.to_h[:properties][:foo][:$ref]
|
|
end
|
|
|
|
test "#to_h includes outputSchema when present" do
|
|
expected = {
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }],
|
|
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",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")],
|
|
output_schema: { properties: { result: { type: "string" } }, required: ["result"] },
|
|
)
|
|
|
|
assert_equal expected, tool.to_h
|
|
end
|
|
|
|
test "#to_h does not include outputSchema when not set" do
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
description: "a mock tool for testing",
|
|
)
|
|
refute tool.to_h.key?(:outputSchema)
|
|
end
|
|
|
|
test "output_schema defaults to nil" do
|
|
class NoOutputSchemaTool < Tool; end
|
|
tool = NoOutputSchemaTool
|
|
assert_nil tool.output_schema
|
|
end
|
|
|
|
test "accepts output_schema as a hash" do
|
|
class HashOutputSchemaTool < Tool
|
|
output_schema({ properties: { result: { type: "string" } }, required: ["result"] })
|
|
end
|
|
|
|
tool = HashOutputSchemaTool
|
|
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
|
|
|
|
test "accepts output_schema as an OutputSchema object" do
|
|
class OutputSchemaObjectTool < Tool
|
|
output_schema Tool::OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
|
end
|
|
|
|
tool = OutputSchemaObjectTool
|
|
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
|
|
|
|
test "output_schema raises detailed error message for invalid schema" do
|
|
error = assert_raises(ArgumentError) do
|
|
Class.new(MCP::Tool) do
|
|
output_schema(
|
|
properties: {
|
|
count: { type: "integer", minimum: "not a number" },
|
|
},
|
|
required: ["count"],
|
|
)
|
|
end
|
|
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"
|
|
end
|
|
|
|
test "output_schema accepts $ref in schema" do
|
|
schema_with_ref = {
|
|
properties: {
|
|
foo: { "$ref" => "#/definitions/bar" },
|
|
},
|
|
required: ["foo"],
|
|
definitions: {
|
|
bar: { type: "string" },
|
|
},
|
|
}
|
|
tool_class = Class.new(MCP::Tool) do
|
|
output_schema schema_with_ref
|
|
end
|
|
assert_equal "#/definitions/bar", tool_class.output_schema.to_h[:properties][:foo][:$ref]
|
|
end
|
|
|
|
test ".define allows definition of tools with output_schema" do
|
|
tool = Tool.define(
|
|
name: "mock_tool",
|
|
title: "Mock Tool",
|
|
description: "a mock tool for testing",
|
|
output_schema: { properties: { result: { type: "string" } }, required: ["result"] },
|
|
) do
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
|
|
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 = {
|
|
"$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
|
|
|
|
class TestToolWithOutputSchema < Tool
|
|
tool_name "test_tool_with_output"
|
|
description "a test tool with output schema"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
output_schema({ properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] })
|
|
|
|
class << self
|
|
def call(message:, server_context: nil)
|
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
|
end
|
|
end
|
|
end
|
|
|
|
test "declarative definition of tools with output schema" do
|
|
tool = TestToolWithOutputSchema
|
|
assert_equal "test_tool_with_output", tool.name_value
|
|
assert_equal "a test tool with output schema", tool.description
|
|
|
|
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 = {
|
|
"$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
|
|
|
|
test "accepts valid tool names" do
|
|
assert Tool.define(name: "getUser")
|
|
assert Tool.define(name: "DATA_EXPORT_v2")
|
|
assert Tool.define(name: "admin.tools.list")
|
|
assert Tool.define(name: "a" * 128)
|
|
end
|
|
|
|
test "raises an error when tool name is empty in class definition" do
|
|
error = assert_raises(ArgumentError) do
|
|
class EmptyTitleNameTool < Tool
|
|
tool_name ""
|
|
end
|
|
end
|
|
assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message)
|
|
end
|
|
|
|
test "allows nil tool name in class definition" do
|
|
assert_nothing_raised do
|
|
class EmptyTitleNameTool < Tool
|
|
tool_name nil
|
|
end
|
|
end
|
|
end
|
|
|
|
test "raises an error when tool name is empty" do
|
|
error = assert_raises(ArgumentError) { Tool.define(name: "") }
|
|
assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message)
|
|
end
|
|
|
|
test "raises an error when tool name exceeds 128 characters" do
|
|
error = assert_raises(ArgumentError) { Tool.define(name: "a" * 129) }
|
|
assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message)
|
|
end
|
|
|
|
test "raises an error when tool name includes invalid characters (e.g., spaces)" do
|
|
error = assert_raises(ArgumentError) { Tool.define(name: "foo bar") }
|
|
assert_equal(<<~MESSAGE, error.message)
|
|
Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.).
|
|
MESSAGE
|
|
end
|
|
end
|
|
end
|