diff --git a/lib/mcp.rb b/lib/mcp.rb index eff9de8..3fc759d 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative "mcp/annotations" require_relative "mcp/configuration" require_relative "mcp/content" require_relative "mcp/instrumentation" @@ -14,7 +15,6 @@ require_relative "mcp/resource_template" require_relative "mcp/server" 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/response" @@ -32,13 +32,4 @@ def configuration @configuration ||= Configuration.new end end - - class Annotations - attr_reader :audience, :priority - - def initialize(audience: nil, priority: nil) - @audience = audience - @priority = priority - end - end end diff --git a/lib/mcp/annotations.rb b/lib/mcp/annotations.rb new file mode 100644 index 0000000..88bead5 --- /dev/null +++ b/lib/mcp/annotations.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module MCP + class Annotations + attr_reader :audience, :priority + + def initialize(audience: nil, priority: nil) + @audience = audience + @priority = priority + + freeze + end + + def to_h + { audience:, priority: }.compact.freeze + end + end +end diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 4f0b0b2..c0cb0b8 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -32,41 +32,41 @@ def initialize(method, capability) end end - extend self + class << self + def ensure_capability!(method, capabilities) + case method + when PROMPTS_GET, PROMPTS_LIST + unless capabilities[:prompts] + raise MissingRequiredCapabilityError.new(method, :prompts) + end + when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ, RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE + unless capabilities[:resources] + raise MissingRequiredCapabilityError.new(method, :resources) + end - def ensure_capability!(method, capabilities) - case method - when PROMPTS_GET, PROMPTS_LIST - unless capabilities[:prompts] - raise MissingRequiredCapabilityError.new(method, :prompts) + if method == RESOURCES_SUBSCRIBE && !capabilities[:resources][:subscribe] + raise MissingRequiredCapabilityError.new(method, :resources_subscribe) + end + when TOOLS_CALL, TOOLS_LIST + unless capabilities[:tools] + raise MissingRequiredCapabilityError.new(method, :tools) + end + when SAMPLING_CREATE_MESSAGE + unless capabilities[:sampling] + raise MissingRequiredCapabilityError.new(method, :sampling) + end + when COMPLETION_COMPLETE + unless capabilities[:completions] + raise MissingRequiredCapabilityError.new(method, :completions) + end + when LOGGING_SET_LEVEL + # Logging is unsupported by the Server + unless capabilities[:logging] + raise MissingRequiredCapabilityError.new(method, :logging) + end + when INITIALIZE, PING + # No specific capability required for initialize or ping end - when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ, RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE - unless capabilities[:resources] - raise MissingRequiredCapabilityError.new(method, :resources) - end - - if method == RESOURCES_SUBSCRIBE && !capabilities[:resources][:subscribe] - raise MissingRequiredCapabilityError.new(method, :resources_subscribe) - end - when TOOLS_CALL, TOOLS_LIST - unless capabilities[:tools] - raise MissingRequiredCapabilityError.new(method, :tools) - end - when SAMPLING_CREATE_MESSAGE - unless capabilities[:sampling] - raise MissingRequiredCapabilityError.new(method, :sampling) - end - when COMPLETION_COMPLETE - unless capabilities[:completions] - raise MissingRequiredCapabilityError.new(method, :completions) - end - when LOGGING_SET_LEVEL - # Logging is unsupported by the Server - unless capabilities[:logging] - raise MissingRequiredCapabilityError.new(method, :logging) - end - when INITIALIZE, PING - # No specific capability required for initialize or ping end end end diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index 7624ef5..1a260f7 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -3,80 +3,35 @@ module MCP class Prompt - class << self - NOT_SET = Object.new - - attr_reader :description_value - attr_reader :arguments_value + attr_reader :name, :description, :arguments, :to_h - def template(args, server_context:) - raise NotImplementedError, "Subclasses must implement template" - end + class << self + def define(...) = new(...) + private :new - def to_h - { name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact - end + private def inherited(subclass) super - subclass.instance_variable_set(:@name_value, nil) - subclass.instance_variable_set(:@description_value, nil) - subclass.instance_variable_set(:@arguments_value, nil) - end - - def prompt_name(value = NOT_SET) - if value == NOT_SET - @name_value - else - @name_value = value - end - end - - def name_value - @name_value || StringUtils.handle_from_class_name(name) - end - - def description(value = NOT_SET) - if value == NOT_SET - @description_value - else - @description_value = value - end - end - - def arguments(value = NOT_SET) - if value == NOT_SET - @arguments_value - else - @arguments_value = value - end + raise TypeError, "#{self} should no longer be subclassed. Use #{self}.define factory method instead." end + end - def define(name: nil, description: nil, arguments: [], &block) - Class.new(self) do - prompt_name name - description description - arguments arguments - define_singleton_method(:template) do |args, server_context:| - instance_exec(args, server_context:, &block) - end - end - end + def initialize(name:, description:, arguments:, &block) + arguments = arguments.map { |arg| Hash === arg ? Argument.new(**arg) : arg } - def validate_arguments!(args) - missing = required_args - args.keys - return if missing.empty? + @name = name + @description = description + @arguments = arguments + @block = block - raise MCP::Server::RequestHandlerError.new( - "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments - ) - end + @to_h = { name:, description:, arguments: arguments.map(&:to_h) }.compact.freeze - private + freeze + end - def required_args - arguments_value.filter_map { |arg| arg.name.to_sym if arg.required } - end + def call(args, server_context:) + @block.call(args, server_context:) end end end diff --git a/lib/mcp/prompt/argument.rb b/lib/mcp/prompt/argument.rb index 2a22fd4..307099a 100644 --- a/lib/mcp/prompt/argument.rb +++ b/lib/mcp/prompt/argument.rb @@ -4,17 +4,20 @@ module MCP class Prompt class Argument - attr_reader :name, :description, :required, :arguments + attr_reader :name, :description, :required, :to_h def initialize(name:, description: nil, required: false) @name = name @description = description @required = required - @arguments = arguments - end - def to_h - { name:, description:, required: }.compact + @to_h = { + name:, + description:, + required:, + }.compact.freeze + + freeze end end end diff --git a/lib/mcp/resource/contents.rb b/lib/mcp/resource/contents.rb index 0bebb39..f49d18f 100644 --- a/lib/mcp/resource/contents.rb +++ b/lib/mcp/resource/contents.rb @@ -20,12 +20,12 @@ class TextContents < Contents attr_reader :text def initialize(text:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) + super(uri:, mime_type:) @text = text end def to_h - super.merge(text: text) + super.merge(text:) end end @@ -33,12 +33,12 @@ class BlobContents < Contents attr_reader :data def initialize(data:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) + super(uri:, mime_type:) @data = data end def to_h - super.merge(data: data) + super.merge(data:) end end end diff --git a/lib/mcp/resource/embedded.rb b/lib/mcp/resource/embedded.rb index 4cc9132..66a76d8 100644 --- a/lib/mcp/resource/embedded.rb +++ b/lib/mcp/resource/embedded.rb @@ -7,6 +7,7 @@ class Embedded attr_reader :resource, :annotations def initialize(resource:, annotations: nil) + @resource = resource @annotations = annotations end diff --git a/lib/mcp/resource_template.rb b/lib/mcp/resource_template.rb index e2cc6f2..ab6b46b 100644 --- a/lib/mcp/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -3,22 +3,22 @@ module MCP class ResourceTemplate - attr_reader :uri_template, :name, :description, :mime_type + attr_reader :uri_template, :name, :description, :mime_type, :to_h def initialize(uri_template:, name:, description: nil, mime_type: nil) @uri_template = uri_template @name = name @description = description @mime_type = mime_type - end - def to_h - { + @to_h = { uriTemplate: @uri_template, name: @name, description: @description, mimeType: @mime_type, - }.compact + }.compact.freeze + + freeze end end end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 2c39fb0..d88aece 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -11,13 +11,11 @@ class Server class RequestHandlerError < StandardError attr_reader :error_type - attr_reader :original_error - def initialize(message, request, error_type: :internal_error, original_error: nil) + def initialize(message, request, error_type: :internal_error) super(message) @request = request @error_type = error_type - @original_error = original_error end end @@ -39,8 +37,8 @@ def initialize( ) @name = name @version = version - @tools = tools.to_h { |t| [t.name_value, t] } - @prompts = prompts.to_h { |p| [p.name_value, p] } + @tools = tools.to_h { |t| [t.name, t] } + @prompts = prompts.to_h { |p| [p.name, p] } @resources = resources @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) @@ -88,12 +86,12 @@ def handle_json(request) def define_tool(name: nil, description: nil, input_schema: nil, annotations: nil, &block) tool = Tool.define(name:, description:, input_schema:, annotations:, &block) - @tools[tool.name_value] = tool + @tools[tool.name] = tool end def define_prompt(name: nil, description: nil, arguments: [], &block) prompt = Prompt.define(name:, description:, arguments:, &block) - @prompts[prompt.name_value] = prompt + @prompts[prompt.name] = prompt end def resources_list_handler(&block) @@ -156,14 +154,14 @@ def handle_request(request, method) @handlers[method].call(params) end rescue => e - report_exception(e, { request: request }) + report_exception(e, { request: }) if e.is_a?(RequestHandlerError) add_instrumentation_data(error: e.error_type) raise e end add_instrumentation_data(error: :internal_error) - raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e) + raise RequestHandlerError.new("Internal error handling #{method} request", request) end } end @@ -201,28 +199,24 @@ def call_tool(request) arguments = request[:arguments] add_instrumentation_data(tool_name:) - if tool.input_schema&.missing_required_arguments?(arguments) - add_instrumentation_data(error: :missing_required_arguments) - raise RequestHandlerError.new( - "Missing required arguments: #{tool.input_schema.missing_required_arguments(arguments).join(", ")}", - request, - error_type: :missing_required_arguments, - ) - end + validate_tool_arguments!(tool, arguments, request) begin - call_params = tool_call_parameters(tool) - - if call_params.include?(:server_context) - tool.call(**arguments.transform_keys(&:to_sym), server_context:).to_h - else - tool.call(**arguments.transform_keys(&:to_sym)).to_h - end - rescue => e - raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e) + tool.call(arguments.transform_keys(&:to_sym), server_context:).to_h + rescue + raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request) end end + def validate_tool_arguments!(tool, arguments, request) + input_schema = tool.input_schema + return unless input_schema + + missing_arguments = input_schema.required - arguments.keys.map(&:to_sym) + + missing_required_arguments!(missing_arguments, request) unless missing_arguments.empty? + end + def list_prompts(request) add_instrumentation_data(method: Methods::PROMPTS_LIST) @prompts.map { |_, prompt| prompt.to_h } @@ -240,9 +234,31 @@ def get_prompt(request) add_instrumentation_data(prompt_name:) prompt_args = request[:arguments] - prompt.validate_arguments!(prompt_args) + validate_prompt_arguments!(prompt, prompt_args, request) + + prompt.call(prompt_args, server_context:).to_h + end + + def validate_prompt_arguments!(prompt, provided_arguments, request) + missing_arguments = prompt.arguments.filter_map do |configured_argument| + next unless configured_argument.required + + key = configured_argument.name + next if provided_arguments.key?(key.to_s) || provided_arguments.key?(key.to_sym) - prompt.template(prompt_args, server_context:).to_h + key + end + + missing_required_arguments!(missing_arguments, request) unless missing_arguments.empty? + end + + def missing_required_arguments!(missing_arguments, request) + add_instrumentation_data(error: :missing_required_arguments) + raise RequestHandlerError.new( + "Missing required arguments: #{missing_arguments.join(", ")}", + request, + error_type: :missing_required_arguments, + ) end def list_resources(request) @@ -273,24 +289,5 @@ def index_resources_by_uri(resources) hash[resource.uri] = resource end end - - def tool_call_parameters(tool) - method_def = tool_call_method_def(tool) - method_def.parameters.flatten - end - - def tool_call_method_def(tool) - method = tool.method(:call) - - if defined?(T::Utils) && T::Utils.respond_to?(:signature_for_method) - sorbet_typed_method_definition = T::Utils.signature_for_method(method)&.method - - # Return the Sorbet typed method definition if it exists, otherwise fallback to original method - # definition if Sorbet is defined but not used by this tool. - sorbet_typed_method_definition || method - else - method - end - end end end diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index d4aae27..40b8390 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -10,11 +10,10 @@ class StdioTransport < Transport STATUS_INTERRUPTED = Signal.list["INT"] + 128 def initialize(server) - @server = server + super @open = false $stdin.set_encoding("UTF-8") $stdout.set_encoding("UTF-8") - super end def open diff --git a/lib/mcp/string_utils.rb b/lib/mcp/string_utils.rb deleted file mode 100644 index 3f0dcdb..0000000 --- a/lib/mcp/string_utils.rb +++ /dev/null @@ -1,26 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -module MCP - module StringUtils - extend self - - def handle_from_class_name(class_name) - underscore(demodulize(class_name)) - end - - private - - def demodulize(path) - path.to_s.split("::").last || path.to_s - end - - def underscore(camel_cased_word) - camel_cased_word.dup - .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') - .gsub(/([a-z\d])([A-Z])/, '\1_\2') - .tr("-", "_") - .downcase - end - end -end diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 48378e4..334e704 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -2,84 +2,43 @@ module MCP class Tool - class << self - NOT_SET = Object.new - - attr_reader :description_value - attr_reader :input_schema_value - attr_reader :annotations_value + attr_reader :name, :description, :input_schema, :annotations, :to_h - def call(*args, server_context:) - raise NotImplementedError, "Subclasses must implement call" - end + class << self + def define(...) = new(...) + private :new - def to_h - result = { - name: name_value, - description: description_value, - inputSchema: input_schema_value.to_h, - } - result[:annotations] = annotations_value.to_h if annotations_value - result - end + private def inherited(subclass) super - subclass.instance_variable_set(:@name_value, nil) - subclass.instance_variable_set(:@description_value, nil) - subclass.instance_variable_set(:@input_schema_value, nil) - subclass.instance_variable_set(:@annotations_value, nil) - end - - def tool_name(value = NOT_SET) - if value == NOT_SET - name_value - else - @name_value = value - end - end - - def name_value - @name_value || StringUtils.handle_from_class_name(name) - end - - def description(value = NOT_SET) - if value == NOT_SET - @description_value - else - @description_value = value - end - end - - def input_schema(value = NOT_SET) - if value == NOT_SET - input_schema_value - elsif value.is_a?(Hash) - properties = value[:properties] || value["properties"] || {} - required = value[:required] || value["required"] || [] - @input_schema_value = InputSchema.new(properties:, required:) - elsif value.is_a?(InputSchema) - @input_schema_value = value - end + raise TypeError, "#{self} should no longer be subclassed. Use #{self}.define factory method instead." end + end - def annotations(hash = NOT_SET) - if hash == NOT_SET - @annotations_value - else - @annotations_value = Annotations.new(**hash) - end - end + def initialize(name:, description: nil, input_schema: nil, annotations: nil, &block) + input_schema = InputSchema.new(**input_schema) if Hash === input_schema + annotations = Annotations.new(**annotations) if Hash === annotations + raise ArgumentError, "Tool definition requires a block" unless block + + @name = name + @description = description + @input_schema = input_schema + @annotations = annotations + @block = block + + @to_h = { + name:, + description:, + inputSchema: input_schema&.to_h, + annotations: annotations&.to_h, + }.compact.freeze + + freeze + end - def define(name: nil, description: nil, input_schema: nil, annotations: nil, &block) - Class.new(self) do - tool_name name - description description - input_schema input_schema - self.annotations(annotations) if annotations - define_singleton_method(:call, &block) if block - end - end + def call(args, server_context:) + @block.call(args, server_context:) end end end diff --git a/lib/mcp/tool/annotations.rb b/lib/mcp/tool/annotations.rb index 6344334..dfdafa1 100644 --- a/lib/mcp/tool/annotations.rb +++ b/lib/mcp/tool/annotations.rb @@ -3,7 +3,7 @@ module MCP class Tool class Annotations - attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint + attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint, :to_h def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil) @title = title @@ -11,16 +11,16 @@ def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempoten @destructive_hint = destructive_hint @idempotent_hint = idempotent_hint @open_world_hint = open_world_hint - end - def to_h - { + @to_h = { title:, readOnlyHint: read_only_hint, destructiveHint: destructive_hint, idempotentHint: idempotent_hint, openWorldHint: open_world_hint, - }.compact + }.compact.freeze + + freeze end end end diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index 4683b7e..dca3a43 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -3,23 +3,19 @@ module MCP class Tool class InputSchema - attr_reader :properties, :required + attr_reader :properties, :required, :to_h def initialize(properties: {}, required: []) - @properties = properties + @properties = properties.transform_keys(&:to_sym) @required = required.map(&:to_sym) - end - - def to_h - { type: "object", properties:, required: } - end - def missing_required_arguments?(arguments) - missing_required_arguments(arguments).any? - end + @to_h = { + type: "object", + properties:, + required:, + }.compact.freeze - def missing_required_arguments(arguments) - (required - arguments.keys.map(&:to_sym)) + freeze end end end diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index abf6ff4..690f360 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -5,7 +5,7 @@ class Tool class Response attr_reader :content, :is_error - def initialize(content, is_error = false) + def initialize(content, is_error: false) @content = content @is_error = is_error end diff --git a/test/mcp/annotations_test.rb b/test/mcp/annotations_test.rb new file mode 100644 index 0000000..4eced5d --- /dev/null +++ b/test/mcp/annotations_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class AnnotationsTest < ActiveSupport::TestCase + test "initializes with no parameters" do + annotations = Annotations.new + + assert_nil annotations.audience + assert_nil annotations.priority + end + + test "initializes with audience only" do + annotations = Annotations.new(audience: :internal) + + assert_equal :internal, annotations.audience + assert_nil annotations.priority + end + + test "initializes with priority only" do + annotations = Annotations.new(priority: 1) + + assert_nil annotations.audience + assert_equal 1, annotations.priority + end + + test "initializes with both audience and priority" do + annotations = Annotations.new(audience: :public, priority: 10) + + assert_equal :public, annotations.audience + assert_equal 10, annotations.priority + end + + test "instance is frozen after initialization" do + annotations = Annotations.new + + assert_predicate annotations, :frozen? + end + + test "to_h returns empty hash when no parameters are set" do + annotations = Annotations.new + result = annotations.to_h + + assert_empty(result) + assert_predicate result, :frozen? + end + + test "to_h returns hash with audience only" do + annotations = Annotations.new(audience: :internal) + result = annotations.to_h + + assert_equal({ audience: :internal }, result) + assert_predicate result, :frozen? + end + + test "to_h returns hash with priority only" do + annotations = Annotations.new(priority: 5) + result = annotations.to_h + + assert_equal({ priority: 5 }, result) + assert_predicate result, :frozen? + end + + test "to_h returns hash with both audience and priority" do + annotations = Annotations.new(audience: :public, priority: 10) + result = annotations.to_h + + assert_equal({ audience: :public, priority: 10 }, result) + assert_predicate result, :frozen? + end + + test "to_h compacts nil values" do + annotations = Annotations.new(audience: nil, priority: 5) + result = annotations.to_h + + assert_equal({ priority: 5 }, result) + assert_not_includes result, :audience + end + end +end diff --git a/test/mcp/prompt_test.rb b/test/mcp/prompt_test.rb index 7200d21..83d82aa 100644 --- a/test/mcp/prompt_test.rb +++ b/test/mcp/prompt_test.rb @@ -5,69 +5,24 @@ module MCP class PromptTest < ActiveSupport::TestCase - class TestPrompt < Prompt - description "Test prompt" - arguments [ + TestPrompt = Prompt.define( + name: "test_prompt", + description: "Test prompt", + arguments: [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), - ] - - class << self - def template(args, server_context:) - Prompt::Result.new( - description: "Hello, world!", - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content: Content::Text.new("Hello, friend!")), - ], - ) - end - end - end - - test "#template returns a Result with description and messages" do - prompt = TestPrompt - - expected_template_result = { + ], + ) do + Prompt::Result.new( description: "Hello, world!", messages: [ - { role: "user", content: { text: "Hello, world!", type: "text" } }, - { role: "assistant", content: { text: "Hello, friend!", type: "text" } }, + Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), + Prompt::Message.new(role: "assistant", content: Content::Text.new("Hello, friend!")), ], - } - - result = prompt.template({ "test_argument" => "Hello, friend!" }, server_context: { user_id: 123 }) - - assert_equal expected_template_result, result.to_h + ) end - test "allows declarative definition of prompts as classes" do - class MockPrompt < Prompt - prompt_name "my_mock_prompt" - description "a mock prompt for testing" - arguments [ - Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), - ] - - class << self - def template(args, server_context:) - Prompt::Result.new( - description: "Hello, world!", - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])), - ], - ) - end - end - end - - prompt = MockPrompt - - assert_equal "my_mock_prompt", prompt.name_value - assert_equal "a mock prompt for testing", prompt.description - assert_equal "test_argument", prompt.arguments.first.name - assert_equal "Test argument", prompt.arguments.first.description - assert prompt.arguments.first.required + test "#call returns a Result with description and messages" do + prompt = TestPrompt expected_template_result = { description: "Hello, world!", @@ -77,35 +32,9 @@ def template(args, server_context:) ], } - result = prompt.template({ "test_argument" => "Hello, friend!" }, server_context: { user_id: 123 }) - assert_equal expected_template_result, result.to_h - end + result = prompt.call({ "test_argument" => "Hello, friend!" }, server_context: { user_id: 123 }) - test "defaults to class name as prompt name" do - class DefaultNamePrompt < Prompt - description "a mock prompt for testing" - arguments [ - Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), - ] - - class << self - def template(args, server_context:) - Prompt::Result.new( - description: "Hello, world!", - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])), - ], - ) - end - end - end - - prompt = DefaultNamePrompt - - assert_equal "default_name_prompt", prompt.name_value - assert_equal "a mock prompt for testing", prompt.description - assert_equal "test_argument", prompt.arguments.first.name + assert_equal expected_template_result, result.to_h end test ".define allows definition of simple prompts with a block" do @@ -127,7 +56,7 @@ def template(args, server_context:) ) end - assert_equal "mock_prompt", prompt.name_value + assert_equal "mock_prompt", prompt.name assert_equal "a mock prompt for testing", prompt.description assert_equal "test_argument", prompt.arguments.first.name @@ -139,7 +68,7 @@ def template(args, server_context:) ], } - result = prompt.template({ "test_argument" => "Hello, friend!" }, server_context: { user_id: 123 }) + result = prompt.call({ "test_argument" => "Hello, friend!" }, server_context: { user_id: 123 }) assert_equal expected, result.to_h end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 6dc56ee..c3e2d9f 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -9,12 +9,14 @@ module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper setup do - @tool = Tool.define(name: "test_tool", description: "Test tool") + @tool = Tool.define(name: "test_tool", description: "Test tool") do + Tool::Response.new("success") + end @tool_that_raises = Tool.define( name: "tool_that_raises", description: "Tool that raises", - input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, ) { raise StandardError, "Tool error" } @prompt = Prompt.define( @@ -23,7 +25,7 @@ class ServerTest < ActiveSupport::TestCase arguments: [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ], - ) do |_| + ) do Prompt::Result.new( description: "Hello, world!", messages: [ @@ -153,7 +155,7 @@ class ServerTest < ActiveSupport::TestCase assert_kind_of Array, result[:tools] assert_equal "test_tool", result[:tools][0][:name] assert_equal "Test tool", result[:tools][0][:description] - assert_empty(result[:tools][0][:inputSchema]) + assert_nil result[:tools][0][:inputSchema] assert_instrumentation_data({ method: "tools/list" }) end @@ -190,23 +192,19 @@ class ServerTest < ActiveSupport::TestCase test "#handle tools/call executes tool and returns result" do tool_name = "test_tool" - tool_args = { arg: "value" } - tool_response = Tool::Response.new([{ result: "success" }]) - - @tool.expects(:call).with(arg: "value").returns(tool_response) request = { jsonrpc: "2.0", method: "tools/call", params: { name: tool_name, - arguments: tool_args, + arguments: {}, }, id: 1, } response = @server.handle(request) - assert_equal tool_response.to_h, response[:result] + assert_equal({ content: "success", isError: false }, response[:result]) assert_instrumentation_data({ method: "tools/call", tool_name: }) end @@ -239,61 +237,20 @@ class ServerTest < ActiveSupport::TestCase test "#handle_json tools/call executes tool and returns result" do tool_name = "test_tool" - tool_args = { arg: "value" } - tool_response = Tool::Response.new([{ result: "success" }]) - - @tool.expects(:call).with(arg: "value").returns(tool_response) request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", - params: { name: tool_name, arguments: tool_args }, + params: { name: tool_name, arguments: {} }, id: 1, }) raw_response = @server.handle_json(request) response = JSON.parse(raw_response, symbolize_names: true) if raw_response - assert_equal tool_response.to_h, response[:result] if response + assert_equal({ content: "success", isError: false }, response[:result]) assert_instrumentation_data({ method: "tools/call", tool_name: }) end - test "#handle_json tools/call executes tool and returns result, when the tool is typed with Sorbet" do - class TypedTestTool < Tool - tool_name "test_tool" - description "a test tool for testing" - input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) - - class << self - extend T::Sig - - sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) } - def call(message:, server_context: nil) - Tool::Response.new([{ type: "text", content: "OK" }]) - end - end - end - - request = JSON.generate({ - jsonrpc: "2.0", - method: "tools/call", - params: { name: "test_tool", arguments: { message: "Hello, world!" } }, - id: 1, - }) - - server = Server.new( - name: @server_name, - tools: [TypedTestTool], - prompts: [@prompt], - resources: [@resource], - resource_templates: [@resource_template], - ) - - raw_response = server.handle_json(request) - response = JSON.parse(raw_response, symbolize_names: true) if raw_response - - assert_equal({ content: [{ type: "text", content: "OK" }], isError: false }, response[:result]) - end - test "#handle tools/call returns internal error and reports exception if the tool raises an error" do @server.configuration.exception_reporter.expects(:call).with do |exception, server_context| assert_not_nil exception @@ -703,9 +660,9 @@ def call(message:, server_context: nil) @server.define_tool( name: "defined_tool", description: "Defined tool", - input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, - ) do |message:| - Tool::Response.new(message) + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, + ) do |args| + Tool::Response.new(args[:message]) end response = @server.handle({ @@ -724,9 +681,9 @@ def call(message:, server_context: nil) @server.define_tool( name: "defined_tool", description: "Defined tool", - input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, - ) do |message:, server_context:| - Tool::Response.new("success #{message} #{server_context[:user_id]}") + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, + ) do |args, server_context:| + Tool::Response.new("success #{args[:message]} #{server_context[:user_id]}") end response = @server.handle({ diff --git a/test/mcp/string_utils_test.rb b/test/mcp/string_utils_test.rb deleted file mode 100644 index 6613e89..0000000 --- a/test/mcp/string_utils_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - class StringUtilsTest < Minitest::Test - def test_handle_from_class_name_returns_the_class_name_without_the_module_for_a_class_without_a_module - assert_equal("test", StringUtils.handle_from_class_name("Test")) - assert_equal("test_class", StringUtils.handle_from_class_name("TestClass")) - end - - def test_handle_from_class_name_returns_the_class_name_without_the_module_for_a_class_with_a_single_parent_module - assert_equal("test", StringUtils.handle_from_class_name("Module::Test")) - assert_equal("test_class", StringUtils.handle_from_class_name("Module::TestClass")) - end - - def test_handle_from_class_name_returns_the_class_name_without_the_module_for_a_class_with_multiple_parent_modules - assert_equal("test", StringUtils.handle_from_class_name("Module::Submodule::Test")) - assert_equal("test_class", StringUtils.handle_from_class_name("Module::Submodule::TestClass")) - end - end -end diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 56463bd..4a7e76c 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -10,6 +10,11 @@ class InputSchemaTest < ActiveSupport::TestCase assert_equal [:message], input_schema.required end + test "properties keys are converted to symbols" do + input_schema = InputSchema.new(properties: { "message" => { type: "string" } }, required: [:message]) + assert_equal({ message: { type: "string" } }, input_schema.properties) + end + test "to_h returns a hash representation of the input schema" do input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) assert_equal( @@ -17,16 +22,6 @@ class InputSchemaTest < ActiveSupport::TestCase input_schema.to_h, ) end - - test "missing_required_arguments returns an array of missing required arguments" do - input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) - assert_equal [:message], input_schema.missing_required_arguments({}) - end - - 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]) - assert_empty input_schema.missing_required_arguments({ message: "Hello, world!" }) - end end end end diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index f53352b..57c5749 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -5,28 +5,24 @@ module MCP class ToolTest < ActiveSupport::TestCase - class TestTool < Tool - tool_name "test_tool" - description "a test tool for testing" - input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) - annotations( + TestTool = Tool.define( + name: "test_tool", + description: "a test tool for testing", + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, + annotations: { title: "Test Tool", read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false, - ) - - class << self - def call(message:, server_context: nil) - Tool::Response.new([{ type: "text", content: "OK" }]) - end - end + }, + ) do + Tool::Response.new([{ type: "text", content: "OK" }]) end test "#to_h returns a hash with name, description, and inputSchema" do - tool = Tool.define(name: "mock_tool", description: "a mock tool for testing") - assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: {} } + tool = Tool.define(name: "mock_tool", description: "a mock tool for testing") {} + assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing" } end test "#to_h includes annotations when present" do @@ -43,41 +39,36 @@ def call(message:, server_context: nil) test "#call invokes the tool block and returns the response" do tool = TestTool - response = tool.call(message: "test") + response = tool.call({ message: "test" }, server_context: {}) assert_equal response.content, [{ type: "text", content: "OK" }] assert_equal response.is_error, false end - test "allows declarative definition of tools as classes" do - class MockTool < Tool - tool_name "my_mock_tool" - description "a mock tool for testing" - input_schema({ properties: { message: { type: "string" } }, required: [:message] }) + test "allows definition of tools with input schema" do + tool = Tool.define( + name: "my_mock_tool", + description: "a mock tool for testing", + input_schema: { properties: { message: { type: "string" } }, required: [:message] }, + ) do + Tool::Response.new([{ type: "text", content: "OK" }]) end - tool = MockTool - assert_equal tool.name_value, "my_mock_tool" + assert_equal tool.name, "my_mock_tool" assert_equal tool.description, "a mock tool for testing" assert_equal tool.input_schema.to_h, { type: "object", properties: { message: { type: "string" } }, required: [:message] } end - test "defaults to class name as tool name" do - class DefaultNameTool < Tool - end - - tool = DefaultNameTool - - assert_equal tool.tool_name, "default_name_tool" - end - test "accepts input schema as an InputSchema object" do - class InputSchemaTool < Tool - input_schema InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + input_schema = Tool::InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + tool = Tool.define( + name: "input_schema_tool", + description: "a test tool", + input_schema: input_schema, + ) do + Tool::Response.new([{ type: "text", content: "OK" }]) end - tool = InputSchemaTool - expected = { type: "object", properties: { message: { type: "string" } }, required: [:message] } assert_equal expected, tool.input_schema.to_h end @@ -87,7 +78,7 @@ class InputSchemaTool < Tool Tool::Response.new([{ type: "text", content: "OK" }]) end - assert_equal tool.name_value, "mock_tool" + assert_equal tool.name, "mock_tool" assert_equal tool.description, "a mock tool for testing" assert_equal tool.input_schema, nil end @@ -104,10 +95,10 @@ class InputSchemaTool < Tool Tool::Response.new([{ type: "text", content: "OK" }]) end - assert_equal tool.name_value, "mock_tool" + assert_equal tool.name, "mock_tool" assert_equal tool.description, "a mock tool for testing" assert_equal tool.input_schema, nil - assert_equal tool.annotations_value.to_h, { title: "Mock Tool", readOnlyHint: true } + assert_equal tool.annotations.to_h, { title: "Mock Tool", readOnlyHint: true } end # Tests for Tool::Annotations class @@ -176,55 +167,5 @@ class InputSchemaTool < Tool annotations = Tool::Annotations.new assert_empty annotations.to_h end - - test "Tool class method annotations can be set and retrieved" do - class AnnotationsTestTool < Tool - tool_name "annotations_test" - annotations( - title: "Annotations Test", - read_only_hint: true, - ) - end - - tool = AnnotationsTestTool - assert_instance_of Tool::Annotations, tool.annotations_value - assert_equal tool.annotations_value.title, "Annotations Test" - assert_equal tool.annotations_value.read_only_hint, true - end - - test "Tool class method annotations can be updated" do - class UpdatableAnnotationsTool < Tool - tool_name "updatable_annotations" - end - - tool = UpdatableAnnotationsTool - tool.annotations(title: "Initial") - assert_equal tool.annotations_value.title, "Initial" - - tool.annotations(title: "Updated") - assert_equal tool.annotations_value.title, "Updated" - end - - test "#call with Sorbet typed tools invokes the tool block and returns the response" do - class TypedTestTool < Tool - tool_name "test_tool" - description "a test tool for testing" - input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) - - class << self - extend T::Sig - - sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) } - def call(message:, server_context: nil) - Tool::Response.new([{ type: "text", content: "OK" }]) - end - end - end - - tool = TypedTestTool - response = tool.call(message: "test") - assert_equal response.content, [{ type: "text", content: "OK" }] - assert_equal response.is_error, false - end end end