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