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 "minitest-reporters"
gem "mocha" gem "mocha"
gem "debug"

View file

@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
model_context_protocol (0.6.0) model_context_protocol (0.7.0)
json_rpc_handler (~> 0.1) json_rpc_handler (~> 0.1)
GEM GEM
@ -28,9 +28,18 @@ GEM
builder (3.3.0) builder (3.3.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.3) connection_pool (2.5.3)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.1) drb (2.2.1)
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) 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 (2.11.3)
json_rpc_handler (0.1.1) json_rpc_handler (0.1.1)
language_server-protocol (3.17.0.4) language_server-protocol (3.17.0.4)
@ -48,11 +57,21 @@ GEM
parser (3.3.8.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
prism (1.4.0) prism (1.4.0)
psych (5.2.4)
date
stringio
racc (1.8.1) racc (1.8.1)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rdoc (6.13.1)
psych (>= 4.0.0)
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5)
rubocop (1.75.5) rubocop (1.75.5)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
@ -72,6 +91,7 @@ GEM
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
securerandom (0.4.1) securerandom (0.4.1)
stringio (3.1.7)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
@ -85,6 +105,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
activesupport activesupport
debug
minitest (~> 5.1) minitest (~> 5.1)
minitest-reporters minitest-reporters
mocha mocha

211
README.md
View file

@ -1,17 +1,6 @@
# Model Context Protocol # Model Context Protocol
A Ruby gem implementing Model Context Protocol + JSON RPC 2.0 spec A Ruby gem for implementing Model Context Protocol servers
## 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.
## MCP Server ## 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. It implements the Model Context Protocol specification, handling model context requests and responses.
### Key Features ### Key Features
- Implements JSON-RPC 2.0 message handling - Implements JSON-RPC 2.0 message handling
- Supports protocol initialization and capability negotiation - Supports protocol initialization and capability negotiation
- Manages tool registration and invocation - 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 ### Supported Methods
- `initialize` - Initializes the protocol and returns server capabilities - `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/list` - Lists all registered tools and their schemas
- `tools/call` - Invokes a specific tool with provided arguments - `tools/call` - Invokes a specific tool with provided arguments
- `prompts/list` - Lists all registered prompts and their schemas - `prompts/list` - Lists all registered prompts and their schemas
- `prompts/get` - Retrieves a specific prompt by name - `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 ### Usage
#### Rails Controller #### 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 ```ruby
module ModelContextProtocol module ModelContextProtocol
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
sig { void }
def index def index
server = ModelContextProtocol::Server.new( server = ModelContextProtocol::Server.new(
name: "my_server", name: "my_server",
@ -57,29 +60,9 @@ module ModelContextProtocol
end 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 #### 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 ```ruby
#!/usr/bin/env ruby #!/usr/bin/env ruby
@ -89,11 +72,12 @@ require "model_context_protocol/transports/stdio"
# Create a simple tool # Create a simple tool
class ExampleTool < ModelContextProtocol::Tool class ExampleTool < ModelContextProtocol::Tool
description "A simple example tool that echoes back its arguments" description "A simple example tool that echoes back its arguments"
input_schema type: "object", input_schema(
properties: { properties: {
message: { type: "string" }, message: { type: "string" },
}, },
required: ["message"] required: ["message"]
)
class << self class << self
def call(message:, server_context:) def call(message:, server_context:)
@ -125,36 +109,13 @@ $ ./stdio_server.rb
{"jsonrpc":"2.0","id":"3","result":["ExampleTool"]} {"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 ## Configuration
The gem can be configured using the `ModelContextProtocol.configure` block: The gem can be configured using the `ModelContextProtocol.configure` block:
```ruby ```ruby
ModelContextProtocol.configure do |config| ModelContextProtocol.configure do |config|
config.exception_reporter = ->(exception, server_context) do config.exception_reporter = do |exception, server_context|
# Your exception reporting logic here # Your exception reporting logic here
# For example with Bugsnag: # For example with Bugsnag:
Bugsnag.notify(exception) do |report| Bugsnag.notify(exception) do |report|
@ -162,15 +123,19 @@ ModelContextProtocol.configure do |config|
end end
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 end
```
or by creating an explicit configuration and passing it into the server. 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 This is useful for systems where an application hosts more than one MCP server but
they might require different instrumentation callbacks. they might require different instrumentation callbacks.
```ruby
configuration = ModelContextProtocol::Configuration.new configuration = ModelContextProtocol::Configuration.new
configuration.exception_reporter = ->(exception, server_context) do configuration.exception_reporter = do |exception, server_context|
# Your exception reporting logic here # Your exception reporting logic here
# For example with Bugsnag: # For example with Bugsnag:
Bugsnag.notify(exception) do |report| Bugsnag.notify(exception) do |report|
@ -178,7 +143,9 @@ configuration.exception_reporter = ->(exception, server_context) do
end end
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( server = ModelContextProtocol::Server.new(
# ... all other options # ... 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 ### Server Protocol Version
The server's protocol version can be overridden using the `protocol_version` class method: 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 ```ruby
class MyTool < ModelContextProtocol::Tool class MyTool < ModelContextProtocol::Tool
description "This tool performs specific functionality..." description "This tool performs specific functionality..."
input_schema [{ type: "text", text: "message" }] input_schema(
properties: {
message: { type: "string" },
},
required: ["message"]
)
annotations( annotations(
title: "My Tool", title: "My Tool",
read_only_hint: true, read_only_hint: true,
@ -242,12 +273,6 @@ class MyTool < ModelContextProtocol::Tool
open_world_hint: false open_world_hint: false
) )
input_schema type: 'object',
properties: {
message: { type: 'string' },
},
required: ['message']
def self.call(message:, server_context:) def self.call(message:, server_context:)
Tool::Response.new([{ type: "text", text: "OK" }]) Tool::Response.new([{ type: "text", text: "OK" }])
end end
@ -392,7 +417,9 @@ To register a handler pass a proc/lambda to as `instrumentation_callback` into t
```ruby ```ruby
ModelContextProtocol.configure do |config| 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 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. `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 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 ## Releases
TODO TODO

View file

@ -6,30 +6,89 @@ require "model_context_protocol"
require "model_context_protocol/transports/stdio" require "model_context_protocol/transports/stdio"
# Create a simple tool # Create a simple tool
class ExampleTool < ModelContextProtocol::Tool class ExampleTool < MCP::Tool
description "A simple example tool that echoes back its arguments" description "A simple example tool that adds two numbers"
input_schema type: "object", input_schema(
properties: { properties: {
message: { type: "string" }, a: { type: "number" },
b: { type: "number" },
}, },
required: ["message"] required: ["a", "b"],
)
class << self class << self
def call(message:, server_context:) def call(a:, b:)
ModelContextProtocol::Tool::Response.new([{ MCP::Tool::Response.new([{
type: "text", type: "text",
text: "Hello from example tool! Message: #{message}", text: "The sum of #{a} and #{b} is #{a + b}",
}]) }])
end end
end 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 # Set up the server
server = ModelContextProtocol::Server.new( server = MCP::Server.new(
name: "example_server", name: "example_server",
tools: [ExampleTool], 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 # Create and start the transport
transport = ModelContextProtocol::Transports::StdioTransport.new(server) transport = MCP::Transports::StdioTransport.new(server)
transport.open transport.open

View file

@ -4,9 +4,18 @@
require "model_context_protocol/server" require "model_context_protocol/server"
require "model_context_protocol/string_utils" require "model_context_protocol/string_utils"
require "model_context_protocol/tool" 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/content"
require "model_context_protocol/resource" 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"
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/version"
require "model_context_protocol/configuration" require "model_context_protocol/configuration"
require "model_context_protocol/methods" require "model_context_protocol/methods"
@ -31,3 +40,5 @@ module ModelContextProtocol
end end
end end
end end
MCP = ModelContextProtocol

View file

@ -2,7 +2,7 @@
module ModelContextProtocol module ModelContextProtocol
class Configuration class Configuration
DEFAULT_PROTOCOL_VERSION = "2025-03-26" DEFAULT_PROTOCOL_VERSION = "2024-11-05"
attr_writer :exception_reporter, :instrumentation_callback, :protocol_version attr_writer :exception_reporter, :instrumentation_callback, :protocol_version

View file

@ -2,13 +2,85 @@
module ModelContextProtocol module ModelContextProtocol
module Methods 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" INITIALIZE = "initialize"
PING = "ping" 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
end end

View file

@ -3,47 +3,6 @@
module ModelContextProtocol module ModelContextProtocol
class Prompt 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 class << self
NOT_SET = Object.new 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 module ModelContextProtocol
class Resource 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 @uri = uri
@name = name @name = name
@description = description @description = description
@mime_type = mime_type @mime_type = mime_type
@contents = contents
end end
def to_h def to_h
@ -19,7 +18,6 @@ module ModelContextProtocol
name: @name, name: @name,
description: @description, description: @description,
mimeType: @mime_type, mimeType: @mime_type,
contents: @contents.map(&:to_h),
} }
end end
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 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, def initialize(
configuration: nil) name: "model_context_protocol",
tools: [],
prompts: [],
resources: [],
resource_templates: [],
server_context: nil,
configuration: nil,
capabilities: { prompts: {}, resources: {}, tools: {} }
)
@name = name @name = name
@tools = tools.to_h { |t| [t.name_value, t] } @tools = tools.to_h { |t| [t.name_value, t] }
@prompts = prompts.to_h { |p| [p.name_value, p] } @prompts = prompts.to_h { |p| [p.name_value, p] }
@resources = resources @resources = resources
@resource_templates = resource_templates
@resource_index = index_resources_by_uri(resources) @resource_index = index_resources_by_uri(resources)
@server_context = server_context @server_context = server_context
@configuration = ModelContextProtocol.configuration.merge(configuration) @configuration = ModelContextProtocol.configuration.merge(configuration)
@capabilities = capabilities
@handlers = { @handlers = {
Methods::RESOURCES_LIST => method(:list_resources), 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_LIST => method(:list_tools),
Methods::TOOLS_CALL => method(:call_tool), Methods::TOOLS_CALL => method(:call_tool),
Methods::PROMPTS_LIST => method(:list_prompts), Methods::PROMPTS_LIST => method(:list_prompts),
Methods::PROMPTS_GET => method(:get_prompt), Methods::PROMPTS_GET => method(:get_prompt),
Methods::INITIALIZE => method(:init), Methods::INITIALIZE => method(:init),
Methods::PING => ->(_) { {} }, Methods::PING => ->(_) { {} },
# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
Methods::LOGGING_SET_LEVEL => ->(_) {},
} }
end end
@ -55,6 +72,16 @@ module ModelContextProtocol
end end
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) def resources_list_handler(&block)
@handlers[Methods::RESOURCES_LIST] = block @handlers[Methods::RESOURCES_LIST] = block
end end
@ -63,6 +90,10 @@ module ModelContextProtocol
@handlers[Methods::RESOURCES_READ] = block @handlers[Methods::RESOURCES_READ] = block
end end
def resources_templates_list_handler(&block)
@handlers[Methods::RESOURCES_TEMPLATES_LIST] = block
end
def tools_list_handler(&block) def tools_list_handler(&block)
@handlers[Methods::TOOLS_LIST] = block @handlers[Methods::TOOLS_LIST] = block
end end
@ -88,6 +119,8 @@ module ModelContextProtocol
return return
end end
Methods.ensure_capability!(method, capabilities)
->(params) { ->(params) {
instrument_call(method) do instrument_call(method) do
case method case method
@ -97,6 +130,10 @@ module ModelContextProtocol
{ prompts: @handlers[Methods::PROMPTS_LIST].call(params) } { prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
when Methods::RESOURCES_LIST when Methods::RESOURCES_LIST
{ resources: @handlers[Methods::RESOURCES_LIST].call(params) } { 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 else
@handlers[method].call(params) @handlers[method].call(params)
end end
@ -113,14 +150,6 @@ module ModelContextProtocol
} }
end end
def capabilities
@capabilities ||= {
prompts: {},
resources: {},
tools: {},
}
end
def server_info def server_info
@server_info ||= { @server_info ||= {
name:, name:,
@ -151,10 +180,25 @@ module ModelContextProtocol
raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found) raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found)
end end
arguments = request[:arguments]
add_instrumentation_data(tool_name:) 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 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 rescue => e
raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e) raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
end end
@ -188,18 +232,17 @@ module ModelContextProtocol
@resources.map(&:to_h) @resources.map(&:to_h)
end 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) add_instrumentation_data(method: Methods::RESOURCES_READ)
resource_uri = request[:uri] add_instrumentation_data(resource_uri: request[:uri])
[]
end
resource = @resource_index[resource_uri] def list_resource_templates(request)
unless resource add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST)
add_instrumentation_data(error: :resource_not_found)
raise RequestHandlerError.new("Resource not found #{resource_uri}", request, error_type: :resource_not_found)
end
add_instrumentation_data(resource_uri:) @resource_templates.map(&:to_h)
resource.to_h
end end
def report_exception(exception, server_context = {}) def report_exception(exception, server_context = {})

View file

@ -2,41 +2,6 @@
module ModelContextProtocol module ModelContextProtocol
class Tool 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 class << self
NOT_SET = Object.new NOT_SET = Object.new
@ -52,7 +17,7 @@ module ModelContextProtocol
result = { result = {
name: name_value, name: name_value,
description: description_value, description: description_value,
inputSchema: input_schema_value, inputSchema: input_schema_value.to_h,
} }
result[:annotations] = annotations_value.to_h if annotations_value result[:annotations] = annotations_value.to_h if annotations_value
result result
@ -88,8 +53,12 @@ module ModelContextProtocol
def input_schema(value = NOT_SET) def input_schema(value = NOT_SET)
if value == NOT_SET if value == NOT_SET
@input_schema_value input_schema_value
else 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 @input_schema_value = value
end end
end end
@ -108,9 +77,7 @@ module ModelContextProtocol
description description description description
input_schema input_schema input_schema input_schema
self.annotations(annotations) if annotations self.annotations(annotations) if annotations
define_singleton_method(:call) do |*args, server_context:| define_singleton_method(:call, &block) if block
instance_exec(*args, server_context:, &block)
end
end end
end 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 # frozen_string_literal: true
module ModelContextProtocol module ModelContextProtocol
VERSION = "0.6.0" VERSION = "0.7.0"
end 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