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
3 changed files with 495 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue