diff --git a/Gemfile.lock b/Gemfile.lock index 25f497b..7384cd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - model_context_protocol (0.4.0) + model_context_protocol (0.5.0) json_rpc_handler (~> 0.1) GEM diff --git a/README.md b/README.md index cd62b12..c0ebf90 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,24 @@ module ModelContextProtocol end ``` +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], + 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: @@ -92,6 +110,26 @@ ModelContextProtocol.configure do |config| config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" } 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. + +configuration = ModelContextProtocol::Configuration.new +configuration.exception_reporter = ->(exception, context) do + # Your exception reporting logic here + # For example with Bugsnag: + Bugsnag.notify(exception) do |report| + report.add_metadata(:model_context_protocol, context) + end +end + +configuration.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" } + +server = ModelContextProtocol::Server.new( + # ... all other options + configuration:, +) ``` ### Exception Reporting @@ -125,10 +163,23 @@ 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", name: "message" }] + input_schema [{ type: "text", text: "message" }] + annotations( + title: "My Tool", + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false + ) - def self.call(message, context:) - Tool::Response.new([{ type: "text", content: "OK" }]) + input_schema type: 'object', + properties: { + message: { type: 'string' }, + }, + required: ['message'] + + def self.call(message:, context:) + Tool::Response.new([{ type: "text", text: "OK" }]) end end @@ -138,14 +189,33 @@ tool = MyTool 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, context| - Tool::Response.new([{ type: "text", content: "OK" }]) +tool = ModelContextProtocol::Tool.define( + name: "my_tool", + description: "This tool performs specific functionality...", + annotations: { + title: "My Tool", + read_only_hint: true + } +) do |args, context| + Tool::Response.new([{ type: "text", text: "OK" }]) end ``` The context parameter is the context passed into the server and can be used to pass per request information, e.g. around authentication state. +### Tool Annotations + +Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported: + +- `title`: A human-readable title for the tool +- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state) +- `destructive_hint`: Indicates if the tool performs destructive operations +- `idempotent_hint`: Indicates if the tool's operations are idempotent +- `open_world_hint`: Indicates if the tool operates in an open world context + +Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method. + ## Prompts 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. diff --git a/lib/model_context_protocol/configuration.rb b/lib/model_context_protocol/configuration.rb index 3792477..01e1d10 100644 --- a/lib/model_context_protocol/configuration.rb +++ b/lib/model_context_protocol/configuration.rb @@ -2,12 +2,57 @@ module ModelContextProtocol class Configuration - attr_accessor :exception_reporter - attr_accessor :instrumentation_callback + attr_writer :exception_reporter, :instrumentation_callback - def initialize - @exception_reporter = ->(exception, context) {} # Default no-op reporter - @instrumentation_callback = ->(data) {} # Default no-op callback + def initialize(exception_reporter: nil, instrumentation_callback: nil) + @exception_reporter = exception_reporter + @instrumentation_callback = instrumentation_callback + end + + def exception_reporter + @exception_reporter || default_exception_reporter + end + + def exception_reporter? + !@exception_reporter.nil? + end + + def instrumentation_callback + @instrumentation_callback || default_instrumentation_callback + end + + def instrumentation_callback? + !@instrumentation_callback.nil? + end + + def merge(other) + return self if other.nil? + + exception_reporter = if other.exception_reporter? + other.exception_reporter + else + @exception_reporter + end + instrumentation_callback = if other.instrumentation_callback? + other.instrumentation_callback + else + @instrumentation_callback + end + + Configuration.new( + exception_reporter:, + instrumentation_callback:, + ) + end + + private + + def default_exception_reporter + @default_exception_reporter ||= ->(exception, context) {} + end + + def default_instrumentation_callback + @default_instrumentation_callback ||= ->(data) {} end end end diff --git a/lib/model_context_protocol/content.rb b/lib/model_context_protocol/content.rb index e0b94b0..6a11e7a 100644 --- a/lib/model_context_protocol/content.rb +++ b/lib/model_context_protocol/content.rb @@ -12,7 +12,7 @@ module ModelContextProtocol end def to_h - { text:, annotations: }.compact + { text:, annotations:, type: "text" }.compact end end @@ -26,7 +26,7 @@ module ModelContextProtocol end def to_h - { data:, mime_type:, annotations: }.compact + { data:, mime_type:, annotations:, type: "image" }.compact end end end diff --git a/lib/model_context_protocol/instrumentation.rb b/lib/model_context_protocol/instrumentation.rb index 044896a..a29b0c9 100644 --- a/lib/model_context_protocol/instrumentation.rb +++ b/lib/model_context_protocol/instrumentation.rb @@ -12,7 +12,7 @@ module ModelContextProtocol end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) add_instrumentation_data(duration: end_time - start_time) - ModelContextProtocol.configuration.instrumentation_callback.call(@instrumentation_data) + configuration.instrumentation_callback.call(@instrumentation_data) result end diff --git a/lib/model_context_protocol/prompt.rb b/lib/model_context_protocol/prompt.rb index 877bc6a..8375775 100644 --- a/lib/model_context_protocol/prompt.rb +++ b/lib/model_context_protocol/prompt.rb @@ -114,7 +114,7 @@ module ModelContextProtocol private def required_args - arguments_value.filter_map { |arg| arg.name if arg.required } + arguments_value.filter_map { |arg| arg.name.to_sym if arg.required } end end end diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb index c1d3a8a..184f1f5 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/model_context_protocol/server.rb @@ -12,19 +12,21 @@ module ModelContextProtocol end end - PROTOCOL_VERSION = "2024-11-05" + PROTOCOL_VERSION = "2025-03-26" include Instrumentation - attr_accessor :name, :tools, :prompts, :resources, :context + attr_accessor :name, :tools, :prompts, :resources, :context, :configuration - def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], context: nil) + def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], context: nil, + configuration: nil) @name = name @tools = tools.to_h { |t| [t.name_value, t] } @prompts = prompts.to_h { |p| [p.name_value, p] } @resources = resources @resource_index = resources.index_by(&:uri) @context = context + @configuration = ModelContextProtocol.configuration.merge(configuration) @handlers = { "resources/list" => method(:list_resources), "resources/read" => method(:read_resource), @@ -186,7 +188,7 @@ module ModelContextProtocol end def report_exception(exception, context = {}) - ModelContextProtocol.configuration.exception_reporter.call(exception, context) + configuration.exception_reporter.call(exception, context) end end end diff --git a/lib/model_context_protocol/tool.rb b/lib/model_context_protocol/tool.rb index 7d7320e..6415b77 100644 --- a/lib/model_context_protocol/tool.rb +++ b/lib/model_context_protocol/tool.rb @@ -15,18 +15,47 @@ module ModelContextProtocol 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 attr_reader :description_value attr_reader :input_schema_value + attr_reader :annotations_value def call(*args, context:) raise NotImplementedError, "Subclasses must implement call" end def to_h - { name: name_value, description: description_value, inputSchema: input_schema_value } + result = { + name: name_value, + description: description_value, + inputSchema: input_schema_value, + } + result[:annotations] = annotations_value.to_h if annotations_value + result end def inherited(subclass) @@ -34,11 +63,12 @@ module ModelContextProtocol subclass.instance_variable_set(:@name_value, nil) subclass.instance_variable_set(:@description_value, nil) subclass.instance_variable_set(:@input_schema_value, nil) + subclass.instance_variable_set(:@annotations_value, nil) end def tool_name(value = NOT_SET) if value == NOT_SET - @name_value + name_value else @name_value = value end @@ -64,11 +94,20 @@ module ModelContextProtocol end end - def define(name: nil, description: nil, input_schema: nil, &block) + def annotations(hash = NOT_SET) + if hash == NOT_SET + @annotations_value + else + @annotations_value = Annotations.new(**hash) + 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) do |*args, context:| instance_exec(*args, context:, &block) end diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb index 3378961..719ada2 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.4.0" + VERSION = "0.5.0" end diff --git a/test/model_context_protocol/instrumentation_test.rb b/test/model_context_protocol/instrumentation_test.rb index 6b5ef99..7a4f933 100644 --- a/test/model_context_protocol/instrumentation_test.rb +++ b/test/model_context_protocol/instrumentation_test.rb @@ -6,12 +6,11 @@ module ModelContextProtocol class InstrumentationTest < ActiveSupport::TestCase class Subject include Instrumentation - attr_reader :instrumentation_data_received + attr_reader :instrumentation_data_received, :configuration def initialize - ModelContextProtocol.configure do |config| - config.instrumentation_callback = ->(data) { @instrumentation_data_received = data } - end + @configuration = ModelContextProtocol::Configuration.new + @configuration.instrumentation_callback = ->(data) { @instrumentation_data_received = data } end def instrumented_method diff --git a/test/model_context_protocol/prompt_test.rb b/test/model_context_protocol/prompt_test.rb index 5098d23..9d979d0 100644 --- a/test/model_context_protocol/prompt_test.rb +++ b/test/model_context_protocol/prompt_test.rb @@ -30,8 +30,8 @@ module ModelContextProtocol expected_template_result = { description: "Hello, world!", messages: [ - { role: "user", content: { text: "Hello, world!" } }, - { role: "assistant", content: { text: "Hello, friend!" } }, + { role: "user", content: { text: "Hello, world!", type: "text" } }, + { role: "assistant", content: { text: "Hello, friend!", type: "text" } }, ], } @@ -72,8 +72,8 @@ module ModelContextProtocol expected_template_result = { description: "Hello, world!", messages: [ - { role: "user", content: { text: "Hello, world!" } }, - { role: "assistant", content: { text: "Hello, friend!" } }, + { role: "user", content: { text: "Hello, world!", type: "text" } }, + { role: "assistant", content: { text: "Hello, friend!", type: "text" } }, ], } @@ -134,8 +134,8 @@ module ModelContextProtocol expected = { description: "Hello, world!", messages: [ - { role: "user", content: { text: "Hello, world!" } }, - { role: "assistant", content: { text: "Hello, friend! user: 123" } }, + { role: "user", content: { text: "Hello, world!", type: "text" } }, + { role: "assistant", content: { text: "Hello, friend! user: 123", type: "text" } }, ], } diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb index ca49cb3..bf88d5d 100644 --- a/test/model_context_protocol/server_test.rb +++ b/test/model_context_protocol/server_test.rb @@ -33,19 +33,19 @@ module ModelContextProtocol ) @server_name = "test_server" - ModelContextProtocol.configure do |config| - config.instrumentation_callback = instrumentation_helper.callback - end + configuration = ModelContextProtocol::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback @server = Server.new( name: @server_name, tools: [@tool], prompts: [@prompt], resources: [@resource], + configuration:, ) end - # https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/utilities/ping/#behavior-requirements + # https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping/#behavior-requirements test "#handle ping request returns empty response" do request = { jsonrpc: "2.0", @@ -98,7 +98,7 @@ module ModelContextProtocol "jsonrpc": "2.0", "id": 1, "result": { - "protocolVersion": "2024-11-05", + "protocolVersion": "2025-03-26", "capabilities": { "prompts": {}, "resources": {}, @@ -217,7 +217,7 @@ module ModelContextProtocol exception = StandardError.new("Tool error") @tool.expects(:call).raises(exception) - ModelContextProtocol.configuration.exception_reporter.expects(:call).with( + @server.configuration.exception_reporter.expects(:call).with( exception, { tool_name: "test_tool", @@ -347,14 +347,14 @@ module ModelContextProtocol id: 1, params: { name: "test_prompt", - arguments: { "test_argument" => "Hello, friend!" }, + arguments: { test_argument: "Hello, friend!" }, }, } expected_result = { description: "Hello, world!", messages: [ - { role: "user", content: { text: "Hello, world!" } }, + { role: "user", content: { text: "Hello, world!", type: "text" } }, ], } @@ -511,7 +511,7 @@ module ModelContextProtocol name: "Test resource", description: "Test resource", mimeType: "text/plain", - contents: [{ text: "Lorem ipsum dolor sit amet" }], + contents: [{ text: "Lorem ipsum dolor sit amet", type: "text" }], }, response[:result], ) @@ -531,5 +531,28 @@ module ModelContextProtocol assert_equal "unknown_method", response[:error][:data] assert_instrumentation_data({ method: "unknown_method" }) end + + test "the global configuration is used if no configuration is passed to the server" do + server = Server.new(name: "test_server") + assert_equal ModelContextProtocol.configuration.instrumentation_callback, + server.configuration.instrumentation_callback + assert_equal ModelContextProtocol.configuration.exception_reporter, + server.configuration.exception_reporter + end + + test "the server configuration takes precedence over the global configuration" do + configuration = ModelContextProtocol::Configuration.new + local_callback = ->(data) { puts "Local callback #{data.inspect}" } + local_exception_reporter = ->(exception, context) { + puts "Local exception reporter #{exception.inspect} #{context.inspect}" + } + configuration.instrumentation_callback = local_callback + configuration.exception_reporter = local_exception_reporter + + server = Server.new(name: "test_server", configuration:) + + assert_equal local_callback, server.configuration.instrumentation_callback + assert_equal local_exception_reporter, server.configuration.exception_reporter + end end end diff --git a/test/model_context_protocol/tool_test.rb b/test/model_context_protocol/tool_test.rb index f9d6e44..d9cae12 100644 --- a/test/model_context_protocol/tool_test.rb +++ b/test/model_context_protocol/tool_test.rb @@ -8,6 +8,13 @@ module ModelContextProtocol tool_name "test_tool" description "a test tool for testing" input_schema [{ type: "text", name: "message" }] + annotations( + title: "Test Tool", + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + ) class << self def call(message) @@ -21,6 +28,18 @@ module ModelContextProtocol assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: nil } end + test "#to_h includes annotations when present" do + tool = TestTool + expected_annotations = { + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + } + assert_equal tool.to_h[:annotations], expected_annotations + end + test "#call invokes the tool block and returns the response" do tool = TestTool response = tool.call("test") @@ -49,6 +68,7 @@ module ModelContextProtocol 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" }] @@ -63,5 +83,118 @@ module ModelContextProtocol assert_equal tool.description, "a mock tool for testing" assert_equal tool.input_schema, nil end + + test ".define allows definition of tools with annotations" do + tool = Tool.define( + name: "mock_tool", + description: "a mock tool for testing", + annotations: { + title: "Mock Tool", + read_only_hint: true, + }, + ) do |_| + Tool::Response.new([{ type: "text", content: "OK" }]) + end + + assert_equal tool.name_value, "mock_tool" + assert_equal tool.description, "a mock tool for testing" + assert_equal tool.input_schema, nil + assert_equal tool.annotations_value.to_h, { title: "Mock Tool", readOnlyHint: true } + end + + # Tests for Tool::Annotations class + test "Tool::Annotations initializes with all properties" do + annotations = Tool::Annotations.new( + title: "Test Tool", + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + ) + + assert_equal annotations.title, "Test Tool" + assert_equal annotations.read_only_hint, true + assert_equal annotations.destructive_hint, false + assert_equal annotations.idempotent_hint, true + assert_equal annotations.open_world_hint, false + end + + test "Tool::Annotations initializes with partial properties" do + annotations = Tool::Annotations.new( + title: "Test Tool", + read_only_hint: true, + ) + + assert_equal annotations.title, "Test Tool" + assert_equal annotations.read_only_hint, true + assert_nil annotations.destructive_hint + assert_nil annotations.idempotent_hint + assert_nil annotations.open_world_hint + end + + test "Tool::Annotations#to_h omits nil values" do + annotations = Tool::Annotations.new( + title: "Test Tool", + read_only_hint: true, + ) + + expected = { + title: "Test Tool", + readOnlyHint: true, + } + assert_equal annotations.to_h, expected + end + + test "Tool::Annotations#to_h handles all properties" do + annotations = Tool::Annotations.new( + title: "Test Tool", + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + ) + + expected = { + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + } + assert_equal annotations.to_h, expected + end + + test "Tool::Annotations#to_h returns empty hash when all values are nil" do + annotations = Tool::Annotations.new + assert_empty annotations.to_h + end + + test "Tool class method annotations can be set and retrieved" do + class AnnotationsTestTool < Tool + tool_name "annotations_test" + annotations( + title: "Annotations Test", + read_only_hint: true, + ) + end + + tool = AnnotationsTestTool + assert_instance_of Tool::Annotations, tool.annotations_value + assert_equal tool.annotations_value.title, "Annotations Test" + assert_equal tool.annotations_value.read_only_hint, true + end + + test "Tool class method annotations can be updated" do + class UpdatableAnnotationsTool < Tool + tool_name "updatable_annotations" + end + + tool = UpdatableAnnotationsTool + tool.annotations(title: "Initial") + assert_equal tool.annotations_value.title, "Initial" + + tool.annotations(title: "Updated") + assert_equal tool.annotations_value.title, "Updated" + end end end