Merge pull request #133 from codenamev/main

Add Tool output_schema support with comprehensive validation
This commit is contained in:
Koichi ITO 2025-09-14 09:08:14 +09:00 • committed by GitHub
commit bdd69956d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 495 additions and 7 deletions

106
README.md
View file

@ -385,6 +385,14 @@ class MyTool < MCP::Tool
},
required: ["message"]
)
output_schema(
properties: {
result: { type: "string" },
success: { type: "boolean" },
timestamp: { type: "string", format: "date-time" }
},
required: ["result", "success", "timestamp"]
)
annotations(
read_only_hint: true,
destructive_hint: false,
@ -451,6 +459,102 @@ Annotations can be set either through the class definition using the `annotation
> [!NOTE]
> This **Tool Annotations** feature is supported starting from `protocol_version: '2025-03-26'`.
### 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
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.
@ -758,7 +862,7 @@ The client provides a wrapper class for tools returned by the server:
- `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

View file

@ -18,6 +18,7 @@ require_relative "mcp/server/transports/stdio_transport"
require_relative "mcp/string_utils"
require_relative "mcp/tool"
require_relative "mcp/tool/input_schema"
require_relative "mcp/tool/output_schema"
require_relative "mcp/tool/response"
require_relative "mcp/tool/annotations"
require_relative "mcp/transport"

View file

@ -3,12 +3,13 @@
module MCP
class Client
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
@description = description
@input_schema = input_schema
@output_schema = output_schema
end
end
end

View file

@ -14,14 +14,14 @@ module MCP
end
def to_h
result = {
{
name: name_value,
title: title_value,
description: description_value,
inputSchema: input_schema_value.to_h,
outputSchema: @output_schema_value&.to_h,
annotations: annotations_value&.to_h,
}.compact
result[:annotations] = annotations_value.to_h if annotations_value
result
end
def inherited(subclass)
@ -30,6 +30,7 @@ module MCP
subclass.instance_variable_set(:@title_value, nil)
subclass.instance_variable_set(:@description_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)
end
@ -49,6 +50,8 @@ module MCP
@input_schema_value || InputSchema.new
end
attr_reader :output_schema_value
def title(value = NOT_SET)
if value == NOT_SET
@title_value
@ -77,6 +80,18 @@ module MCP
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)
if hash == NOT_SET
@annotations_value
@ -85,12 +100,13 @@ module MCP
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
tool_name name
title title
description description
input_schema input_schema
output_schema output_schema
self.annotations(annotations) if annotations
define_singleton_method(:call, &block) if block
end

View 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

View file

@ -28,6 +28,38 @@ module MCP
@tool.input_schema,
)
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

View 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

View file

@ -255,5 +255,133 @@ module MCP
end
assert_match(/Invalid JSON Schema/, error.message)
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