Add Tool output_schema support with comprehensive validation
This commit implements optional JSON Schema validation for tool outputs according to the MCP specification: - Add MCP::Tool::OutputSchema class mirroring InputSchema functionality - Support output_schema in class definitions, Tool.define, and OutputSchema objects - Include validate_result method for runtime validation - Update MCP::Client::Tool to track output schemas - Add comprehensive test coverage with 249 passing tests - Update README with dedicated Tool Output Schemas section - Follow MCP spec requirements for server/client validation
This commit is contained in:
parent
3f2a4b3b21
commit
aad8e94530
8 changed files with 495 additions and 7 deletions
106
README.md
106
README.md
|
|
@ -385,6 +385,14 @@ class MyTool < MCP::Tool
|
||||||
},
|
},
|
||||||
required: ["message"]
|
required: ["message"]
|
||||||
)
|
)
|
||||||
|
output_schema(
|
||||||
|
properties: {
|
||||||
|
result: { type: "string" },
|
||||||
|
success: { type: "boolean" },
|
||||||
|
timestamp: { type: "string", format: "date-time" }
|
||||||
|
},
|
||||||
|
required: ["result", "success", "timestamp"]
|
||||||
|
)
|
||||||
annotations(
|
annotations(
|
||||||
read_only_hint: true,
|
read_only_hint: true,
|
||||||
destructive_hint: false,
|
destructive_hint: false,
|
||||||
|
|
@ -448,6 +456,102 @@ Tools can include annotations that provide additional metadata about their behav
|
||||||
|
|
||||||
Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method.
|
Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method.
|
||||||
|
|
||||||
|
### Tool Output Schemas
|
||||||
|
|
||||||
|
Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways:
|
||||||
|
|
||||||
|
1. **Class definition with output_schema:**
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
class WeatherTool < MCP::Tool
|
||||||
|
tool_name "get_weather"
|
||||||
|
description "Get current weather for a location"
|
||||||
|
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
location: { type: "string" },
|
||||||
|
units: { type: "string", enum: ["celsius", "fahrenheit"] }
|
||||||
|
},
|
||||||
|
required: ["location"]
|
||||||
|
)
|
||||||
|
|
||||||
|
output_schema(
|
||||||
|
properties: {
|
||||||
|
temperature: { type: "number" },
|
||||||
|
condition: { type: "string" },
|
||||||
|
humidity: { type: "integer" }
|
||||||
|
},
|
||||||
|
required: ["temperature", "condition", "humidity"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def self.call(location:, units: "celsius", server_context:)
|
||||||
|
# Call weather API and structure the response
|
||||||
|
api_response = WeatherAPI.fetch(location, units)
|
||||||
|
weather_data = {
|
||||||
|
temperature: api_response.temp,
|
||||||
|
condition: api_response.description,
|
||||||
|
humidity: api_response.humidity_percent
|
||||||
|
}
|
||||||
|
|
||||||
|
output_schema.validate_result(weather_data)
|
||||||
|
|
||||||
|
MCP::Tool::Response.new([{
|
||||||
|
type: "text",
|
||||||
|
text: weather_data.to_json
|
||||||
|
}])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Using Tool.define with output_schema:**
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
tool = MCP::Tool.define(
|
||||||
|
name: "calculate_stats",
|
||||||
|
description: "Calculate statistics for a dataset",
|
||||||
|
input_schema: {
|
||||||
|
properties: {
|
||||||
|
numbers: { type: "array", items: { type: "number" } }
|
||||||
|
},
|
||||||
|
required: ["numbers"]
|
||||||
|
},
|
||||||
|
output_schema: {
|
||||||
|
properties: {
|
||||||
|
mean: { type: "number" },
|
||||||
|
median: { type: "number" },
|
||||||
|
count: { type: "integer" }
|
||||||
|
},
|
||||||
|
required: ["mean", "median", "count"]
|
||||||
|
}
|
||||||
|
) do |args, server_context|
|
||||||
|
# Calculate statistics and validate against schema
|
||||||
|
MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }])
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Using OutputSchema objects:**
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
class DataTool < MCP::Tool
|
||||||
|
output_schema MCP::Tool::OutputSchema.new(
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
data: { type: "object" }
|
||||||
|
},
|
||||||
|
required: ["success"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) specifies that:
|
||||||
|
|
||||||
|
- **Server Validation**: Servers MUST provide structured results that conform to the output schema
|
||||||
|
- **Client Validation**: Clients SHOULD validate structured results against the output schema
|
||||||
|
- **Better Integration**: Enables strict schema validation, type information, and improved developer experience
|
||||||
|
- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block
|
||||||
|
|
||||||
|
The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
|
||||||
|
|
||||||
### Prompts
|
### Prompts
|
||||||
|
|
||||||
MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs.
|
MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs.
|
||||||
|
|
@ -755,7 +859,7 @@ The client provides a wrapper class for tools returned by the server:
|
||||||
|
|
||||||
- `MCP::Client::Tool` - Represents a single tool with its metadata
|
- `MCP::Client::Tool` - Represents a single tool with its metadata
|
||||||
|
|
||||||
This class provide easy access to tool properties like name, description, and input schema.
|
This class provides easy access to tool properties like name, description, input schema, and output schema.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ require_relative "mcp/server/transports/stdio_transport"
|
||||||
require_relative "mcp/string_utils"
|
require_relative "mcp/string_utils"
|
||||||
require_relative "mcp/tool"
|
require_relative "mcp/tool"
|
||||||
require_relative "mcp/tool/input_schema"
|
require_relative "mcp/tool/input_schema"
|
||||||
|
require_relative "mcp/tool/output_schema"
|
||||||
require_relative "mcp/tool/response"
|
require_relative "mcp/tool/response"
|
||||||
require_relative "mcp/tool/annotations"
|
require_relative "mcp/tool/annotations"
|
||||||
require_relative "mcp/transport"
|
require_relative "mcp/transport"
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@
|
||||||
module MCP
|
module MCP
|
||||||
class Client
|
class Client
|
||||||
class Tool
|
class Tool
|
||||||
attr_reader :name, :description, :input_schema
|
attr_reader :name, :description, :input_schema, :output_schema
|
||||||
|
|
||||||
def initialize(name:, description:, input_schema:)
|
def initialize(name:, description:, input_schema:, output_schema: nil)
|
||||||
@name = name
|
@name = name
|
||||||
@description = description
|
@description = description
|
||||||
@input_schema = input_schema
|
@input_schema = input_schema
|
||||||
|
@output_schema = output_schema
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,14 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_h
|
def to_h
|
||||||
result = {
|
{
|
||||||
name: name_value,
|
name: name_value,
|
||||||
title: title_value,
|
title: title_value,
|
||||||
description: description_value,
|
description: description_value,
|
||||||
inputSchema: input_schema_value.to_h,
|
inputSchema: input_schema_value.to_h,
|
||||||
|
outputSchema: @output_schema_value&.to_h,
|
||||||
|
annotations: annotations_value&.to_h,
|
||||||
}.compact
|
}.compact
|
||||||
result[:annotations] = annotations_value.to_h if annotations_value
|
|
||||||
result
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def inherited(subclass)
|
def inherited(subclass)
|
||||||
|
|
@ -30,6 +30,7 @@ module MCP
|
||||||
subclass.instance_variable_set(:@title_value, nil)
|
subclass.instance_variable_set(:@title_value, nil)
|
||||||
subclass.instance_variable_set(:@description_value, nil)
|
subclass.instance_variable_set(:@description_value, nil)
|
||||||
subclass.instance_variable_set(:@input_schema_value, nil)
|
subclass.instance_variable_set(:@input_schema_value, nil)
|
||||||
|
subclass.instance_variable_set(:@output_schema_value, nil)
|
||||||
subclass.instance_variable_set(:@annotations_value, nil)
|
subclass.instance_variable_set(:@annotations_value, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -49,6 +50,8 @@ module MCP
|
||||||
@input_schema_value || InputSchema.new
|
@input_schema_value || InputSchema.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr_reader :output_schema_value
|
||||||
|
|
||||||
def title(value = NOT_SET)
|
def title(value = NOT_SET)
|
||||||
if value == NOT_SET
|
if value == NOT_SET
|
||||||
@title_value
|
@title_value
|
||||||
|
|
@ -77,6 +80,18 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def output_schema(value = NOT_SET)
|
||||||
|
if value == NOT_SET
|
||||||
|
output_schema_value
|
||||||
|
elsif value.is_a?(Hash)
|
||||||
|
properties = value[:properties] || value["properties"] || {}
|
||||||
|
required = value[:required] || value["required"] || []
|
||||||
|
@output_schema_value = OutputSchema.new(properties:, required:)
|
||||||
|
elsif value.is_a?(OutputSchema)
|
||||||
|
@output_schema_value = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def annotations(hash = NOT_SET)
|
def annotations(hash = NOT_SET)
|
||||||
if hash == NOT_SET
|
if hash == NOT_SET
|
||||||
@annotations_value
|
@annotations_value
|
||||||
|
|
@ -85,12 +100,13 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def define(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, &block)
|
def define(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, &block)
|
||||||
Class.new(self) do
|
Class.new(self) do
|
||||||
tool_name name
|
tool_name name
|
||||||
title title
|
title title
|
||||||
description description
|
description description
|
||||||
input_schema input_schema
|
input_schema input_schema
|
||||||
|
output_schema output_schema
|
||||||
self.annotations(annotations) if annotations
|
self.annotations(annotations) if annotations
|
||||||
define_singleton_method(:call, &block) if block
|
define_singleton_method(:call, &block) if block
|
||||||
end
|
end
|
||||||
|
|
|
||||||
66
lib/mcp/tool/output_schema.rb
Normal file
66
lib/mcp/tool/output_schema.rb
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "json-schema"
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
class Tool
|
||||||
|
class OutputSchema
|
||||||
|
class ValidationError < StandardError; end
|
||||||
|
|
||||||
|
attr_reader :properties, :required
|
||||||
|
|
||||||
|
def initialize(properties: {}, required: [])
|
||||||
|
@properties = properties
|
||||||
|
@required = required.map(&:to_sym)
|
||||||
|
validate_schema!
|
||||||
|
end
|
||||||
|
|
||||||
|
def ==(other)
|
||||||
|
other.is_a?(OutputSchema) && properties == other.properties && required == other.required
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{ type: "object" }.tap do |hsh|
|
||||||
|
hsh[:properties] = properties if properties.any?
|
||||||
|
hsh[:required] = required if required.any?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_result(result)
|
||||||
|
errors = JSON::Validator.fully_validate(to_h, result)
|
||||||
|
if errors.any?
|
||||||
|
raise ValidationError, "Invalid result: #{errors.join(", ")}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_schema!
|
||||||
|
check_for_refs!
|
||||||
|
schema = to_h
|
||||||
|
schema_reader = JSON::Schema::Reader.new(
|
||||||
|
accept_uri: false,
|
||||||
|
accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
|
||||||
|
)
|
||||||
|
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
|
||||||
|
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
||||||
|
if errors.any?
|
||||||
|
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_for_refs!(obj = properties)
|
||||||
|
case obj
|
||||||
|
when Hash
|
||||||
|
if obj.key?("$ref") || obj.key?(:$ref)
|
||||||
|
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas"
|
||||||
|
end
|
||||||
|
|
||||||
|
obj.each_value { |value| check_for_refs!(value) }
|
||||||
|
when Array
|
||||||
|
obj.each { |item| check_for_refs!(item) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -28,6 +28,38 @@ module MCP
|
||||||
@tool.input_schema,
|
@tool.input_schema,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_output_schema_returns_nil_when_not_provided
|
||||||
|
assert_nil(@tool.output_schema)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_output_schema_returns_output_schema_when_provided
|
||||||
|
tool_with_output = Tool.new(
|
||||||
|
name: "test_tool_with_output",
|
||||||
|
description: "A test tool with output schema",
|
||||||
|
input_schema: { "type" => "object", "properties" => { "foo" => { "type" => "string" } } },
|
||||||
|
output_schema: { "type" => "object", "properties" => { "result" => { "type" => "string" } } },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal(
|
||||||
|
{ "type" => "object", "properties" => { "result" => { "type" => "string" } } },
|
||||||
|
tool_with_output.output_schema,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialization_with_all_parameters
|
||||||
|
tool = Tool.new(
|
||||||
|
name: "full_tool",
|
||||||
|
description: "A tool with all parameters",
|
||||||
|
input_schema: { "type" => "object" },
|
||||||
|
output_schema: { "type" => "object", "properties" => { "status" => { "type" => "boolean" } } },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal("full_tool", tool.name)
|
||||||
|
assert_equal("A tool with all parameters", tool.description)
|
||||||
|
assert_equal({ "type" => "object" }, tool.input_schema)
|
||||||
|
assert_equal({ "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, tool.output_schema)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
140
test/mcp/tool/output_schema_test.rb
Normal file
140
test/mcp/tool/output_schema_test.rb
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
class Tool
|
||||||
|
class OutputSchemaTest < ActiveSupport::TestCase
|
||||||
|
test "required arguments are converted to symbols" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
|
assert_equal [:result], output_schema.required
|
||||||
|
end
|
||||||
|
|
||||||
|
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] },
|
||||||
|
output_schema.to_h,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate_result validates result against the schema" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
||||||
|
assert_nothing_raised do
|
||||||
|
output_schema.validate_result({ result: "success" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate_result raises error for invalid result" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
||||||
|
assert_raises(OutputSchema::ValidationError) do
|
||||||
|
output_schema.validate_result({ result: 123 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate_result raises error for missing required field" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
||||||
|
assert_raises(OutputSchema::ValidationError) do
|
||||||
|
output_schema.validate_result({})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid schema raises argument error" do
|
||||||
|
assert_raises(ArgumentError) do
|
||||||
|
OutputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "schema without required arguments is valid" do
|
||||||
|
assert_nothing_raised do
|
||||||
|
OutputSchema.new(properties: { foo: { type: "string" } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unexpected errors bubble up from validate_result" do
|
||||||
|
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
||||||
|
|
||||||
|
JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
|
||||||
|
assert_raises(RuntimeError) do
|
||||||
|
schema.validate_result({ foo: "bar" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects schemas with $ref references" do
|
||||||
|
assert_raises(ArgumentError) do
|
||||||
|
OutputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects schemas with symbol $ref references" do
|
||||||
|
assert_raises(ArgumentError) do
|
||||||
|
OutputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "== compares two output schemas with the same properties and required fields" do
|
||||||
|
schema1 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
||||||
|
schema2 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
||||||
|
assert_equal schema1, schema2
|
||||||
|
|
||||||
|
schema3 = OutputSchema.new(properties: { bar: { type: "string" } }, required: [:bar])
|
||||||
|
refute_equal schema1, schema3
|
||||||
|
|
||||||
|
schema4 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:bar])
|
||||||
|
refute_equal schema1, schema4
|
||||||
|
|
||||||
|
schema5 = OutputSchema.new(properties: { bar: { type: "string" } }, required: [:foo])
|
||||||
|
refute_equal schema1, schema5
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty schema is valid" do
|
||||||
|
schema = OutputSchema.new
|
||||||
|
assert_equal({ type: "object" }, schema.to_h)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates complex nested schemas" do
|
||||||
|
schema = OutputSchema.new(
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
items: { type: "array", items: { type: "string" } },
|
||||||
|
count: { type: "integer", minimum: 0 },
|
||||||
|
},
|
||||||
|
required: ["items"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: [:data],
|
||||||
|
)
|
||||||
|
|
||||||
|
valid_result = {
|
||||||
|
data: {
|
||||||
|
items: ["item1", "item2"],
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_nothing_raised do
|
||||||
|
schema.validate_result(valid_result)
|
||||||
|
end
|
||||||
|
|
||||||
|
invalid_result = {
|
||||||
|
data: {
|
||||||
|
items: [123, 456], # Should be strings
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_raises(OutputSchema::ValidationError) do
|
||||||
|
schema.validate_result(invalid_result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -255,5 +255,133 @@ module MCP
|
||||||
end
|
end
|
||||||
assert_match(/Invalid JSON Schema/, error.message)
|
assert_match(/Invalid JSON Schema/, error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#to_h includes outputSchema when present" do
|
||||||
|
tool = Tool.define(
|
||||||
|
name: "mock_tool",
|
||||||
|
title: "Mock Tool",
|
||||||
|
description: "a mock tool for testing",
|
||||||
|
output_schema: { properties: { result: { type: "string" } }, required: ["result"] },
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
name: "mock_tool",
|
||||||
|
title: "Mock Tool",
|
||||||
|
description: "a mock tool for testing",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
outputSchema: { type: "object", 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 = { 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 = { 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 rejects any $ref in schema" do
|
||||||
|
schema_with_ref = {
|
||||||
|
properties: {
|
||||||
|
foo: { "$ref" => "#/definitions/bar" },
|
||||||
|
},
|
||||||
|
required: ["foo"],
|
||||||
|
definitions: {
|
||||||
|
bar: { type: "string" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
error = assert_raises(ArgumentError) do
|
||||||
|
Class.new(MCP::Tool) do
|
||||||
|
output_schema schema_with_ref
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert_match(/Invalid JSON Schema/, error.message)
|
||||||
|
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 = { 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 = { 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] }
|
||||||
|
assert_equal expected_output, tool.output_schema.to_h
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue