Internal Release 0.5.0

Bump protocol version to 2025-03-26
Provide fallback value for tool_name
Make configuration per server
Add support for tool annotations
document tool annotations
This commit is contained in:
Topher Bullock 2025-03-28 09:51:32 -04:00
commit 7175d49108
13 changed files with 353 additions and 42 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true
module ModelContextProtocol
VERSION = "0.4.0"
VERSION = "0.5.0"
end

View file

@ -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

View file

@ -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" } },
],
}

View file

@ -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

View file

@ -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