diff --git a/.rubocop.yml b/.rubocop.yml index 9c5ae4e..8657204 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,2 +1,5 @@ inherit_gem: rubocop-shopify: rubocop.yml +Naming/FileName: + Exclude: + - 'lib/mcp-ruby.rb' diff --git a/Gemfile.lock b/Gemfile.lock index 57c3115..628866b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - model_context_protocol (0.1.1) + model_context_protocol (0.2.2) activesupport GEM diff --git a/README.md b/README.md index 3c9204c..ca0f1c3 100644 --- a/README.md +++ b/README.md @@ -59,43 +59,117 @@ end MCP spec includes [Tools](https://modelcontextprotocol.io/docs/concepts/tools) which provide functionality to LLM apps. -This gem provides a `ModelContextProtocol::Tool` class that can be instantiated to create tools. - -Tools can be passed into the `ModelContextProtocol::Server` constructor to register them with the server. - -### Example Tool Implementation - -The `Tool` class allows creating tools that can be used within the model context. -To create a tool, instantiate the class with the required parameters and an optional block: +This gem provides a `ModelContextProtocol::Tool` class that can be used to create tools in two ways: +1. As a class definition: ```ruby -tool = ModelContextProtocol::Tool.new(name: "my_tool", description: "This tool performs specific functionality...") do |args| - # Implement the tool's functionality here - result = process_something(args["parameter_name"]) - ModelContextProtocol::Tool::Response.new([{ type: "text", text: result }], false ) +class MyTool < ModelContextProtocol::Tool + tool_name "my_tool" + tool_description "This tool performs specific functionality..." + tool_input_schema [{ type: "text", name: "message" }] + + def call(message) + Tool::Response.new([{ type: "text", content: "OK" }]) + end +end + +tool = MyTool.new +``` + +2. By using the `ModelContextProtocol::Tool.define` method with a block: +```ruby +tool = ModelContextProtocol::Tool.define(name: "my_tool", description: "This tool performs specific functionality...") do |args| + Tool::Response.new([{ type: "text", content: "OK" }]) end ``` ## Prompts -MCP spec includes -[Prompts](https://modelcontextprotocol.io/docs/concepts/prompts), `Prompts` enable servers to define reusable prompt -templates and workflows that clients can easily surface to users and LLMs +MCP spec includes [Prompts](https://modelcontextprotocol.io/docs/concepts/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. -The `Prompt` class allows creating prompts that can be used within the model context. - -To create a prompt, instantiate the class with the required parameters and an optional block: +The `ModelContextProtocol::Prompt` class provides two ways to create prompts: +1. As a class definition with metadata: ```ruby -prompt = ModelContextProtocol::Prompt.new(name: "my_prompt", description: "This prompt performs specific functionality...") do |args| - # Implement the prompt's functionality here - result = template_something(args["parameter_name"]) - ModelContextProtocol::Prompt::Response.new([{ type: "text", text: result }], false ) +class MyPrompt < ModelContextProtocol::Prompt + prompt_name "my_prompt" # Optional - defaults to underscored class name + description "This prompt performs specific functionality..." + arguments [ + Prompt::Argument.new( + name: "message", + description: "Input message", + required: true + ) + ] + + def template(args) + Prompt::Result.new( + description: "Response description", + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User message") + ), + Prompt::Message.new( + role: "assistant", + content: Content::Text.new(args["message"]) + ) + ] + ) + end end ``` +2. Using the `ModelContextProtocol::Prompt.define` method: +```ruby +prompt = ModelContextProtocol::Prompt.define( + name: "my_prompt", + description: "This prompt performs specific functionality...", + arguments: [ + Prompt::Argument.new( + name: "message", + description: "Input message", + required: true + ) + ] +) do |args| + Prompt::Result.new( + description: "Response description", + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User message") + ), + Prompt::Message.new( + role: "assistant", + content: Content::Text.new(args["message"]) + ) + ] + ) +end +``` +### Key Components +- `Prompt::Argument` - Defines input parameters for the prompt template +- `Prompt::Message` - Represents a message in the conversation with a role and content +- `Prompt::Result` - The output of a prompt template containing description and messages +- `Content::Text` - Text content for messages + +### Usage + +Register prompts with the MCP server: + +```ruby +server = ModelContextProtocol::Server.new( + name: "my_server", + prompts: [MyPrompt.new] +) +``` + +The server will handle prompt listing and execution through the MCP protocol methods: +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves and executes a specific prompt with arguments ## Releases diff --git a/lib/mcp-ruby.rb b/lib/mcp-ruby.rb new file mode 100644 index 0000000..1c09d09 --- /dev/null +++ b/lib/mcp-ruby.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "model_context_protocol" diff --git a/lib/model_context_protocol.rb b/lib/model_context_protocol.rb index 9ddf047..56d0763 100644 --- a/lib/model_context_protocol.rb +++ b/lib/model_context_protocol.rb @@ -7,6 +7,7 @@ require "model_context_protocol/tool" require "model_context_protocol/content" require "model_context_protocol/resource" require "model_context_protocol/prompt" +require "model_context_protocol/version" module ModelContextProtocol class Annotations diff --git a/lib/model_context_protocol/prompt.rb b/lib/model_context_protocol/prompt.rb index befff83..a4b3cab 100644 --- a/lib/model_context_protocol/prompt.rb +++ b/lib/model_context_protocol/prompt.rb @@ -44,19 +44,59 @@ module ModelContextProtocol end end + class << self + attr_reader :description_value + attr_reader :arguments_value + + 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) + @name_value = value + end + + def name_value + @name_value || name.demodulize.underscore + end + + def description(value) + @description_value = value + end + + def arguments(value) + @arguments_value = value + end + + def define(name: nil, description: nil, arguments: [], &block) + new(name:, description:, arguments:).tap do |prompt| + prompt.define_singleton_method(:template) do |args| + instance_exec(args, &block) + end + end + end + end + attr_reader :name, :description, :arguments - def initialize(name:, description: nil, arguments: [], &block) - @name = name - @description = description - @arguments = arguments - @template_block = block + def initialize(name: nil, description: nil, arguments: nil) + @name = name || self.class.name_value + @description = description || self.class.description_value + @arguments = arguments || self.class.arguments_value end def template(args) - validate_args!(args) - result = @template_block.call(args) - result + raise NotImplementedError, "Prompt subclasses must implement template" + end + + def validate_arguments!(args) + missing = required_args - args.keys + return if missing.empty? + + raise ArgumentError, "Missing required arguments: #{missing.join(", ")}" end def to_h @@ -68,12 +108,5 @@ module ModelContextProtocol def required_args arguments.filter_map { |arg| arg.name if arg.required } end - - def validate_args!(args) - missing = required_args - args.keys - return if missing.empty? - - raise ArgumentError, "Missing required arguments: #{missing.join(", ")}" - end end end diff --git a/lib/model_context_protocol/resource.rb b/lib/model_context_protocol/resource.rb index 2a33343..0e02ff1 100644 --- a/lib/model_context_protocol/resource.rb +++ b/lib/model_context_protocol/resource.rb @@ -2,57 +2,25 @@ # frozen_string_literal: true module ModelContextProtocol - module Resource - class Embedded - attr_reader :resource, :annotations + class Resource + attr_reader :uri, :name, :description, :mime_type, :contents - def initialize(resource:, annotations: nil) - @resource = resource - @annotations = annotations - end - - def to_h - { resource: resource.to_h, annotations: }.compact - end + def initialize(uri:, name:, description:, mime_type:, contents:) + @uri = uri + @name = name + @description = description + @mime_type = mime_type + @contents = contents end - class Contents - attr_reader :uri, :mime_type - - def initialize(uri:, mime_type: nil) - @uri = uri - @mime_type = mime_type - end - - def to_h - { uri:, mime_type: }.compact - end - end - - class TextContents < Contents - attr_reader :text - - def initialize(text:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) - @text = text - end - - def to_h - super.merge(text: text) - end - end - - class BlobContents < Contents - attr_reader :data - - def initialize(data:, uri:, mime_type:) - super(uri: uri, mime_type: mime_type) - @data = data - end - - def to_h - super.merge(data: data) - end + def to_h + { + uri: @uri, + name: @name, + description: @description, + mimeType: @mime_type, + contents: @contents.map(&:to_h), + } end end end diff --git a/lib/model_context_protocol/resource/contents.rb b/lib/model_context_protocol/resource/contents.rb new file mode 100644 index 0000000..34282f3 --- /dev/null +++ b/lib/model_context_protocol/resource/contents.rb @@ -0,0 +1,45 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class Resource + class Contents + attr_reader :uri, :mime_type + + def initialize(uri:, mime_type: nil) + @uri = uri + @mime_type = mime_type + end + + def to_h + { uri:, mime_type: }.compact + end + end + + class TextContents < Contents + attr_reader :text + + def initialize(text:, uri:, mime_type:) + super(uri: uri, mime_type: mime_type) + @text = text + end + + def to_h + super.merge(text: text) + end + end + + class BlobContents < Contents + attr_reader :data + + def initialize(data:, uri:, mime_type:) + super(uri: uri, mime_type: mime_type) + @data = data + end + + def to_h + super.merge(data: data) + end + end + end +end diff --git a/lib/model_context_protocol/resource/embedded.rb b/lib/model_context_protocol/resource/embedded.rb new file mode 100644 index 0000000..9449d8b --- /dev/null +++ b/lib/model_context_protocol/resource/embedded.rb @@ -0,0 +1,18 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class Resource + class Embedded + attr_reader :resource, :annotations + + def initialize(resource:, annotations: nil) + @annotations = annotations + end + + def to_h + { resource: resource.to_h, annotations: }.compact + end + end + end +end diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb index eda3152..bd66b3d 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/model_context_protocol/server.rb @@ -4,12 +4,25 @@ module ModelContextProtocol class Server PROTOCOL_VERSION = "2024-11-05" - attr_accessor :name, :tools, :prompts + attr_accessor :name, :tools, :prompts, :resources - def initialize(name: "model_context_protocol", tools: [], prompts: []) + def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: []) @name = name @tools = tools.to_h { |t| [t.name, t] } @prompts = prompts.to_h { |p| [p.name, p] } + @resources = resources + @resource_index = resources.index_by(&:uri) + + @handlers = { + "resources/list" => method(:list_resources), + "resources/read" => method(:read_resource), + "tools/list" => method(:list_tools), + "tools/call" => method(:call_tool), + "prompts/list" => method(:list_prompts), + "prompts/get" => method(:get_prompt), + "initialize" => method(:init), + "ping" => method(:ping), + } end def handle(request) @@ -20,7 +33,7 @@ module ModelContextProtocol if parsed_request.notification? handle_notification(parsed_request) else - call_method(parsed_request) + handle_method(parsed_request) end rescue JsonRPC::Error => e JsonRPC::Response.new(id: parsed_request&.id, error: e) @@ -29,35 +42,32 @@ module ModelContextProtocol response end + def resources_list_handler(&block) + @handlers["resources/list"] = block + end + + def resources_read_handler(&block) + @handlers["resources/read"] = block + end + + def tools_list_handler(&block) + @handlers["tools/list"] = block + end + + def tools_call_handler(&block) + @handlers["tools/call"] = block + end + + def prompts_list_handler(&block) + @handlers["prompts/list"] = block + end + + def prompts_get_handler(&block) + @handlers["prompts/get"] = block + end + private - def handle_notification(request) - nil - end - - def call_method(request) - case request.method - when "initialize" - JsonRPC::Response.new(id: request.id, result: { - protocolVersion: PROTOCOL_VERSION, - capabilities: capabilities, - serverInfo: server_info, - }) - when "ping" - JsonRPC::Response.new(id: request.id, result: "pong") - when "tools/list" - list_tools(request) - when "tools/call" - call_tool(request) - when "prompts/list" - list_prompts(request) - when "prompts/get" - get_prompt(request) - else - raise JsonRPC::MethodNotFoundError.new(message: "Method not found #{request.method}") - end - end - def capabilities @capabilities ||= { experimental: nil, @@ -76,6 +86,41 @@ module ModelContextProtocol } end + def handle_notification(request) + nil + end + + def handle_method(request) + request_handler = @handlers[request.method] + raise JsonRPC::MethodNotFoundError.new(message: "Method not found #{request.method}") unless request_handler + + result = request_handler.call(request) + wrapped_result = case request.method + when "tools/list" + { tools: result } + when "prompts/list" + { prompts: result } + when "resources/list" + { resources: result } + else + result + end + + JsonRPC::Response.new(id: request.id, result: wrapped_result) + end + + def init(request) + { + protocolVersion: PROTOCOL_VERSION, + capabilities: capabilities, + serverInfo: server_info, + } + end + + def ping(request) + "pong" + end + def call_tool(request) tool_name = request.params&.dig("name") tool = tools[tool_name] @@ -89,15 +134,15 @@ module ModelContextProtocol raise JsonRPC::InternalError.new(message: e.message) end - JsonRPC::Response.new(id: request.id, result: result.to_h) + result.to_h end def list_tools(request) - JsonRPC::Response.new(id: request.id, result: { tools: @tools.map { |_, tool| tool.to_h } }) + @tools.map { |_, tool| tool.to_h } end def list_prompts(request) - JsonRPC::Response.new(id: request.id, result: { prompts: @prompts.map { |_, prompt| prompt.to_h } }) + @prompts.map { |_, prompt| prompt.to_h } end def get_prompt(request) @@ -106,12 +151,28 @@ module ModelContextProtocol raise JsonRPC::MethodNotFoundError.new(message: "Prompt not found #{prompt_name}") unless prompt begin - result = prompt.template(request.params&.dig("arguments") || {}) + prompt_args = request.params&.dig("arguments") + prompt.validate_arguments!(prompt_args) + + result = prompt.template(prompt_args) rescue ArgumentError => e raise JsonRPC::InvalidParamsError.new(message: e.message) end - JsonRPC::Response.new(id: request.id, result: result.to_h) + result.to_h + end + + def list_resources(request) + @resources.map(&:to_h) + end + + def read_resource(request) + resource_uri = request.params&.dig("uri") + + resource = @resource_index[resource_uri] + raise JsonRPC::MethodNotFoundError.new(message: "Resource not found #{resource_uri}") unless resource + + resource.to_h end end end diff --git a/lib/model_context_protocol/tool.rb b/lib/model_context_protocol/tool.rb index 5ce5da3..a22d452 100644 --- a/lib/model_context_protocol/tool.rb +++ b/lib/model_context_protocol/tool.rb @@ -15,17 +15,52 @@ module ModelContextProtocol end end + class << self + attr_reader :description_value + attr_reader :input_schema_value + + 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) + end + + def tool_name(value) + @name_value = value + end + + def name_value + @name_value || name.demodulize.underscore + end + + def description(value) + @description_value = value + end + + def input_schema(value) + @input_schema_value = value + end + + def define(name: nil, description: nil, input_schema: nil, &block) + new(name:, description:, input_schema:).tap do |tool| + tool.define_singleton_method(:call) do |*args| + instance_exec(*args, &block) + end + end + end + end + attr_reader :name, :description, :input_schema - def initialize(name:, description: nil, input_schema: nil, &block) - @name = name - @description = description - @input_schema = input_schema - @tool_block = block_given? ? block : -> { Response.new([{ type: "text", content: "OK" }]) } + def initialize(name: nil, description: nil, input_schema: nil) + @name = name || self.class.name_value + @description = description || self.class.description_value + @input_schema = input_schema || self.class.input_schema_value end def call(*args) - @tool_block.call(*args) + raise NotImplementedError, "Subclasses must implement call" end def to_h diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb index 7a7f378..7a6743c 100644 --- a/lib/model_context_protocol/version.rb +++ b/lib/model_context_protocol/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ModelContextProtocol - VERSION = "0.1.1" + VERSION = "0.2.2" end diff --git a/test/model_context_protocol/prompt_test.rb b/test/model_context_protocol/prompt_test.rb index 716c412..c5129fa 100644 --- a/test/model_context_protocol/prompt_test.rb +++ b/test/model_context_protocol/prompt_test.rb @@ -5,27 +5,13 @@ require "test_helper" module ModelContextProtocol class PromptTest < ActiveSupport::TestCase - test "#template validates arguments" do - prompt = Prompt.new( - name: "test_prompt", - description: "Test prompt", - arguments: [ - Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), - ], - ) - assert_raises(ArgumentError) do - prompt.template({}) - end - end + class TestPrompt < Prompt + description "Test prompt" + arguments [ + Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), + ] - test "#template returns a Result with description and messages when arguments are valid" do - prompt = Prompt.new( - name: "test_prompt", - description: "Test prompt", - arguments: [ - Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), - ], - ) do |_| + def template(args) Prompt::Result.new( description: "Hello, world!", messages: [ @@ -34,6 +20,108 @@ module ModelContextProtocol ], ) end + end + + test "#template returns a Result with description and messages" do + prompt = TestPrompt.new + + expected_template_result = { + description: "Hello, world!", + messages: [ + { role: "user", content: { text: "Hello, world!" } }, + { role: "assistant", content: { text: "Hello, friend!" } }, + ], + } + + result = prompt.template({ "test_argument" => "Hello, friend!" }) + + 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), + ] + + def template(args) + 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 + + prompt = MockPrompt.new + + assert_equal "my_mock_prompt", prompt.name + 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_equal true, prompt.arguments.first.required + + expected_template_result = { + description: "Hello, world!", + messages: [ + { role: "user", content: { text: "Hello, world!" } }, + { role: "assistant", content: { text: "Hello, friend!" } }, + ], + } + + result = prompt.template({ "test_argument" => "Hello, friend!" }) + assert_equal expected_template_result, result.to_h + end + + 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), + ] + + def template(args) + 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 + + prompt = DefaultNamePrompt.new + + assert_equal "default_name_prompt", prompt.name + assert_equal "a mock prompt for testing", prompt.description + assert_equal "test_argument", prompt.arguments.first.name + end + + test ".define allows definition of simple prompts with a block" do + prompt = Prompt.define( + name: "mock_prompt", + description: "a mock prompt for testing", + arguments: [ + Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), + ], + ) do |args| + 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 + + assert_equal "mock_prompt", prompt.name + assert_equal "a mock prompt for testing", prompt.description + assert_equal "test_argument", prompt.arguments.first.name expected = { description: "Hello, world!", @@ -44,7 +132,6 @@ module ModelContextProtocol } result = prompt.template({ "test_argument" => "Hello, friend!" }) - assert_equal expected, result.to_h end end diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb index 6d19753..459d140 100644 --- a/test/model_context_protocol/server_test.rb +++ b/test/model_context_protocol/server_test.rb @@ -7,7 +7,7 @@ module ModelContextProtocol setup do @tool = Tool.new(name: "test_tool", description: "Test tool", input_schema: {}) - @prompt = Prompt.new( + @prompt = Prompt.define( name: "test_prompt", description: "Test prompt", arguments: [ @@ -22,11 +22,19 @@ module ModelContextProtocol ) end + @resource = Resource.new( + uri: "test_resource", + name: "Test resource", + description: "Test resource", + mime_type: "text/plain", + contents: [Content::Text.new("Hello, world!")], + ) + @server_name = "test_server" - @server = Server.new(name: @server_name, tools: [@tool], prompts: [@prompt]) + @server = Server.new(name: @server_name, tools: [@tool], prompts: [@prompt], resources: [@resource]) end - test "ping request returns pong" do + test "#handle ping request returns pong" do request = { jsonrpc: "2.0", method: "ping", @@ -40,7 +48,7 @@ module ModelContextProtocol assert_equal 1, response.id end - test "initialize request returns protocol info, server info, and capabilities" do + test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", method: "initialize", @@ -58,7 +66,22 @@ module ModelContextProtocol assert_equal ModelContextProtocol::VERSION, result[:serverInfo][:version] end - test "tools/list returns available tools" do + test "#handle returns nil for notification requests" do + request = { + jsonrpc: "2.0", + method: "some_notification", + }.to_json + + assert_nil @server.handle(request) + end + + test "#handle returns error response for invalid requests" do + response = @server.handle("invalid json") + assert_instance_of JsonRPC::Response, response + assert_instance_of(JsonRPC::ParseError, response.error) + end + + test "#handle tools/list returns available tools" do request = { jsonrpc: "2.0", method: "tools/list", @@ -72,16 +95,22 @@ module ModelContextProtocol assert_equal({}, response.result[:tools][0][:inputSchema]) end - test "returns nil for notification requests" do + test "#tools_list_handler sets the tools/list handler" do + @server.tools_list_handler do + [{ name: "hammer", description: "Hammer time!" }] + end + request = { jsonrpc: "2.0", - method: "some_notification", + method: "tools/list", + id: 1, }.to_json - assert_nil @server.handle(request) + response = @server.handle(request) + assert_equal({ tools: [{ name: "hammer", description: "Hammer time!" }] }, response.result) end - test "tools/call executes tool and returns result" do + 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" }]) @@ -102,7 +131,7 @@ module ModelContextProtocol assert_equal tool_response.to_h, response.result end - test "tools/call returns error if the tool raises an error" do + test "#handle tools/call returns error if the tool raises an error" do @tool.expects(:call).raises(StandardError.new("Tool error")) request = { @@ -121,13 +150,7 @@ module ModelContextProtocol assert_equal "Tool error", response.error&.message end - test "returns error response for invalid requests" do - response = @server.handle("invalid json") - assert_instance_of JsonRPC::Response, response - assert_instance_of(JsonRPC::ParseError, response.error) - end - - test "tools/call returns error for unknown tool" do + test "#handle tools/call returns error for unknown tool" do request = { jsonrpc: "2.0", method: "tools/call", @@ -143,7 +166,24 @@ module ModelContextProtocol assert_instance_of(JsonRPC::MethodNotFoundError, response.error) end - test "prompts/list returns list of prompts" do + test "#tools_call_handler sets the tools/call handler" do + @server.tools_call_handler do |request| + tool_name = request.params&.dig("name") + return Tool::Response.new("#{tool_name} called successfully").to_h + end + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "my_tool", arguments: {} }, + id: 1, + }.to_json + + response = @server.handle(request) + assert_equal({ content: "my_tool called successfully", is_error: false }, response.result) + end + + test "#handle prompts/list returns list of prompts" do request = { jsonrpc: "2.0", method: "prompts/list", @@ -154,7 +194,22 @@ module ModelContextProtocol assert_equal({ prompts: [@prompt.to_h] }, response.result) end - test "prompts/get returns templated prompt" do + test "#prompts_list_handler sets the prompts/list handler" do + @server.prompts_list_handler do + [{ name: "foo_prompt", description: "Foo prompt" }] + end + + request = { + jsonrpc: "2.0", + method: "prompts/list", + id: 1, + }.to_json + + response = @server.handle(request) + assert_equal({ prompts: [{ name: "foo_prompt", description: "Foo prompt" }] }, response.result) + end + + test "#handle prompts/get returns templated prompt" do request = { jsonrpc: "2.0", method: "prompts/get", @@ -177,7 +232,7 @@ module ModelContextProtocol assert_equal(expected_result, response.result) end - test "prompts/get returns error if prompt is not found" do + test "#handle prompts/get returns error if prompt is not found" do request = { jsonrpc: "2.0", method: "prompts/get", @@ -193,7 +248,7 @@ module ModelContextProtocol assert_instance_of(JsonRPC::MethodNotFoundError, response.error) end - test "prompts/get returns error if prompt arguments are invalid" do + test "#handle prompts/get returns error if prompt arguments are invalid" do request = { jsonrpc: "2.0", method: "prompts/get", @@ -210,11 +265,129 @@ module ModelContextProtocol assert_equal "Missing required arguments: test_argument", response.error.message end - test "unknown method returns method not found error" do + test "#prompts_get_handler sets the prompts/get handler" do + @server.prompts_get_handler do |request| + prompt_name = request.params&.dig("name") + return Prompt::Result.new( + description: prompt_name, + messages: [ + Prompt::Message.new(role: "user", content: Content::Text.new(request.params&.dig("arguments", "foo"))), + ], + ).to_h + end + request = { jsonrpc: "2.0", - method: "unknown_method", + method: "prompts/get", id: 1, + params: { name: "foo_bar_prompt", arguments: { "foo" => "bar" } }, + }.to_json + + response = @server.handle(request) + assert_equal( + { description: "foo_bar_prompt", messages: [{ role: "user", content: { text: "bar" } }] }, + response.result, + ) + end + + test "#handle resources/list returns a list of resources" do + request = { + jsonrpc: "2.0", + method: "resources/list", + id: 1, + }.to_json + + response = @server.handle(request) + assert_equal({ resources: [@resource.to_h] }, response.result) + end + + test "#resources_list_handler sets the resources/list handler" do + @server.resources_list_handler do + [{ uri: "test_resource", name: "Test resource", description: "Test resource" }] + end + + request = { + jsonrpc: "2.0", + method: "resources/list", + id: 1, + }.to_json + + response = @server.handle(request) + assert_equal( + { resources: [{ uri: "test_resource", name: "Test resource", description: "Test resource" }] }, + response.result, + ) + end + + test "#handle resources/read returns a resource" do + request = { + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { + uri: @resource.uri, + }, + }.to_json + + response = @server.handle(request) + assert_equal(@resource.to_h, response.result) + end + + test "#handle resources/read returns error if resource is not found" do + request = { + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { + uri: "unknown_resource", + }, + }.to_json + + response = @server.handle(request) + assert_instance_of(JsonRPC::Response, response) + assert_equal "Resource not found unknown_resource", response.error.message + end + + test "#resources_read_handler sets the resources/read handler" do + @server.resources_read_handler do |request| + uri = request.params&.dig("uri") + + Resource.new( + uri: uri, + name: "Test resource", + description: "Test resource", + mime_type: "text/plain", + contents: [Content::Text.new("Lorem ipsum dolor sit amet")], + ).to_h + end + + request = { + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { + uri: "test_resource", + }, + }.to_json + + response = @server.handle(request) + assert_equal( + { + uri: "test_resource", + name: "Test resource", + description: "Test resource", + mimeType: "text/plain", + contents: [{ text: "Lorem ipsum dolor sit amet" }], + }, + response.result, + ) + end + + test "#handle unknown method returns method not found error" do + request = { + jsonrpc: "2.0", + id: 1, + method: "unknown_method", }.to_json response = @server.handle(request) diff --git a/test/model_context_protocol/tool_test.rb b/test/model_context_protocol/tool_test.rb index e095039..2800ab2 100644 --- a/test/model_context_protocol/tool_test.rb +++ b/test/model_context_protocol/tool_test.rb @@ -4,19 +4,62 @@ require "test_helper" module ModelContextProtocol class ToolTest < ActiveSupport::TestCase + class TestTool < Tool + tool_name "test_tool" + description "a test tool for testing" + input_schema [{ type: "text", name: "message" }] + + def call(message) + Tool::Response.new([{ type: "text", content: "OK" }]) + end + end + test "#to_h returns a hash with name, description, and inputSchema" do tool = Tool.new(name: "mock_tool", description: "a mock tool for testing") assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: nil } end test "#call invokes the tool block and returns the response" do - tool = Tool.new(name: "mock_tool", description: "a mock tool for testing") do |_| - Tool::Response.new([{ type: "text", content: "OK" }]) - end - - response = tool.call + tool = TestTool.new + response = tool.call("test") 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 [{ type: "text", name: "message" }] + end + + tool = MockTool.new + assert_equal tool.name, "my_mock_tool" + assert_equal tool.description, "a mock tool for testing" + assert_equal tool.input_schema, [{ type: "text", name: "message" }] + end + + test "defaults to class name as tool name" do + class DefaultNameTool < Tool + description "a mock tool for testing" + input_schema [{ type: "text", name: "message" }] + end + + tool = DefaultNameTool.new + + assert_equal tool.name, "default_name_tool" + assert_equal tool.description, "a mock tool for testing" + assert_equal tool.input_schema, [{ type: "text", name: "message" }] + end + + test ".define allows definition of simple tools with a block" do + tool = Tool.define(name: "mock_tool", description: "a mock tool for testing") do |_| + Tool::Response.new([{ type: "text", content: "OK" }]) + end + + assert_equal tool.name, "mock_tool" + assert_equal tool.description, "a mock tool for testing" + assert_equal tool.input_schema, nil + end end end