Merge pull request #198 from ebeigarts/feature/schema-additional-properties
Refactor schema validation to reduce duplication and add additionalProperties support
This commit is contained in:
commit
4eab486d56
8 changed files with 240 additions and 162 deletions
|
|
@ -75,9 +75,7 @@ module MCP
|
||||||
if value == NOT_SET
|
if value == NOT_SET
|
||||||
input_schema_value
|
input_schema_value
|
||||||
elsif value.is_a?(Hash)
|
elsif value.is_a?(Hash)
|
||||||
properties = value[:properties] || value["properties"] || {}
|
@input_schema_value = InputSchema.new(value)
|
||||||
required = value[:required] || value["required"] || []
|
|
||||||
@input_schema_value = InputSchema.new(properties:, required:)
|
|
||||||
elsif value.is_a?(InputSchema)
|
elsif value.is_a?(InputSchema)
|
||||||
@input_schema_value = value
|
@input_schema_value = value
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,28 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "json-schema"
|
require_relative "schema"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
class Tool
|
class Tool
|
||||||
class InputSchema
|
class InputSchema < Schema
|
||||||
class ValidationError < StandardError; end
|
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?(InputSchema) && 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 missing_required_arguments?(arguments)
|
def missing_required_arguments?(arguments)
|
||||||
missing_required_arguments(arguments).any?
|
missing_required_arguments(arguments).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
def missing_required_arguments(arguments)
|
def missing_required_arguments(arguments)
|
||||||
(required - arguments.keys.map(&:to_sym))
|
return [] unless schema[:required].is_a?(Array)
|
||||||
|
|
||||||
|
(schema[:required] - arguments.keys.map(&:to_s))
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_arguments(arguments)
|
def validate_arguments(arguments)
|
||||||
errors = JSON::Validator.fully_validate(to_h, arguments)
|
errors = fully_validate(arguments)
|
||||||
if errors.any?
|
if errors.any?
|
||||||
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
|
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def validate_schema!
|
|
||||||
check_for_refs!
|
|
||||||
schema = to_h
|
|
||||||
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
|
||||||
schema_reader = JSON::Schema::Reader.new(
|
|
||||||
accept_uri: false,
|
|
||||||
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
|
||||||
)
|
|
||||||
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
|
||||||
# Converts metaschema to a file URI for cross-platform compatibility
|
|
||||||
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
|
||||||
metaschema = metaschema_uri.to_s
|
|
||||||
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 input schemas"
|
|
||||||
end
|
|
||||||
|
|
||||||
obj.each_value { |value| check_for_refs!(value) }
|
|
||||||
when Array
|
|
||||||
obj.each { |item| check_for_refs!(item) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,18 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "json-schema"
|
require_relative "schema"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
class Tool
|
class Tool
|
||||||
class OutputSchema
|
class OutputSchema < Schema
|
||||||
class ValidationError < StandardError; end
|
class ValidationError < StandardError; end
|
||||||
|
|
||||||
attr_reader :schema
|
|
||||||
|
|
||||||
def initialize(schema = {})
|
|
||||||
@schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
|
|
||||||
@schema[:type] ||= "object"
|
|
||||||
validate_schema!
|
|
||||||
end
|
|
||||||
|
|
||||||
def ==(other)
|
|
||||||
other.is_a?(OutputSchema) && schema == other.schema
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_h
|
|
||||||
@schema
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_result(result)
|
def validate_result(result)
|
||||||
errors = JSON::Validator.fully_validate(to_h, result)
|
errors = fully_validate(result)
|
||||||
if errors.any?
|
if errors.any?
|
||||||
raise ValidationError, "Invalid result: #{errors.join(", ")}"
|
raise ValidationError, "Invalid result: #{errors.join(", ")}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def deep_transform_keys(schema, &block)
|
|
||||||
case schema
|
|
||||||
when Hash
|
|
||||||
schema.each_with_object({}) do |(key, value), result|
|
|
||||||
if key.casecmp?("$ref")
|
|
||||||
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas"
|
|
||||||
end
|
|
||||||
|
|
||||||
result[yield(key)] = deep_transform_keys(value, &block)
|
|
||||||
end
|
|
||||||
when Array
|
|
||||||
schema.map { |e| deep_transform_keys(e, &block) }
|
|
||||||
else
|
|
||||||
schema
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_schema!
|
|
||||||
schema = to_h
|
|
||||||
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
|
||||||
schema_reader = JSON::Schema::Reader.new(
|
|
||||||
accept_uri: false,
|
|
||||||
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
|
||||||
)
|
|
||||||
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
|
||||||
# Converts metaschema to a file URI for cross-platform compatibility
|
|
||||||
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
|
||||||
metaschema = metaschema_uri.to_s
|
|
||||||
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
|
||||||
if errors.any?
|
|
||||||
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
65
lib/mcp/tool/schema.rb
Normal file
65
lib/mcp/tool/schema.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "json-schema"
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
class Tool
|
||||||
|
class Schema
|
||||||
|
attr_reader :schema
|
||||||
|
|
||||||
|
def initialize(schema = {})
|
||||||
|
@schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
|
||||||
|
@schema[:type] ||= "object"
|
||||||
|
validate_schema!
|
||||||
|
end
|
||||||
|
|
||||||
|
def ==(other)
|
||||||
|
other.is_a?(self.class) && schema == other.schema
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
@schema
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fully_validate(data)
|
||||||
|
JSON::Validator.fully_validate(to_h, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_transform_keys(schema, &block)
|
||||||
|
case schema
|
||||||
|
when Hash
|
||||||
|
schema.each_with_object({}) do |(key, value), result|
|
||||||
|
if key.casecmp?("$ref")
|
||||||
|
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool schemas"
|
||||||
|
end
|
||||||
|
|
||||||
|
result[yield(key)] = deep_transform_keys(value, &block)
|
||||||
|
end
|
||||||
|
when Array
|
||||||
|
schema.map { |e| deep_transform_keys(e, &block) }
|
||||||
|
else
|
||||||
|
schema
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_schema!
|
||||||
|
schema = to_h
|
||||||
|
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
||||||
|
schema_reader = JSON::Schema::Reader.new(
|
||||||
|
accept_uri: false,
|
||||||
|
accept_file: ->(path) { File.realpath(path.to_s).start_with?(gem_path) },
|
||||||
|
)
|
||||||
|
metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
|
||||||
|
# Converts metaschema to a file URI for cross-platform compatibility
|
||||||
|
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
||||||
|
metaschema = metaschema_uri.to_s
|
||||||
|
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
||||||
|
if errors.any?
|
||||||
|
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1096,6 +1096,62 @@ module MCP
|
||||||
assert_equal "OK", response[:result][:content][0][:content]
|
assert_equal "OK", response[:result][:content][0][:content]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "tools/call allows additional properties by default" do
|
||||||
|
server = Server.new(
|
||||||
|
tools: [TestTool],
|
||||||
|
configuration: Configuration.new(validate_tool_call_arguments: true),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = server.handle(
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "test_tool",
|
||||||
|
arguments: {
|
||||||
|
message: "Hello, world!",
|
||||||
|
other_property: "I am allowed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "2.0", response[:jsonrpc]
|
||||||
|
assert_equal 1, response[:id]
|
||||||
|
assert response[:result], "Expected result key in response"
|
||||||
|
assert_equal "text", response[:result][:content][0][:type]
|
||||||
|
assert_equal "OK", response[:result][:content][0][:content]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tools/call disallows additional properties when additionalProperties set to false" do
|
||||||
|
server = Server.new(
|
||||||
|
tools: [TestToolWithAdditionalPropertiesSetToFalse],
|
||||||
|
configuration: Configuration.new(validate_tool_call_arguments: true),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = server.handle(
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "test_tool_with_additional_properties_set_to_false",
|
||||||
|
arguments: {
|
||||||
|
message: "Hello, world!",
|
||||||
|
extra: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "2.0", response[:jsonrpc]
|
||||||
|
assert_equal 1, response[:id]
|
||||||
|
assert_nil response[:error], "Expected no JSON-RPC error"
|
||||||
|
assert response[:result][:isError]
|
||||||
|
assert_includes response[:result][:content][0][:text], "Invalid arguments"
|
||||||
|
end
|
||||||
|
|
||||||
test "tools/call with no args" do
|
test "tools/call with no args" do
|
||||||
server = Server.new(tools: [@tool_with_no_args])
|
server = Server.new(tools: [@tool_with_no_args])
|
||||||
|
|
||||||
|
|
@ -1123,7 +1179,19 @@ module MCP
|
||||||
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def call(message:, server_context: nil)
|
def call(server_context: nil, **kwargs)
|
||||||
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestToolWithAdditionalPropertiesSetToFalse < Tool
|
||||||
|
tool_name "test_tool_with_additional_properties_set_to_false"
|
||||||
|
description "a test tool with additionalProperties set to false for testing"
|
||||||
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false })
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context: nil, **kwargs)
|
||||||
Tool::Response.new([{ type: "text", content: "OK" }])
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,37 +5,45 @@ require "test_helper"
|
||||||
module MCP
|
module MCP
|
||||||
class Tool
|
class Tool
|
||||||
class InputSchemaTest < ActiveSupport::TestCase
|
class InputSchemaTest < ActiveSupport::TestCase
|
||||||
test "required arguments are converted to symbols" do
|
test "required arguments are converted to strings" do
|
||||||
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
|
||||||
assert_equal [:message], input_schema.required
|
assert_equal ["message"], input_schema.schema[:required]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "to_h returns a hash representation of the input schema" do
|
test "to_h returns a hash representation of the input schema" do
|
||||||
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
||||||
assert_equal(
|
assert_equal(
|
||||||
{ type: "object", properties: { message: { type: "string" } }, required: [:message] },
|
{ type: "object", properties: { message: { type: "string" } }, required: ["message"] },
|
||||||
|
input_schema.to_h,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_h returns a hash representation of the input schema with additionalProperties set to false" do
|
||||||
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false)
|
||||||
|
assert_equal(
|
||||||
|
{ type: "object", properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false },
|
||||||
input_schema.to_h,
|
input_schema.to_h,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "missing_required_arguments returns an array of missing required arguments" do
|
test "missing_required_arguments returns an array of missing required arguments" do
|
||||||
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
||||||
assert_equal [:message], input_schema.missing_required_arguments({})
|
assert_equal ["message"], input_schema.missing_required_arguments({})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "missing_required_arguments returns an empty array if no required arguments are missing" do
|
test "missing_required_arguments returns an empty array if no required arguments are missing" do
|
||||||
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
|
input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
||||||
assert_empty input_schema.missing_required_arguments({ message: "Hello, world!" })
|
assert_empty input_schema.missing_required_arguments({ message: "Hello, world!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid schema initialization" do
|
test "valid schema initialization" do
|
||||||
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: [:foo] }, schema.to_h)
|
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid schema raises argument error" do
|
test "invalid schema raises argument error" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ArgumentError) do
|
||||||
InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo])
|
InputSchema.new(properties: { foo: { type: "invalid_type" } }, required: ["foo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -46,19 +54,36 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate arguments with valid data" do
|
test "validate arguments with valid data" do
|
||||||
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
assert_nil(schema.validate_arguments({ foo: "bar" }))
|
assert_nil(schema.validate_arguments({ foo: "bar" }))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate arguments with invalid data" do
|
test "validate arguments with invalid data" do
|
||||||
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
assert_raises(InputSchema::ValidationError) do
|
assert_raises(InputSchema::ValidationError) do
|
||||||
schema.validate_arguments({ foo: 123 })
|
schema.validate_arguments({ foo: 123 })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "validate arguments with valid data when additionalProperties set to nil (default)" do
|
||||||
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
|
assert_nil(schema.validate_arguments({ foo: "bar", extra: 123 }))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate arguments with valid data when additionalProperties set to true (default)" do
|
||||||
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: true)
|
||||||
|
assert_nil(schema.validate_arguments({ foo: "bar", extra: 123 }))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate arguments with invalid data when additionalProperties set to false" do
|
||||||
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false)
|
||||||
|
assert_raises(InputSchema::ValidationError) do
|
||||||
|
schema.validate_arguments({ foo: "bar", extra: 123 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "unexpected errors bubble up from validate_arguments" do
|
test "unexpected errors bubble up from validate_arguments" do
|
||||||
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
|
|
||||||
JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
|
JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
|
||||||
assert_raises(RuntimeError) do
|
assert_raises(RuntimeError) do
|
||||||
|
|
@ -69,29 +94,32 @@ module MCP
|
||||||
|
|
||||||
test "rejects schemas with $ref references" do
|
test "rejects schemas with $ref references" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ArgumentError) do
|
||||||
InputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo])
|
InputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: ["foo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects schemas with symbol $ref references" do
|
test "rejects schemas with symbol $ref references" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ArgumentError) do
|
||||||
InputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo])
|
InputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: ["foo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "== compares two input schemas with the same properties and required fields" do
|
test "== compares two input schemas with the same properties, required fields" do
|
||||||
schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema1 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema2 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
assert_equal schema1, schema2
|
assert_equal schema1, schema2
|
||||||
|
|
||||||
schema3 = InputSchema.new(properties: { bar: { type: "string" } }, required: [:bar])
|
schema3 = InputSchema.new(properties: { bar: { type: "string" } }, required: ["bar"])
|
||||||
refute_equal schema1, schema3
|
refute_equal schema1, schema3
|
||||||
|
|
||||||
schema4 = InputSchema.new(properties: { foo: { type: "string" } }, required: [:bar])
|
schema4 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["bar"])
|
||||||
refute_equal schema1, schema4
|
refute_equal schema1, schema4
|
||||||
|
|
||||||
schema5 = InputSchema.new(properties: { bar: { type: "string" } }, required: [:foo])
|
schema5 = InputSchema.new(properties: { bar: { type: "string" } }, required: ["foo"])
|
||||||
refute_equal schema1, schema5
|
refute_equal schema1, schema5
|
||||||
|
|
||||||
|
schema6 = InputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"], additionalProperties: false)
|
||||||
|
refute_equal schema1, schema6
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ module MCP
|
||||||
class Tool
|
class Tool
|
||||||
class OutputSchemaTest < ActiveSupport::TestCase
|
class OutputSchemaTest < ActiveSupport::TestCase
|
||||||
test "to_h returns a hash representation of the output schema" do
|
test "to_h returns a hash representation of the output schema" do
|
||||||
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
assert_equal(
|
assert_equal(
|
||||||
{ type: "object", properties: { result: { type: "string" } }, required: ["result"] },
|
{ type: "object", properties: { result: { type: "string" } }, required: ["result"] },
|
||||||
output_schema.to_h,
|
output_schema.to_h,
|
||||||
|
|
@ -14,34 +14,55 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate_result validates result against the schema" do
|
test "validate_result validates result against the schema" do
|
||||||
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
assert_nothing_raised do
|
assert_nothing_raised do
|
||||||
output_schema.validate_result({ result: "success" })
|
output_schema.validate_result({ result: "success" })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "validate_result validates result with additional properties against the schema when additionalProperties set to nil (default)" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
|
assert_nothing_raised do
|
||||||
|
output_schema.validate_result({ result: "success", extra: 123 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate_result validates result with additional properties against the schema when additionalProperties set to true" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"], additionalProperties: true)
|
||||||
|
assert_nothing_raised do
|
||||||
|
output_schema.validate_result({ result: "success", extra: 123 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate_result raises error with additional properties when additionalProperties set to false)" do
|
||||||
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"], additionalProperties: false)
|
||||||
|
assert_raises(OutputSchema::ValidationError) do
|
||||||
|
output_schema.validate_result({ result: "success", extra: 123 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "validate_result raises error for invalid result" do
|
test "validate_result raises error for invalid result" do
|
||||||
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
assert_raises(OutputSchema::ValidationError) do
|
assert_raises(OutputSchema::ValidationError) do
|
||||||
output_schema.validate_result({ result: 123 })
|
output_schema.validate_result({ result: 123 })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate_result raises error for missing required field" do
|
test "validate_result raises error for missing required field" do
|
||||||
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
output_schema = OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
assert_raises(OutputSchema::ValidationError) do
|
assert_raises(OutputSchema::ValidationError) do
|
||||||
output_schema.validate_result({})
|
output_schema.validate_result({})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid schema initialization" do
|
test "valid schema initialization" do
|
||||||
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h)
|
assert_equal({ type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }, schema.to_h)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid schema raises argument error" do
|
test "invalid schema raises argument error" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ArgumentError) do
|
||||||
OutputSchema.new(properties: { foo: { type: "invalid_type" } }, required: [:foo])
|
OutputSchema.new(properties: { foo: { type: "invalid_type" } }, required: ["foo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -52,7 +73,7 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unexpected errors bubble up from validate_result" do
|
test "unexpected errors bubble up from validate_result" do
|
||||||
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
|
|
||||||
JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
|
JSON::Validator.stub(:fully_validate, ->(*) { raise "unexpected error" }) do
|
||||||
assert_raises(RuntimeError) do
|
assert_raises(RuntimeError) do
|
||||||
|
|
@ -63,28 +84,28 @@ module MCP
|
||||||
|
|
||||||
test "rejects schemas with $ref references" do
|
test "rejects schemas with $ref references" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ArgumentError) do
|
||||||
OutputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: [:foo])
|
OutputSchema.new(properties: { foo: { "$ref" => "#/definitions/bar" } }, required: ["foo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects schemas with symbol $ref references" do
|
test "rejects schemas with symbol $ref references" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ArgumentError) do
|
||||||
OutputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: [:foo])
|
OutputSchema.new(properties: { foo: { :$ref => "#/definitions/bar" } }, required: ["foo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "== compares two output schemas with the same properties and required fields" do
|
test "== compares two output schemas with the same properties and required fields" do
|
||||||
schema1 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema1 = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
schema2 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:foo])
|
schema2 = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["foo"])
|
||||||
assert_equal schema1, schema2
|
assert_equal schema1, schema2
|
||||||
|
|
||||||
schema3 = OutputSchema.new(properties: { bar: { type: "string" } }, required: [:bar])
|
schema3 = OutputSchema.new(properties: { bar: { type: "string" } }, required: ["bar"])
|
||||||
refute_equal schema1, schema3
|
refute_equal schema1, schema3
|
||||||
|
|
||||||
schema4 = OutputSchema.new(properties: { foo: { type: "string" } }, required: [:bar])
|
schema4 = OutputSchema.new(properties: { foo: { type: "string" } }, required: ["bar"])
|
||||||
refute_equal schema1, schema4
|
refute_equal schema1, schema4
|
||||||
|
|
||||||
schema5 = OutputSchema.new(properties: { bar: { type: "string" } }, required: [:foo])
|
schema5 = OutputSchema.new(properties: { bar: { type: "string" } }, required: ["foo"])
|
||||||
refute_equal schema1, schema5
|
refute_equal schema1, schema5
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -105,7 +126,7 @@ module MCP
|
||||||
required: ["items"],
|
required: ["items"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: [:data],
|
required: ["data"],
|
||||||
)
|
)
|
||||||
|
|
||||||
valid_result = {
|
valid_result = {
|
||||||
|
|
@ -136,7 +157,7 @@ module MCP
|
||||||
type: "array",
|
type: "array",
|
||||||
items: {
|
items: {
|
||||||
properties: { foo: { type: "string" } },
|
properties: { foo: { type: "string" } },
|
||||||
required: [:foo],
|
required: ["foo"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert_equal(
|
assert_equal(
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,13 @@ module MCP
|
||||||
class MockTool < Tool
|
class MockTool < Tool
|
||||||
tool_name "my_mock_tool"
|
tool_name "my_mock_tool"
|
||||||
description "a mock tool for testing"
|
description "a mock tool for testing"
|
||||||
input_schema({ properties: { message: { type: "string" } }, required: [:message] })
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = MockTool
|
tool = MockTool
|
||||||
assert_equal "my_mock_tool", tool.name_value
|
assert_equal "my_mock_tool", tool.name_value
|
||||||
assert_equal "a mock tool for testing", tool.description
|
assert_equal "a mock tool for testing", tool.description
|
||||||
assert_equal({ type: "object", properties: { message: { type: "string" } }, required: [:message] }, tool.input_schema.to_h)
|
assert_equal({ type: "object", properties: { message: { type: "string" } }, required: ["message"] }, tool.input_schema.to_h)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "defaults to class name as tool name" do
|
test "defaults to class name as tool name" do
|
||||||
|
|
@ -103,12 +103,12 @@ module MCP
|
||||||
|
|
||||||
test "accepts input schema as an InputSchema object" do
|
test "accepts input schema as an InputSchema object" do
|
||||||
class InputSchemaTool < Tool
|
class InputSchemaTool < Tool
|
||||||
input_schema InputSchema.new(properties: { message: { type: "string" } }, required: [:message])
|
input_schema InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = InputSchemaTool
|
tool = InputSchemaTool
|
||||||
|
|
||||||
expected = { type: "object", properties: { message: { type: "string" } }, required: [:message] }
|
expected = { type: "object", properties: { message: { type: "string" } }, required: ["message"] }
|
||||||
assert_equal expected, tool.input_schema.to_h
|
assert_equal expected, tool.input_schema.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -119,7 +119,7 @@ module MCP
|
||||||
properties: {
|
properties: {
|
||||||
count: { type: "integer", minimum: "not a number" },
|
count: { type: "integer", minimum: "not a number" },
|
||||||
},
|
},
|
||||||
required: [:count],
|
required: ["count"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -340,7 +340,7 @@ module MCP
|
||||||
|
|
||||||
test "accepts output_schema as a hash" do
|
test "accepts output_schema as a hash" do
|
||||||
class HashOutputSchemaTool < Tool
|
class HashOutputSchemaTool < Tool
|
||||||
output_schema({ properties: { result: { type: "string" } }, required: [:result] })
|
output_schema({ properties: { result: { type: "string" } }, required: ["result"] })
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = HashOutputSchemaTool
|
tool = HashOutputSchemaTool
|
||||||
|
|
@ -350,7 +350,7 @@ module MCP
|
||||||
|
|
||||||
test "accepts output_schema as an OutputSchema object" do
|
test "accepts output_schema as an OutputSchema object" do
|
||||||
class OutputSchemaObjectTool < Tool
|
class OutputSchemaObjectTool < Tool
|
||||||
output_schema Tool::OutputSchema.new(properties: { result: { type: "string" } }, required: [:result])
|
output_schema Tool::OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = OutputSchemaObjectTool
|
tool = OutputSchemaObjectTool
|
||||||
|
|
@ -365,7 +365,7 @@ module MCP
|
||||||
properties: {
|
properties: {
|
||||||
count: { type: "integer", minimum: "not a number" },
|
count: { type: "integer", minimum: "not a number" },
|
||||||
},
|
},
|
||||||
required: [:count],
|
required: ["count"],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -428,7 +428,7 @@ module MCP
|
||||||
assert_equal "test_tool_with_output", tool.name_value
|
assert_equal "test_tool_with_output", tool.name_value
|
||||||
assert_equal "a test tool with output schema", tool.description
|
assert_equal "a test tool with output schema", tool.description
|
||||||
|
|
||||||
expected_input = { type: "object", properties: { message: { type: "string" } }, required: [:message] }
|
expected_input = { type: "object", properties: { message: { type: "string" } }, required: ["message"] }
|
||||||
assert_equal expected_input, tool.input_schema.to_h
|
assert_equal expected_input, tool.input_schema.to_h
|
||||||
|
|
||||||
expected_output = { type: "object", properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] }
|
expected_output = { type: "object", properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue