ruby-sdk/test/mcp/tool_test.rb
Koichi ITO 1fc698bb8e Advertise JSON Schema 2020-12 dialect on emitted tool schemas
## 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.
2026-05-11 13:44:30 +09:00

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