diff --git a/lib/model_context_protocol/server/tool.rb b/lib/model_context_protocol/server/tool.rb index 31b16fc..3c1fabd 100644 --- a/lib/model_context_protocol/server/tool.rb +++ b/lib/model_context_protocol/server/tool.rb @@ -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 @@ -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 @@ -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 @@ -156,9 +157,12 @@ 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) @@ -166,5 +170,51 @@ def security_schemes(&block) @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 diff --git a/spec/lib/model_context_protocol/server/tool_spec.rb b/spec/lib/model_context_protocol/server/tool_spec.rb index 0405188..e874cd9 100644 --- a/spec/lib/model_context_protocol/server/tool_spec.rb +++ b/spec/lib/model_context_protocol/server/tool_spec.rb @@ -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 @@ -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 @@ -456,7 +458,7 @@ } end annotations do - {readOnlyHint: true} + read_only_hint true end end end @@ -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) diff --git a/spec/support/tools/test_tool_with_annotations.rb b/spec/support/tools/test_tool_with_annotations.rb index 67e793d..45e8244 100644 --- a/spec/support/tools/test_tool_with_annotations.rb +++ b/spec/support/tools/test_tool_with_annotations.rb @@ -15,7 +15,7 @@ class TestToolWithAnnotations < ModelContextProtocol::Server::Tool } end annotations do - {readOnlyHint: true} + read_only_hint true end end