diff --git a/README.md b/README.md index 615c4c4..33be366 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,9 @@ require "mcp" require "mcp/server/transports/stdio_transport" # Create a simple tool -class ExampleTool < MCP::Tool +module ExampleTool + extend MCP::Tool + description "A simple example tool that echoes back its arguments" input_schema( properties: { @@ -275,7 +277,9 @@ This gem provides a `MCP::Tool` class that can be used to create tools in two wa 1. As a class definition: ```ruby -class MyTool < MCP::Tool +module MyTool + extend MCP::Tool + description "This tool performs specific functionality..." input_schema( properties: { @@ -338,7 +342,9 @@ The `MCP::Prompt` class provides two ways to create prompts: 1. As a class definition with metadata: ```ruby -class MyPrompt < MCP::Prompt +module MyPrompt + extend MCP::Prompt + prompt_name "my_prompt" # Optional - defaults to underscored class name description "This prompt performs specific functionality..." arguments [ diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index 7624ef5..3f53654 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -2,58 +2,16 @@ # frozen_string_literal: true module MCP - class Prompt - class << self - NOT_SET = Object.new - - attr_reader :description_value - attr_reader :arguments_value - - def template(args, server_context:) - raise NotImplementedError, "Subclasses must implement template" - end - - def to_h - { name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact - end - - 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 + module Prompt + NOT_SET = Object.new - 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 - end + attr_reader :description_value + attr_reader :arguments_value + class << self def define(name: nil, description: nil, arguments: [], &block) - Class.new(self) do + Module.new do + extend Prompt prompt_name name description description arguments arguments @@ -62,21 +20,64 @@ def define(name: nil, description: nil, arguments: [], &block) end end end + end - def validate_arguments!(args) - missing = required_args - args.keys - return if missing.empty? + def template(args, server_context:) + raise NotImplementedError, "Prompts must implement template" + end + + def to_h + { name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact + end - raise MCP::Server::RequestHandlerError.new( - "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments - ) + def extended(mod) + super + mod.instance_variable_set(:@name_value, nil) + mod.instance_variable_set(:@description_value, nil) + mod.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 - private + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end + end - def required_args - arguments_value.filter_map { |arg| arg.name.to_sym if arg.required } + def arguments(value = NOT_SET) + if value == NOT_SET + @arguments_value + else + @arguments_value = value end end + + def validate_arguments!(args) + missing = required_args - args.keys + return if missing.empty? + + raise MCP::Server::RequestHandlerError.new( + "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments + ) + end + + private + + def required_args + arguments_value.filter_map { |arg| arg.name.to_sym if arg.required } + end end end diff --git a/lib/mcp/prompt/argument.rb b/lib/mcp/prompt/argument.rb index 2a22fd4..3e71e19 100644 --- a/lib/mcp/prompt/argument.rb +++ b/lib/mcp/prompt/argument.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true module MCP - class Prompt + module Prompt class Argument attr_reader :name, :description, :required, :arguments diff --git a/lib/mcp/prompt/message.rb b/lib/mcp/prompt/message.rb index b5c94f9..118668f 100644 --- a/lib/mcp/prompt/message.rb +++ b/lib/mcp/prompt/message.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true module MCP - class Prompt + module Prompt class Message attr_reader :role, :content diff --git a/lib/mcp/prompt/result.rb b/lib/mcp/prompt/result.rb index 693885f..6fd631c 100644 --- a/lib/mcp/prompt/result.rb +++ b/lib/mcp/prompt/result.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true module MCP - class Prompt + module Prompt class Result attr_reader :description, :messages diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 48378e4..2c85265 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -1,84 +1,85 @@ # frozen_string_literal: true module MCP - class Tool - class << self - NOT_SET = Object.new + module Tool + NOT_SET = Object.new - attr_reader :description_value - attr_reader :input_schema_value - attr_reader :annotations_value + attr_reader :description_value + attr_reader :input_schema_value + attr_reader :annotations_value - def call(*args, server_context:) - raise NotImplementedError, "Subclasses must implement call" + class << self + def define(name: nil, description: nil, input_schema: nil, annotations: nil, &block) + Module.new do + extend Tool + tool_name name + description description + input_schema input_schema + annotations annotations if annotations + define_singleton_method(:call, &block) if block + end end + end - 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 + def call(*args, server_context:) + raise NotImplementedError, "Tools must implement call" + end - 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 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 - def tool_name(value = NOT_SET) - if value == NOT_SET - name_value - else - @name_value = value - end - end + def extended(mod) + super + mod.instance_variable_set(:@name_value, nil) + mod.instance_variable_set(:@description_value, nil) + mod.instance_variable_set(:@input_schema_value, nil) + mod.instance_variable_set(:@annotations_value, nil) + end - def name_value - @name_value || StringUtils.handle_from_class_name(name) + def tool_name(value = NOT_SET) + if value == NOT_SET + name_value + else + @name_value = value end + end - def description(value = NOT_SET) - if value == NOT_SET - @description_value - else - @description_value = value - end - end + def name_value + @name_value || StringUtils.handle_from_class_name(name) + 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 + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value end + end - def annotations(hash = NOT_SET) - if hash == NOT_SET - @annotations_value - else - @annotations_value = Annotations.new(**hash) - 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 + 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 + def annotations(hash = NOT_SET) + if hash == NOT_SET + @annotations_value + else + @annotations_value = Annotations.new(**hash) end end end diff --git a/lib/mcp/tool/annotations.rb b/lib/mcp/tool/annotations.rb index 6344334..9f4c377 100644 --- a/lib/mcp/tool/annotations.rb +++ b/lib/mcp/tool/annotations.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MCP - class Tool + module Tool class Annotations attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint diff --git a/lib/mcp/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb index 4683b7e..75a167d 100644 --- a/lib/mcp/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MCP - class Tool + module Tool class InputSchema attr_reader :properties, :required diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index abf6ff4..5881916 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MCP - class Tool + module Tool class Response attr_reader :content, :is_error diff --git a/test/mcp/prompt_test.rb b/test/mcp/prompt_test.rb index 7200d21..acdb661 100644 --- a/test/mcp/prompt_test.rb +++ b/test/mcp/prompt_test.rb @@ -5,7 +5,9 @@ module MCP class PromptTest < ActiveSupport::TestCase - class TestPrompt < Prompt + class TestPrompt + extend Prompt + description "Test prompt" arguments [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), @@ -41,7 +43,9 @@ def template(args, server_context:) end test "allows declarative definition of prompts as classes" do - class MockPrompt < Prompt + class MockPrompt + extend Prompt + prompt_name "my_mock_prompt" description "a mock prompt for testing" arguments [ @@ -82,7 +86,9 @@ def template(args, server_context:) end test "defaults to class name as prompt name" do - class DefaultNamePrompt < Prompt + class DefaultNamePrompt + extend Prompt + description "a mock prompt for testing" arguments [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 6dc56ee..c8a24e9 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -258,7 +258,9 @@ class ServerTest < ActiveSupport::TestCase end test "#handle_json tools/call executes tool and returns result, when the tool is typed with Sorbet" do - class TypedTestTool < Tool + class TypedTestTool + extend Tool + tool_name "test_tool" description "a test tool for testing" input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) diff --git a/test/mcp/tool/input_schema_test.rb b/test/mcp/tool/input_schema_test.rb index 56463bd..0a91e12 100644 --- a/test/mcp/tool/input_schema_test.rb +++ b/test/mcp/tool/input_schema_test.rb @@ -3,7 +3,7 @@ require "test_helper" module MCP - class Tool + module Tool class InputSchemaTest < ActiveSupport::TestCase test "required arguments are converted to symbols" do input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) diff --git a/test/mcp/tool_test.rb b/test/mcp/tool_test.rb index f53352b..259f447 100644 --- a/test/mcp/tool_test.rb +++ b/test/mcp/tool_test.rb @@ -5,7 +5,9 @@ module MCP class ToolTest < ActiveSupport::TestCase - class TestTool < Tool + class TestTool + extend Tool + tool_name "test_tool" description "a test tool for testing" input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) @@ -49,7 +51,9 @@ def call(message:, server_context: nil) end test "allows declarative definition of tools as classes" do - class MockTool < Tool + class MockTool + extend Tool + tool_name "my_mock_tool" description "a mock tool for testing" input_schema({ properties: { message: { type: "string" } }, required: [:message] }) @@ -63,7 +67,8 @@ class MockTool < Tool end test "defaults to class name as tool name" do - class DefaultNameTool < Tool + class DefaultNameTool + extend Tool end tool = DefaultNameTool @@ -72,8 +77,10 @@ class DefaultNameTool < Tool end test "accepts input schema as an InputSchema object" do - class InputSchemaTool < Tool - input_schema InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + class InputSchemaTool + extend Tool + + input_schema Tool::InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) end tool = InputSchemaTool @@ -178,7 +185,9 @@ class InputSchemaTool < Tool end test "Tool class method annotations can be set and retrieved" do - class AnnotationsTestTool < Tool + class AnnotationsTestTool + extend Tool + tool_name "annotations_test" annotations( title: "Annotations Test", @@ -193,7 +202,9 @@ class AnnotationsTestTool < Tool end test "Tool class method annotations can be updated" do - class UpdatableAnnotationsTool < Tool + class UpdatableAnnotationsTool + extend Tool + tool_name "updatable_annotations" end @@ -206,7 +217,9 @@ class UpdatableAnnotationsTool < Tool end test "#call with Sorbet typed tools invokes the tool block and returns the response" do - class TypedTestTool < Tool + class TypedTestTool + extend Tool + tool_name "test_tool" description "a test tool for testing" input_schema({ properties: { message: { type: "string" } }, required: ["message"] })