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
This commit is contained in:
Tyler Rowsell 2025-05-08 10:11:20 -04:00 • committed by Topher Bullock
commit baeb99ffbc
23 changed files with 842 additions and 253 deletions

View file

@ -12,3 +12,4 @@ gem "rubocop-shopify", require: false
gem "minitest-reporters"
gem "mocha"
gem "debug"

View file

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

211
README.md
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more