Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions lib/model_context_protocol/server/tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def define(&block)
@title = definition_dsl.title
@input_schema = definition_dsl.input_schema
@output_schema = definition_dsl.output_schema
@annotations = definition_dsl.annotations
@annotations = definition_dsl.defined_annotations
@security_schemes = definition_dsl.security_schemes
end

Expand All @@ -104,7 +104,7 @@ def inherited(subclass)
subclass.instance_variable_set(:@title, @title)
subclass.instance_variable_set(:@input_schema, @input_schema)
subclass.instance_variable_set(:@output_schema, @output_schema)
subclass.instance_variable_set(:@annotations, @annotations)
subclass.instance_variable_set(:@annotations, @annotations&.dup)
subclass.instance_variable_set(:@security_schemes, @security_schemes)
end

Expand All @@ -124,7 +124,8 @@ def definition
result = {name: @name, description: @description, inputSchema: @input_schema}
result[:title] = @title if @title
result[:outputSchema] = @output_schema if @output_schema
result[:annotations] = @annotations if @annotations
annotations_hash = @annotations&.serialized
result[:annotations] = annotations_hash if annotations_hash
result[:securitySchemes] = @security_schemes if @security_schemes
result
end
Expand Down Expand Up @@ -156,15 +157,64 @@ def output_schema(&block)
@output_schema
end

attr_reader :defined_annotations

def annotations(&block)
@annotations = instance_eval(&block) if block_given?
@annotations
@defined_annotations = AnnotationsDSL.new
@defined_annotations.instance_eval(&block)
@defined_annotations
end

def security_schemes(&block)
@security_schemes = instance_eval(&block) if block_given?
@security_schemes
end
end

class AnnotationsDSL
def initialize
@read_only_hint = nil
@destructive_hint = nil
@idempotent_hint = nil
@open_world_hint = nil
end

def read_only_hint(value)
validate_boolean!(:read_only_hint, value)
@read_only_hint = value
end

def destructive_hint(value)
validate_boolean!(:destructive_hint, value)
@destructive_hint = value
end

def idempotent_hint(value)
validate_boolean!(:idempotent_hint, value)
@idempotent_hint = value
end

def open_world_hint(value)
validate_boolean!(:open_world_hint, value)
@open_world_hint = value
end

def serialized
result = {}
result[:readOnlyHint] = @read_only_hint unless @read_only_hint.nil?
result[:destructiveHint] = @destructive_hint unless @destructive_hint.nil?
result[:idempotentHint] = @idempotent_hint unless @idempotent_hint.nil?
result[:openWorldHint] = @open_world_hint unless @open_world_hint.nil?
result.empty? ? nil : result
end

private

def validate_boolean!(field, value)
unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
raise ArgumentError, "#{field} must be a boolean, got: #{value.inspect}"
end
end
end
end
end
126 changes: 121 additions & 5 deletions spec/lib/model_context_protocol/server/tool_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,12 @@
}
end
annotations do
{readOnlyHint: true}
read_only_hint true
end
end
end

expect(tool_with_annotations.annotations).to eq(readOnlyHint: true)
expect(tool_with_annotations.annotations.serialized).to eq(readOnlyHint: true)
end

it "inherits annotations in subclasses" do
Expand All @@ -328,12 +328,14 @@
name "fetch"
description "Fetch the full contents of a single resource"
input_schema { {type: "object", properties: {}, required: []} }
annotations { {readOnlyHint: true} }
annotations do
read_only_hint true
end
end
end

child_tool = Class.new(parent_tool)
expect(child_tool.annotations).to eq({readOnlyHint: true})
expect(child_tool.annotations.serialized).to eq({readOnlyHint: true})
end

it "sets security schemes when provided" do
Expand Down Expand Up @@ -456,7 +458,7 @@
}
end
annotations do
{readOnlyHint: true}
read_only_hint true
end
end
end
Expand Down Expand Up @@ -558,6 +560,120 @@ def call
end
end

describe "AnnotationsDSL" do
it "serializes all hints with camelCase keys" do
tool = Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
read_only_hint false
destructive_hint true
idempotent_hint false
open_world_hint true
end
end
end

expect(tool.annotations.serialized).to eq(
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
)
end

it "only serializes hints that are set" do
tool = Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
destructive_hint false
end
end
end

expect(tool.annotations.serialized).to eq(destructiveHint: false)
end

it "returns nil from serialized when no hints are set" do
tool = Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
end
end
end

expect(tool.annotations.serialized).to be_nil
end

it "raises ArgumentError for non-boolean read_only_hint" do
expect {
Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
read_only_hint "true"
end
end
end
}.to raise_error(ArgumentError, /read_only_hint must be a boolean/)
end

it "raises ArgumentError for non-boolean destructive_hint" do
expect {
Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
destructive_hint 1
end
end
end
}.to raise_error(ArgumentError, /destructive_hint must be a boolean/)
end

it "raises ArgumentError for non-boolean idempotent_hint" do
expect {
Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
idempotent_hint nil
end
end
end
}.to raise_error(ArgumentError, /idempotent_hint must be a boolean/)
end

it "raises ArgumentError for non-boolean open_world_hint" do
expect {
Class.new(ModelContextProtocol::Server::Tool) do
define do
name "test"
description "Test tool"
input_schema { {type: "object", properties: {}, required: []} }
annotations do
open_world_hint "false"
end
end
end
}.to raise_error(ArgumentError, /open_world_hint must be a boolean/)
end
end

describe "server logger integration" do
it "calls server_logger during execution" do
allow(client_logger).to receive(:info)
Expand Down
2 changes: 1 addition & 1 deletion spec/support/tools/test_tool_with_annotations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class TestToolWithAnnotations < ModelContextProtocol::Server::Tool
}
end
annotations do
{readOnlyHint: true}
read_only_hint true
end
end

Expand Down
Loading