From baeb99ffbc79aeb3d7057c3af9267160006154a2 Mon Sep 17 00:00:00 2001 From: Tyler Rowsell Date: Thu, 8 May 2025 10:11:20 -0400 Subject: [PATCH] Internal Release 0.7.0 - add support for resource templates - improve capability negotiation on handlers - clean up readme - reduce default protocol version to "2024-11-05" - address spec compliance of resources - add debug gem to development - alias ModelContextProtocol to MCP - move prompt subclasses to subdir - improve tool and prompt definition - improve passing of server context to tools - improve tool input schema validation --- Gemfile | 1 + Gemfile.lock | 23 +- README.md | 211 ++++++++++------ examples/stdio_server.rb | 79 +++++- lib/model_context_protocol.rb | 11 + lib/model_context_protocol/configuration.rb | 2 +- lib/model_context_protocol/methods.rb | 84 ++++++- lib/model_context_protocol/prompt.rb | 41 ---- lib/model_context_protocol/prompt/argument.rb | 21 ++ lib/model_context_protocol/prompt/message.rb | 19 ++ lib/model_context_protocol/prompt/result.rb | 19 ++ lib/model_context_protocol/resource.rb | 6 +- .../resource_template.rb | 24 ++ lib/model_context_protocol/server.rb | 87 +++++-- lib/model_context_protocol/tool.rb | 49 +--- .../tool/annotations.rb | 27 ++ .../tool/input_schema.rb | 26 ++ lib/model_context_protocol/tool/response.rb | 18 ++ lib/model_context_protocol/version.rb | 2 +- test/model_context_protocol/methods_test.rb | 68 +++++ test/model_context_protocol/server_test.rb | 232 ++++++++++++++---- .../tool/input_schema_test.rb | 32 +++ test/model_context_protocol/tool_test.rb | 27 +- 23 files changed, 849 insertions(+), 260 deletions(-) create mode 100644 lib/model_context_protocol/prompt/argument.rb create mode 100644 lib/model_context_protocol/prompt/message.rb create mode 100644 lib/model_context_protocol/prompt/result.rb create mode 100644 lib/model_context_protocol/resource_template.rb create mode 100644 lib/model_context_protocol/tool/annotations.rb create mode 100644 lib/model_context_protocol/tool/input_schema.rb create mode 100644 lib/model_context_protocol/tool/response.rb create mode 100644 test/model_context_protocol/methods_test.rb create mode 100644 test/model_context_protocol/tool/input_schema_test.rb diff --git a/Gemfile b/Gemfile index 9454344..ecf15d1 100644 --- a/Gemfile +++ b/Gemfile @@ -12,3 +12,4 @@ gem "rubocop-shopify", require: false gem "minitest-reporters" gem "mocha" +gem "debug" diff --git a/Gemfile.lock b/Gemfile.lock index 6391669..a54b619 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - model_context_protocol (0.6.0) + model_context_protocol (0.7.0) json_rpc_handler (~> 0.1) GEM @@ -28,9 +28,18 @@ GEM builder (3.3.0) concurrent-ruby (1.3.5) connection_pool (2.5.3) + date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) drb (2.2.1) i18n (1.14.7) concurrent-ruby (~> 1.0) + io-console (0.8.0) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) json (2.11.3) json_rpc_handler (0.1.1) language_server-protocol (3.17.0.4) @@ -48,11 +57,21 @@ GEM parser (3.3.8.0) ast (~> 2.4.1) racc + pp (0.6.2) + prettyprint + prettyprint (0.2.0) prism (1.4.0) + psych (5.2.4) + date + stringio racc (1.8.1) rainbow (3.1.1) rake (13.2.1) + rdoc (6.13.1) + psych (>= 4.0.0) regexp_parser (2.10.0) + reline (0.6.1) + io-console (~> 0.5) rubocop (1.75.5) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -72,6 +91,7 @@ GEM ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) securerandom (0.4.1) + stringio (3.1.7) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) @@ -85,6 +105,7 @@ PLATFORMS DEPENDENCIES activesupport + debug minitest (~> 5.1) minitest-reporters mocha diff --git a/README.md b/README.md index 285b60b..6314c62 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,6 @@ # Model Context Protocol -A Ruby gem implementing Model Context Protocol + JSON RPC 2.0 spec - -## Overview - -Model Context Protocol provides a standardized way to interact with language models through a JSON RPC 2.0 interface. - -It handles request/response formatting, error handling, and tool integrations in a type-safe manner using Sorbet. - -See https://spec.modelcontextprotocol.io/specification/ - -This implementation breaks from the transport examples in the spec as it implements a POST-based JSON RPC call rather -than a stateful SSE transport. +A Ruby gem for implementing Model Context Protocol servers ## MCP Server @@ -19,31 +8,45 @@ The `ModelContextProtocol::Server` class is the core component that handles JSON It implements the Model Context Protocol specification, handling model context requests and responses. ### Key Features - - Implements JSON-RPC 2.0 message handling - Supports protocol initialization and capability negotiation - Manages tool registration and invocation -- Provides type-safe request/response handling using Sorbet +- Supports prompt registration and execution +- Supports resource registration and retrieval ### Supported Methods - - `initialize` - Initializes the protocol and returns server capabilities -- `ping` - Simple health check that returns "pong" +- `ping` - Simple health check - `tools/list` - Lists all registered tools and their schemas - `tools/call` - Invokes a specific tool with provided arguments - `prompts/list` - Lists all registered prompts and their schemas - `prompts/get` - Retrieves a specific prompt by name +- `resources/list` - Lists all registered resources and their schemas +- `resources/read` - Retrieves a specific resource by name +- `resources/templates/list` - Lists all registered resource templates and their schemas + +### Unsupported Features ( to be implemented in future versions ) + +- Notifications +- Log Level +- Resource subscriptions +- Completions +- Complete StreamableHTTP implementation with streaming responses ### Usage #### Rails Controller -Implement an `ApplicationController` which calls the `Server#handle` method, eg + +When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming +[StreamableHTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport +requests. + +You can use the `Server#handle_json` method to handle requests. ```ruby module ModelContextProtocol class ApplicationController < ActionController::Base - sig { void } def index server = ModelContextProtocol::Server.new( name: "my_server", @@ -57,29 +60,9 @@ module ModelContextProtocol end ``` -or, if you want/need to parse the json yourself you can do the following - -```ruby -module ModelContextProtocol - class ApplicationController < ActionController::Base - - sig { void } - def index - server = ModelContextProtocol::Server.new( - name: "my_server", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, - ) - render(json: server.handle(JSON.parse(request.body.read)).to_h) - end - end -end -``` - #### Stdio Transport -If you want to build a simple command-line application, you can use the stdio transport: +If you want to build a local command-line application, you can use the stdio transport: ```ruby #!/usr/bin/env ruby @@ -89,11 +72,12 @@ require "model_context_protocol/transports/stdio" # Create a simple tool class ExampleTool < ModelContextProtocol::Tool description "A simple example tool that echoes back its arguments" - input_schema type: "object", + input_schema( properties: { message: { type: "string" }, }, required: ["message"] + ) class << self def call(message:, server_context:) @@ -125,36 +109,13 @@ $ ./stdio_server.rb {"jsonrpc":"2.0","id":"3","result":["ExampleTool"]} ``` - - - -#### Testing without a transport - -To see sample responses without setting up a client to hit the server, you can simply run - -```ruby -server = ModelContextProtocol::Server.new( - name: "my_server", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], - server_context: { user_id: current_user.id }, -) -request = { - jsonrpc: "2.0", - method: "tools/list", - params: {}, - id: "1" -} -server.handle(request) -``` - ## Configuration The gem can be configured using the `ModelContextProtocol.configure` block: ```ruby ModelContextProtocol.configure do |config| - config.exception_reporter = ->(exception, server_context) do + config.exception_reporter = do |exception, server_context| # Your exception reporting logic here # For example with Bugsnag: Bugsnag.notify(exception) do |report| @@ -162,15 +123,19 @@ ModelContextProtocol.configure do |config| end end - config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" } + config.instrumentation_callback = do |data| + puts "Got instrumentation data #{data.inspect}" + end end +``` or by creating an explicit configuration and passing it into the server. This is useful for systems where an application hosts more than one MCP server but they might require different instrumentation callbacks. +```ruby configuration = ModelContextProtocol::Configuration.new -configuration.exception_reporter = ->(exception, server_context) do +configuration.exception_reporter = do |exception, server_context| # Your exception reporting logic here # For example with Bugsnag: Bugsnag.notify(exception) do |report| @@ -178,7 +143,9 @@ configuration.exception_reporter = ->(exception, server_context) do end end -configuration.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" } +configuration.instrumentation_callback = do |data| + puts "Got instrumentation data #{data.inspect}" +end server = ModelContextProtocol::Server.new( # ... all other options @@ -186,6 +153,65 @@ server = ModelContextProtocol::Server.new( ) ``` +### Server Context and Configuration Block Data + +#### `server_context` + +The `server_context` is a user-defined hash that is passed into the server instance and made available to tools, prompts, and exception/instrumentation callbacks. It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. + +**Type:** +```ruby +server_context: { [String, Symbol] => Any } +``` + +**Example:** +```ruby +server = ModelContextProtocol::Server.new( + name: "my_server", + server_context: { user_id: current_user.id, request_id: request.uuid } +) +``` + +This hash is then passed as the `server_context` argument to tool and prompt calls, and is included in exception and instrumentation callbacks. + +#### Configuration Block Data + +##### Exception Reporter + +The exception reporter receives: + +- `exception`: The Ruby exception object that was raised +- `server_context`: The context hash provided to the server + +**Signature:** +```ruby +exception_reporter = ->(exception, server_context) { ... } +``` + +##### Instrumentation Callback + +The instrumentation callback receives a hash with the following possible keys: + +- `method`: (String) The protocol method called (e.g., "ping", "tools/list") +- `tool_name`: (String, optional) The name of the tool called +- `prompt_name`: (String, optional) The name of the prompt called +- `resource_uri`: (String, optional) The URI of the resource called +- `error`: (String, optional) Error code if a lookup failed +- `duration`: (Float) Duration of the call in seconds + +**Type:** +```ruby +instrumentation_callback = ->(data) { ... } +# where data is a Hash with keys as described above +``` + +**Example:** +```ruby +config.instrumentation_callback = ->(data) do + puts "Instrumentation: #{data.inspect}" +end +``` + ### Server Protocol Version The server's protocol version can be overridden using the `protocol_version` class method: @@ -233,7 +259,12 @@ This gem provides a `ModelContextProtocol::Tool` class that can be used to creat ```ruby class MyTool < ModelContextProtocol::Tool description "This tool performs specific functionality..." - input_schema [{ type: "text", text: "message" }] + input_schema( + properties: { + message: { type: "string" }, + }, + required: ["message"] + ) annotations( title: "My Tool", read_only_hint: true, @@ -242,12 +273,6 @@ class MyTool < ModelContextProtocol::Tool open_world_hint: false ) - input_schema type: 'object', - properties: { - message: { type: 'string' }, - }, - required: ['message'] - def self.call(message:, server_context:) Tool::Response.new([{ type: "text", text: "OK" }]) end @@ -392,7 +417,9 @@ To register a handler pass a proc/lambda to as `instrumentation_callback` into t ```ruby ModelContextProtocol.configure do |config| - config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" } + config.instrumentation_callback = do |data| + puts "Got instrumentation data #{data.inspect}" + end end ``` @@ -407,6 +434,40 @@ The data contains the following keys: `tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered. This is to avoid potential issues with metric cardinality +## Resources + +MCP spec includes [Resources](https://modelcontextprotocol.io/docs/concepts/resources) + +The `ModelContextProtocol::Resource` class provides a way to register resources with the server. + +```ruby +resource = ModelContextProtocol::Resource.new( + uri: "example.com/my_resource", + mime_type: "text/plain", + text: "Lorem ipsum dolor sit amet" +) + +server = ModelContextProtocol::Server.new( + name: "my_server", + resources: [resource], +) +``` + +The server must register a handler for the `resources/read` method to retrieve a resource dynamically. + +```ruby +server.resources_read_handler do |params| + [{ + uri: params[:uri], + mimeType: "text/plain", + text: "Hello, world!", + }] +end + +``` + +otherwise 'resources/read' requests will be a no-op. + ## Releases TODO diff --git a/examples/stdio_server.rb b/examples/stdio_server.rb index e71b1a7..7c1a09a 100755 --- a/examples/stdio_server.rb +++ b/examples/stdio_server.rb @@ -6,30 +6,89 @@ require "model_context_protocol" require "model_context_protocol/transports/stdio" # Create a simple tool -class ExampleTool < ModelContextProtocol::Tool - description "A simple example tool that echoes back its arguments" - input_schema type: "object", +class ExampleTool < MCP::Tool + description "A simple example tool that adds two numbers" + input_schema( properties: { - message: { type: "string" }, + a: { type: "number" }, + b: { type: "number" }, }, - required: ["message"] + required: ["a", "b"], + ) class << self - def call(message:, server_context:) - ModelContextProtocol::Tool::Response.new([{ + def call(a:, b:) + MCP::Tool::Response.new([{ type: "text", - text: "Hello from example tool! Message: #{message}", + text: "The sum of #{a} and #{b} is #{a + b}", }]) end end end +# Create a simple prompt +class ExamplePrompt < MCP::Prompt + description "A simple example prompt that echoes back its arguments" + arguments [ + MCP::Prompt::Argument.new( + name: "message", + description: "The message to echo back", + required: true, + ), + ] + + class << self + def template(args, server_context:) + MCP::Prompt::Result.new( + messages: [ + MCP::Prompt::Message.new( + role: "user", + content: MCP::Content::Text.new(args[:message]), + ), + ], + ) + end + end +end + # Set up the server -server = ModelContextProtocol::Server.new( +server = MCP::Server.new( name: "example_server", tools: [ExampleTool], + prompts: [ExamplePrompt], + resources: [ + MCP::Resource.new( + uri: "test_resource", + name: "Test resource", + description: "Test resource that echoes back the uri as its content", + mime_type: "text/plain", + ), + ], ) +server.define_tool( + name: "echo", + description: "A simple example tool that echoes back its arguments", + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, +) do |message:| + MCP::Tool::Response.new( + [ + { + type: "text", + text: "Hello from echo tool! Message: #{message}", + }, + ], + ) +end + +server.resources_read_handler do |params| + [{ + uri: params[:uri], + mimeType: "text/plain", + text: "Hello, world!", + }] +end + # Create and start the transport -transport = ModelContextProtocol::Transports::StdioTransport.new(server) +transport = MCP::Transports::StdioTransport.new(server) transport.open diff --git a/lib/model_context_protocol.rb b/lib/model_context_protocol.rb index 468b198..db18639 100644 --- a/lib/model_context_protocol.rb +++ b/lib/model_context_protocol.rb @@ -4,9 +4,18 @@ require "model_context_protocol/server" require "model_context_protocol/string_utils" require "model_context_protocol/tool" +require "model_context_protocol/tool/input_schema" +require "model_context_protocol/tool/annotations" +require "model_context_protocol/tool/response" require "model_context_protocol/content" require "model_context_protocol/resource" +require "model_context_protocol/resource/contents" +require "model_context_protocol/resource/embedded" +require "model_context_protocol/resource_template" require "model_context_protocol/prompt" +require "model_context_protocol/prompt/argument" +require "model_context_protocol/prompt/message" +require "model_context_protocol/prompt/result" require "model_context_protocol/version" require "model_context_protocol/configuration" require "model_context_protocol/methods" @@ -31,3 +40,5 @@ module ModelContextProtocol end end end + +MCP = ModelContextProtocol diff --git a/lib/model_context_protocol/configuration.rb b/lib/model_context_protocol/configuration.rb index 97fe532..b6dab82 100644 --- a/lib/model_context_protocol/configuration.rb +++ b/lib/model_context_protocol/configuration.rb @@ -2,7 +2,7 @@ module ModelContextProtocol class Configuration - DEFAULT_PROTOCOL_VERSION = "2025-03-26" + DEFAULT_PROTOCOL_VERSION = "2024-11-05" attr_writer :exception_reporter, :instrumentation_callback, :protocol_version diff --git a/lib/model_context_protocol/methods.rb b/lib/model_context_protocol/methods.rb index 9c73f1e..3c6152f 100644 --- a/lib/model_context_protocol/methods.rb +++ b/lib/model_context_protocol/methods.rb @@ -2,13 +2,85 @@ module ModelContextProtocol module Methods - RESOURCES_LIST = "resources/list" - RESOURCES_READ = "resources/read" - TOOLS_LIST = "tools/list" - TOOLS_CALL = "tools/call" - PROMPTS_LIST = "prompts/list" - PROMPTS_GET = "prompts/get" INITIALIZE = "initialize" PING = "ping" + LOGGING_SET_LEVEL = "logging/setLevel" + + PROMPTS_GET = "prompts/get" + PROMPTS_LIST = "prompts/list" + COMPLETION_COMPLETE = "completion/complete" + + RESOURCES_LIST = "resources/list" + RESOURCES_READ = "resources/read" + RESOURCES_TEMPLATES_LIST = "resources/templates/list" + RESOURCES_SUBSCRIBE = "resources/subscribe" + RESOURCES_UNSUBSCRIBE = "resources/unsubscribe" + + TOOLS_CALL = "tools/call" + TOOLS_LIST = "tools/list" + + SAMPLING_CREATE_MESSAGE = "sampling/createMessage" + + class MissingRequiredCapabilityError < StandardError + attr_reader :method + attr_reader :capability + + def initialize(method, capability) + super("Server does not support #{capability} (required for #{method})") + @method = method + @capability = capability + end + end + + extend self + + ALL_METHODS = [ + SAMPLING_CREATE_MESSAGE, + INITIALIZE, + LOGGING_SET_LEVEL, + PING, + PROMPTS_GET, + PROMPTS_LIST, + COMPLETION_COMPLETE, + RESOURCES_LIST, + RESOURCES_READ, + RESOURCES_TEMPLATES_LIST, + RESOURCES_SUBSCRIBE, + RESOURCES_UNSUBSCRIBE, + TOOLS_CALL, + TOOLS_LIST, + ] + + def ensure_capability!(method, capabilities) + case method + when PROMPTS_GET, PROMPTS_LIST, COMPLETION_COMPLETE + 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 + + 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 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 end diff --git a/lib/model_context_protocol/prompt.rb b/lib/model_context_protocol/prompt.rb index 6aa923f..d7cd4b9 100644 --- a/lib/model_context_protocol/prompt.rb +++ b/lib/model_context_protocol/prompt.rb @@ -3,47 +3,6 @@ module ModelContextProtocol class Prompt - class Argument - attr_reader :name, :description, :required, :arguments - - def initialize(name:, description: nil, required: false) - @name = name - @description = description - @required = required - @arguments = arguments - end - - def to_h - { name:, description:, required: }.compact - end - end - - class Message - attr_reader :role, :content - - def initialize(role:, content:) - @role = role - @content = content - end - - def to_h - { role:, content: content.to_h }.compact - end - end - - class Result - attr_reader :description, :messages - - def initialize(description: nil, messages: []) - @description = description - @messages = messages - end - - def to_h - { description:, messages: messages.map(&:to_h) }.compact - end - end - class << self NOT_SET = Object.new diff --git a/lib/model_context_protocol/prompt/argument.rb b/lib/model_context_protocol/prompt/argument.rb new file mode 100644 index 0000000..9940284 --- /dev/null +++ b/lib/model_context_protocol/prompt/argument.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class Prompt + class Argument + attr_reader :name, :description, :required, :arguments + + def initialize(name:, description: nil, required: false) + @name = name + @description = description + @required = required + @arguments = arguments + end + + def to_h + { name:, description:, required: }.compact + end + end + end +end diff --git a/lib/model_context_protocol/prompt/message.rb b/lib/model_context_protocol/prompt/message.rb new file mode 100644 index 0000000..e7bf602 --- /dev/null +++ b/lib/model_context_protocol/prompt/message.rb @@ -0,0 +1,19 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class Prompt + class Message + attr_reader :role, :content + + def initialize(role:, content:) + @role = role + @content = content + end + + def to_h + { role:, content: content.to_h }.compact + end + end + end +end diff --git a/lib/model_context_protocol/prompt/result.rb b/lib/model_context_protocol/prompt/result.rb new file mode 100644 index 0000000..685bbc8 --- /dev/null +++ b/lib/model_context_protocol/prompt/result.rb @@ -0,0 +1,19 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class Prompt + class Result + attr_reader :description, :messages + + def initialize(description: nil, messages: []) + @description = description + @messages = messages + end + + def to_h + { description:, messages: messages.map(&:to_h) }.compact + end + end + end +end diff --git a/lib/model_context_protocol/resource.rb b/lib/model_context_protocol/resource.rb index 0e02ff1..800834f 100644 --- a/lib/model_context_protocol/resource.rb +++ b/lib/model_context_protocol/resource.rb @@ -3,14 +3,13 @@ module ModelContextProtocol class Resource - attr_reader :uri, :name, :description, :mime_type, :contents + attr_reader :uri, :name, :description, :mime_type - def initialize(uri:, name:, description:, mime_type:, contents:) + def initialize(uri:, name:, description:, mime_type:) @uri = uri @name = name @description = description @mime_type = mime_type - @contents = contents end def to_h @@ -19,7 +18,6 @@ module ModelContextProtocol name: @name, description: @description, mimeType: @mime_type, - contents: @contents.map(&:to_h), } end end diff --git a/lib/model_context_protocol/resource_template.rb b/lib/model_context_protocol/resource_template.rb new file mode 100644 index 0000000..f82242a --- /dev/null +++ b/lib/model_context_protocol/resource_template.rb @@ -0,0 +1,24 @@ +# typed: strict +# frozen_string_literal: true + +module ModelContextProtocol + class ResourceTemplate + attr_reader :uri_template, :name, :description, :mime_type + + 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 + { + uriTemplate: @uri_template, + name: @name, + description: @description, + mimeType: @mime_type, + }.compact + end + end +end diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb index bd47b3c..e707566 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/model_context_protocol/server.rb @@ -20,26 +20,43 @@ module ModelContextProtocol include Instrumentation - attr_accessor :name, :tools, :prompts, :resources, :server_context, :configuration + attr_accessor :name, :tools, :prompts, :resources, :server_context, :configuration, :capabilities - def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], server_context: nil, - configuration: nil) + def initialize( + name: "model_context_protocol", + tools: [], + prompts: [], + resources: [], + resource_templates: [], + server_context: nil, + configuration: nil, + capabilities: { prompts: {}, resources: {}, tools: {} } + ) @name = name @tools = tools.to_h { |t| [t.name_value, t] } @prompts = prompts.to_h { |p| [p.name_value, p] } @resources = resources + @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) @server_context = server_context @configuration = ModelContextProtocol.configuration.merge(configuration) + @capabilities = capabilities + @handlers = { Methods::RESOURCES_LIST => method(:list_resources), - Methods::RESOURCES_READ => method(:read_resource), + Methods::RESOURCES_READ => method(:read_resource_no_content), + Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates), Methods::TOOLS_LIST => method(:list_tools), Methods::TOOLS_CALL => method(:call_tool), Methods::PROMPTS_LIST => method(:list_prompts), Methods::PROMPTS_GET => method(:get_prompt), Methods::INITIALIZE => method(:init), Methods::PING => ->(_) { {} }, + + # No op handlers for currently unsupported methods + Methods::RESOURCES_SUBSCRIBE => ->(_) {}, + Methods::RESOURCES_UNSUBSCRIBE => ->(_) {}, + Methods::LOGGING_SET_LEVEL => ->(_) {}, } end @@ -55,6 +72,16 @@ module ModelContextProtocol end end + 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 + end + + def define_prompt(name: nil, description: nil, arguments: [], &block) + prompt = Prompt.define(name:, description:, arguments:, &block) + @prompts[prompt.name_value] = prompt + end + def resources_list_handler(&block) @handlers[Methods::RESOURCES_LIST] = block end @@ -63,6 +90,10 @@ module ModelContextProtocol @handlers[Methods::RESOURCES_READ] = block end + def resources_templates_list_handler(&block) + @handlers[Methods::RESOURCES_TEMPLATES_LIST] = block + end + def tools_list_handler(&block) @handlers[Methods::TOOLS_LIST] = block end @@ -88,6 +119,8 @@ module ModelContextProtocol return end + Methods.ensure_capability!(method, capabilities) + ->(params) { instrument_call(method) do case method @@ -97,6 +130,10 @@ module ModelContextProtocol { prompts: @handlers[Methods::PROMPTS_LIST].call(params) } when Methods::RESOURCES_LIST { resources: @handlers[Methods::RESOURCES_LIST].call(params) } + when Methods::RESOURCES_READ + { contents: @handlers[Methods::RESOURCES_READ].call(params) } + when Methods::RESOURCES_TEMPLATES_LIST + { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) } else @handlers[method].call(params) end @@ -113,14 +150,6 @@ module ModelContextProtocol } end - def capabilities - @capabilities ||= { - prompts: {}, - resources: {}, - tools: {}, - } - end - def server_info @server_info ||= { name:, @@ -151,10 +180,25 @@ module ModelContextProtocol raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found) end + 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 + begin - tool.call(**request[:arguments], server_context:).to_h + call_params = tool.method(:call).parameters.flatten + 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) end @@ -188,18 +232,17 @@ module ModelContextProtocol @resources.map(&:to_h) end - def read_resource(request) + # Server implementation should set read_resource_handler to override no-op default + def read_resource_no_content(request) add_instrumentation_data(method: Methods::RESOURCES_READ) - resource_uri = request[:uri] + add_instrumentation_data(resource_uri: request[:uri]) + [] + end - resource = @resource_index[resource_uri] - unless resource - add_instrumentation_data(error: :resource_not_found) - raise RequestHandlerError.new("Resource not found #{resource_uri}", request, error_type: :resource_not_found) - end + def list_resource_templates(request) + add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST) - add_instrumentation_data(resource_uri:) - resource.to_h + @resource_templates.map(&:to_h) end def report_exception(exception, server_context = {}) diff --git a/lib/model_context_protocol/tool.rb b/lib/model_context_protocol/tool.rb index fa5324d..388cf88 100644 --- a/lib/model_context_protocol/tool.rb +++ b/lib/model_context_protocol/tool.rb @@ -2,41 +2,6 @@ module ModelContextProtocol class Tool - class Response - attr_reader :content, :is_error - - def initialize(content, is_error = false) - @content = content - @is_error = is_error - end - - def to_h - { content:, isError: is_error }.compact - end - end - - class Annotations - attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint - - def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil) - @title = title - @read_only_hint = read_only_hint - @destructive_hint = destructive_hint - @idempotent_hint = idempotent_hint - @open_world_hint = open_world_hint - end - - def to_h - { - title:, - readOnlyHint: read_only_hint, - destructiveHint: destructive_hint, - idempotentHint: idempotent_hint, - openWorldHint: open_world_hint, - }.compact - end - end - class << self NOT_SET = Object.new @@ -52,7 +17,7 @@ module ModelContextProtocol result = { name: name_value, description: description_value, - inputSchema: input_schema_value, + inputSchema: input_schema_value.to_h, } result[:annotations] = annotations_value.to_h if annotations_value result @@ -88,8 +53,12 @@ module ModelContextProtocol def input_schema(value = NOT_SET) if value == NOT_SET - @input_schema_value - else + 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 @@ -108,9 +77,7 @@ module ModelContextProtocol description description input_schema input_schema self.annotations(annotations) if annotations - define_singleton_method(:call) do |*args, server_context:| - instance_exec(*args, server_context:, &block) - end + define_singleton_method(:call, &block) if block end end end diff --git a/lib/model_context_protocol/tool/annotations.rb b/lib/model_context_protocol/tool/annotations.rb new file mode 100644 index 0000000..2266c9f --- /dev/null +++ b/lib/model_context_protocol/tool/annotations.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ModelContextProtocol + class Tool + class Annotations + attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint + + def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil) + @title = title + @read_only_hint = read_only_hint + @destructive_hint = destructive_hint + @idempotent_hint = idempotent_hint + @open_world_hint = open_world_hint + end + + def to_h + { + title:, + readOnlyHint: read_only_hint, + destructiveHint: destructive_hint, + idempotentHint: idempotent_hint, + openWorldHint: open_world_hint, + }.compact + end + end + end +end diff --git a/lib/model_context_protocol/tool/input_schema.rb b/lib/model_context_protocol/tool/input_schema.rb new file mode 100644 index 0000000..4718205 --- /dev/null +++ b/lib/model_context_protocol/tool/input_schema.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ModelContextProtocol + class Tool + class InputSchema + attr_reader :properties, :required + + def initialize(properties: {}, required: []) + @properties = properties + @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 + + def missing_required_arguments(arguments) + (required - arguments.keys.map(&:to_sym)) + end + end + end +end diff --git a/lib/model_context_protocol/tool/response.rb b/lib/model_context_protocol/tool/response.rb new file mode 100644 index 0000000..9207793 --- /dev/null +++ b/lib/model_context_protocol/tool/response.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ModelContextProtocol + class Tool + class Response + attr_reader :content, :is_error + + def initialize(content, is_error = false) + @content = content + @is_error = is_error + end + + def to_h + { content:, isError: is_error }.compact + end + end + end +end diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb index 16d6321..c2e2323 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.6.0" + VERSION = "0.7.0" end diff --git a/test/model_context_protocol/methods_test.rb b/test/model_context_protocol/methods_test.rb new file mode 100644 index 0000000..e8ff522 --- /dev/null +++ b/test/model_context_protocol/methods_test.rb @@ -0,0 +1,68 @@ +# typed: strict +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + class MethodsTest < ActiveSupport::TestCase + test "ensure_capability! for tools/list method raises an error if tools capability is not present" do + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(Methods::TOOLS_LIST, {}) + end + assert_equal "Server does not support tools (required for tools/list)", error.message + end + + test "ensure_capability! for sampling/createMessage raises an error if sampling capability is not present" do + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(Methods::SAMPLING_CREATE_MESSAGE, {}) + end + assert_equal "Server does not support sampling (required for sampling/createMessage)", error.message + end + + test "ensure_capability! for logging/setLevel raises an error if logging capability is not present" do + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(Methods::LOGGING_SET_LEVEL, {}) + end + assert_equal "Server does not support logging (required for logging/setLevel)", error.message + end + + test "ensure_capability! for prompts/get and prompts/list raise an error if prompts capability is not present" do + [Methods::PROMPTS_GET, Methods::PROMPTS_LIST].each do |method| + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(method, {}) + end + assert_equal "Server does not support prompts (required for #{method})", error.message + end + end + + test "ensure_capability! for resources/list, resources/templates/list, resources/read raise an error if resources capability is not present" do + [Methods::RESOURCES_LIST, Methods::RESOURCES_TEMPLATES_LIST, Methods::RESOURCES_READ].each do |method| + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(method, {}) + end + assert_equal "Server does not support resources (required for #{method})", error.message + end + end + + test "ensure_capability! for tools/call and tools/list raise an error if tools capability is not present" do + [Methods::TOOLS_CALL, Methods::TOOLS_LIST].each do |method| + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(method, {}) + end + assert_equal "Server does not support tools (required for #{method})", error.message + end + end + + test "ensure_capability! for resources/subscribe raises an error if resources subscribe capability is not present" do + error = assert_raises(Methods::MissingRequiredCapabilityError) do + Methods.ensure_capability!(Methods::RESOURCES_SUBSCRIBE, { resources: {} }) + end + assert_equal "Server does not support resources_subscribe (required for resources/subscribe)", error.message + end + + test "ensure_capability! does not raise for ping and initialize methods" do + assert_nothing_raised { Methods.ensure_capability!(Methods::PING, {}) } + assert_nothing_raised { Methods.ensure_capability!(Methods::INITIALIZE, {}) } + end + end +end diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb index dd6caa1..2a35667 100644 --- a/test/model_context_protocol/server_test.rb +++ b/test/model_context_protocol/server_test.rb @@ -2,16 +2,18 @@ require "test_helper" require "json" +require "debug" module ModelContextProtocol class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper setup do - @tool = Tool.define(name: "test_tool", description: "Test tool", input_schema: {}) + @tool = Tool.define(name: "test_tool", description: "Test tool") + @tool_that_raises = Tool.define( name: "tool_that_raises", description: "Tool that raises", - input_schema: {}, + input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, ) { raise StandardError, "Tool error" } @prompt = Prompt.define( @@ -34,7 +36,13 @@ module ModelContextProtocol name: "Test resource", description: "Test resource", mime_type: "text/plain", - contents: [Content::Text.new("Hello, world!")], + ) + + @resource_template = ResourceTemplate.new( + uri_template: "test_resource/{id}", + name: "Test resource", + description: "Test resource", + mime_type: "text/plain", ) @server_name = "test_server" @@ -46,6 +54,7 @@ module ModelContextProtocol tools: [@tool, @tool_that_raises], prompts: [@prompt], resources: [@resource], + resource_templates: [@resource_template], configuration:, ) end @@ -103,7 +112,7 @@ module ModelContextProtocol "jsonrpc": "2.0", "id": 1, "result": { - "protocolVersion": "2025-03-26", + "protocolVersion": "2024-11-05", "capabilities": { "prompts": {}, "resources": {}, @@ -179,10 +188,10 @@ module ModelContextProtocol 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_args = { arg: "value" } + tool_response = Tool::Response.new([{ result: "success" }]) - @tool.expects(:call).with(**tool_args, server_context: @server.server_context).returns(tool_response) + @tool.expects(:call).with(arg: "value").returns(tool_response) request = { jsonrpc: "2.0", @@ -199,12 +208,39 @@ module ModelContextProtocol assert_instrumentation_data({ method: "tools/call", tool_name: }) end + test "#handle tools/call returns error if required tool arguments are missing" do + tool_with_required_argument = Tool.define( + name: "test_tool", + description: "Test tool", + input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, + ) do |message: nil| + Tool::Response.new("success #{message}") + end + + server = Server.new( + name: "test_server", + tools: [tool_with_required_argument], + ) + + request = { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "test_tool", arguments: {} }, + id: 1, + } + + response = server.handle(request) + + assert_equal "Missing required arguments: message", response[:error][:data] + assert_equal "Internal error", response[:error][:message] + end + 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(**tool_args, server_context: @server.server_context).returns(tool_response) + @tool.expects(:call).with(arg: "value").returns(tool_response) request = JSON.generate({ jsonrpc: "2.0", @@ -213,8 +249,9 @@ module ModelContextProtocol id: 1, }) - response = JSON.parse(@server.handle_json(request), symbolize_names: true) - assert_equal tool_response.to_h, response[:result] + 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_instrumentation_data({ method: "tools/call", tool_name: }) end @@ -226,7 +263,7 @@ module ModelContextProtocol request: { jsonrpc: "2.0", method: "tools/call", - params: { name: "tool_that_raises", arguments: {} }, + params: { name: "tool_that_raises", arguments: { message: "test" } }, id: 1, }, }, @@ -239,7 +276,7 @@ module ModelContextProtocol method: "tools/call", params: { name: "tool_that_raises", - arguments: {}, + arguments: { message: "test" }, }, id: 1, } @@ -257,7 +294,7 @@ module ModelContextProtocol method: "tools/call", params: { name: "tool_that_raises", - arguments: {}, + arguments: { message: "test" }, }, id: 1, }) @@ -274,7 +311,7 @@ module ModelContextProtocol method: "tools/call", params: { name: "unknown_tool", - arguments: {}, + arguments: { message: "test" }, }, id: 1, } @@ -462,47 +499,28 @@ module ModelContextProtocol assert_instrumentation_data({ method: "resources/list" }) end - test "#handle resources/read returns a resource" do + test "#handle resources/read returns an empty array of contents by default" do request = { jsonrpc: "2.0", method: "resources/read", id: 1, params: { - uri: @resource.uri, + uri: "example.com", }, } response = @server.handle(request) - assert_equal(@resource.to_h, response[:result]) - assert_instrumentation_data({ method: "resources/read", resource_uri: @resource.uri }) - 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", - }, - } - - response = @server.handle(request) - assert_equal "Resource not found unknown_resource", response[:error][:data] - assert_instrumentation_data({ method: "resources/read", error: :resource_not_found }) + assert_equal({ contents: [] }, response[:result]) + assert_instrumentation_data({ method: "resources/read", resource_uri: "example.com" }) end test "#resources_read_handler sets the resources/read handler" do @server.resources_read_handler do |request| - uri = request[:uri] - - Resource.new( - uri: uri, - name: "Test resource", - description: "Test resource", + return { + uri: request[:uri], mime_type: "text/plain", - contents: [Content::Text.new("Lorem ipsum dolor sit amet")], - ).to_h + text: "Lorem ipsum dolor sit amet", + } end request = { @@ -510,22 +528,76 @@ module ModelContextProtocol method: "resources/read", id: 1, params: { - uri: "test_resource", + uri: "example.com/my_resource", }, } + response = @server.handle(request) + assert_equal( + { contents: [{ uri: "example.com/my_resource", mimeType: "text/plain", text: "Lorem ipsum dolor sit amet" }] }, + response[:result], + ) + end + + test "#handle resources/templates/list returns a list of resource templates" do + request = { + jsonrpc: "2.0", + method: "resources/templates/list", + id: 1, + } + 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", type: "text" }], + resourceTemplates: [@resource_template.to_h], }, response[:result], ) - assert_instrumentation_data({ method: "resources/read" }) + assert_instrumentation_data({ method: "resources/templates/list" }) + end + + test "#resources_templates_list_handler sets the resources/templates/list handler" do + @server.resources_templates_list_handler do + [{ uriTemplate: "test_resource_template/{id}", name: "Test resource template", description: "a template" }] + end + + request = { + jsonrpc: "2.0", + method: "resources/templates/list", + id: 1, + } + + response = @server.handle(request) + assert_equal( + { + resourceTemplates: [ + { + uriTemplate: "test_resource_template/{id}", + name: "Test resource template", + description: "a template", + }, + ], + }, + response[:result], + ) + assert_instrumentation_data({ method: "resources/templates/list" }) + end + + test "#handle method with missing required top-level capability returns an error" do + @server.capabilities = {} + + response = @server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) + assert_equal "Server does not support prompts (required for prompts/list)", response[:error][:data] + + response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + assert_equal "Server does not support resources (required for resources/list)", response[:error][:data] + end + + test "#handle method with missing required nested capability returns an error" do + @server.capabilities = { resources: {} } + response = @server.handle({ jsonrpc: "2.0", method: "resources/subscribe", id: 1 }) + assert_equal "Server does not support resources_subscribe (required for resources/subscribe)", + response[:error][:data] end test "#handle unknown method returns method not found error" do @@ -576,6 +648,70 @@ module ModelContextProtocol assert_equal Configuration::DEFAULT_PROTOCOL_VERSION, response[:result][:protocolVersion] end + test "#define_tool adds a tool to the server" do + @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) + end + + response = @server.handle({ + jsonrpc: "2.0", + method: "tools/call", + params: { name: "defined_tool", arguments: { message: "success" } }, + id: 1, + }) + + assert_equal({ content: "success", isError: false }, response[:result]) + end + + test "#define_tool call definition allows tool arguments and server context" do + @server.server_context = { user_id: "123" } + + @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]}") + end + + response = @server.handle({ + jsonrpc: "2.0", + method: "tools/call", + params: { name: "defined_tool", arguments: { message: "hello" } }, + id: 1, + }) + + assert_equal({ content: "success hello 123", isError: false }, response[:result]) + end + + test "#define_prompt adds a tool to the server" do + @server.define_prompt(name: "defined_prompt", description: "Defined prompt", arguments: []) do + Prompt::Result.new( + description: "a prompt description", + messages: [Prompt::Message.new(role: "user", content: Content::Text.new("a prompt message"))], + ) + end + + response = @server.handle({ + jsonrpc: "2.0", + method: "prompts/get", + params: { name: "defined_prompt", arguments: {} }, + id: 1, + }) + + assert_equal( + { + description: "a prompt description", + messages: [{ role: "user", content: { text: "a prompt message", type: "text" } }], + }, + response[:result], + ) + end + test "server protocol version can be overridden via configuration" do custom_version = "2025-03-27" configuration = Configuration.new(protocol_version: custom_version) diff --git a/test/model_context_protocol/tool/input_schema_test.rb b/test/model_context_protocol/tool/input_schema_test.rb new file mode 100644 index 0000000..c28bbcf --- /dev/null +++ b/test/model_context_protocol/tool/input_schema_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + class Tool + class InputSchemaTest < ActiveSupport::TestCase + test "required arguments are converted to symbols" do + input_schema = InputSchema.new(properties: { message: { type: "string" } }, required: ["message"]) + assert_equal [:message], input_schema.required + 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( + { type: "object", properties: { message: { type: "string" } }, required: [:message] }, + 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_equal [], input_schema.missing_required_arguments({ message: "Hello, world!" }) + end + end + end +end diff --git a/test/model_context_protocol/tool_test.rb b/test/model_context_protocol/tool_test.rb index d9cae12..b137832 100644 --- a/test/model_context_protocol/tool_test.rb +++ b/test/model_context_protocol/tool_test.rb @@ -7,7 +7,7 @@ module ModelContextProtocol class TestTool < Tool tool_name "test_tool" description "a test tool for testing" - input_schema [{ type: "text", name: "message" }] + input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) annotations( title: "Test Tool", read_only_hint: true, @@ -17,7 +17,7 @@ module ModelContextProtocol ) class << self - def call(message) + def call(message, server_context: nil) Tool::Response.new([{ type: "text", content: "OK" }]) end end @@ -25,7 +25,7 @@ module ModelContextProtocol 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: nil } + assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: {} } end test "#to_h includes annotations when present" do @@ -51,27 +51,34 @@ module ModelContextProtocol class MockTool < Tool tool_name "my_mock_tool" description "a mock tool for testing" - input_schema [{ type: "text", name: "message" }] + input_schema({ properties: { message: { type: "string" } }, required: [:message] }) end tool = MockTool assert_equal tool.name_value, "my_mock_tool" assert_equal tool.description, "a mock tool for testing" - assert_equal tool.input_schema, [{ type: "text", name: "message" }] + 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 - description "a mock tool for testing" - input_schema [{ type: "text", name: "message" }] end tool = DefaultNameTool assert_equal tool.tool_name, "default_name_tool" - assert_equal tool.name_value, "default_name_tool" - assert_equal tool.description, "a mock tool for testing" - assert_equal tool.input_schema, [{ type: "text", name: "message" }] + end + + test "accepts input schema as an InputSchema object" do + class InputSchemaTool < Tool + input_schema InputSchema.new(properties: { message: { type: "string" } }, required: [:message]) + end + + tool = InputSchemaTool + + expected = { type: "object", properties: { message: { type: "string" } }, required: [:message] } + assert_equal expected, tool.input_schema.to_h end test ".define allows definition of simple tools with a block" do