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:
parent
62184957e0
commit
baeb99ffbc
23 changed files with 842 additions and 253 deletions
1
Gemfile
1
Gemfile
|
|
@ -12,3 +12,4 @@ gem "rubocop-shopify", require: false
|
|||
|
||||
gem "minitest-reporters"
|
||||
gem "mocha"
|
||||
gem "debug"
|
||||
|
|
|
|||
23
Gemfile.lock
23
Gemfile.lock
|
|
@ -1,7 +1,7 @@
|
|||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
model_context_protocol (0.6.0)
|
||||
model_context_protocol (0.7.0)
|
||||
json_rpc_handler (~> 0.1)
|
||||
|
||||
GEM
|
||||
|
|
@ -28,9 +28,18 @@ GEM
|
|||
builder (3.3.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
drb (2.2.1)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.2)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (2.11.3)
|
||||
json_rpc_handler (0.1.1)
|
||||
language_server-protocol (3.17.0.4)
|
||||
|
|
@ -48,11 +57,21 @@ GEM
|
|||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
psych (5.2.4)
|
||||
date
|
||||
stringio
|
||||
racc (1.8.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rdoc (6.13.1)
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
io-console (~> 0.5)
|
||||
rubocop (1.75.5)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
|
|
@ -72,6 +91,7 @@ GEM
|
|||
ruby-progressbar (1.13.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
securerandom (0.4.1)
|
||||
stringio (3.1.7)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.4)
|
||||
|
|
@ -85,6 +105,7 @@ PLATFORMS
|
|||
|
||||
DEPENDENCIES
|
||||
activesupport
|
||||
debug
|
||||
minitest (~> 5.1)
|
||||
minitest-reporters
|
||||
mocha
|
||||
|
|
|
|||
211
README.md
211
README.md
|
|
@ -1,17 +1,6 @@
|
|||
# Model Context Protocol
|
||||
|
||||
A Ruby gem implementing Model Context Protocol + JSON RPC 2.0 spec
|
||||
|
||||
## Overview
|
||||
|
||||
Model Context Protocol provides a standardized way to interact with language models through a JSON RPC 2.0 interface.
|
||||
|
||||
It handles request/response formatting, error handling, and tool integrations in a type-safe manner using Sorbet.
|
||||
|
||||
See https://spec.modelcontextprotocol.io/specification/
|
||||
|
||||
This implementation breaks from the transport examples in the spec as it implements a POST-based JSON RPC call rather
|
||||
than a stateful SSE transport.
|
||||
A Ruby gem for implementing Model Context Protocol servers
|
||||
|
||||
## MCP Server
|
||||
|
||||
|
|
@ -19,31 +8,45 @@ The `ModelContextProtocol::Server` class is the core component that handles JSON
|
|||
It implements the Model Context Protocol specification, handling model context requests and responses.
|
||||
|
||||
### Key Features
|
||||
|
||||
- Implements JSON-RPC 2.0 message handling
|
||||
- Supports protocol initialization and capability negotiation
|
||||
- Manages tool registration and invocation
|
||||
- Provides type-safe request/response handling using Sorbet
|
||||
- Supports prompt registration and execution
|
||||
- Supports resource registration and retrieval
|
||||
|
||||
### Supported Methods
|
||||
|
||||
- `initialize` - Initializes the protocol and returns server capabilities
|
||||
- `ping` - Simple health check that returns "pong"
|
||||
- `ping` - Simple health check
|
||||
- `tools/list` - Lists all registered tools and their schemas
|
||||
- `tools/call` - Invokes a specific tool with provided arguments
|
||||
- `prompts/list` - Lists all registered prompts and their schemas
|
||||
- `prompts/get` - Retrieves a specific prompt by name
|
||||
- `resources/list` - Lists all registered resources and their schemas
|
||||
- `resources/read` - Retrieves a specific resource by name
|
||||
- `resources/templates/list` - Lists all registered resource templates and their schemas
|
||||
|
||||
### Unsupported Features ( to be implemented in future versions )
|
||||
|
||||
- Notifications
|
||||
- Log Level
|
||||
- Resource subscriptions
|
||||
- Completions
|
||||
- Complete StreamableHTTP implementation with streaming responses
|
||||
|
||||
### Usage
|
||||
|
||||
#### Rails Controller
|
||||
Implement an `ApplicationController` which calls the `Server#handle` method, eg
|
||||
|
||||
When added to a Rails controller on a route that handles POST requests, your server will be compliant with non-streaming
|
||||
[StreamableHTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport
|
||||
requests.
|
||||
|
||||
You can use the `Server#handle_json` method to handle requests.
|
||||
|
||||
```ruby
|
||||
module ModelContextProtocol
|
||||
class ApplicationController < ActionController::Base
|
||||
|
||||
sig { void }
|
||||
def index
|
||||
server = ModelContextProtocol::Server.new(
|
||||
name: "my_server",
|
||||
|
|
@ -57,29 +60,9 @@ module ModelContextProtocol
|
|||
end
|
||||
```
|
||||
|
||||
or, if you want/need to parse the json yourself you can do the following
|
||||
|
||||
```ruby
|
||||
module ModelContextProtocol
|
||||
class ApplicationController < ActionController::Base
|
||||
|
||||
sig { void }
|
||||
def index
|
||||
server = ModelContextProtocol::Server.new(
|
||||
name: "my_server",
|
||||
tools: [SomeTool, AnotherTool],
|
||||
prompts: [MyPrompt],
|
||||
server_context: { user_id: current_user.id },
|
||||
)
|
||||
render(json: server.handle(JSON.parse(request.body.read)).to_h)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### Stdio Transport
|
||||
|
||||
If you want to build a simple command-line application, you can use the stdio transport:
|
||||
If you want to build a local command-line application, you can use the stdio transport:
|
||||
|
||||
```ruby
|
||||
#!/usr/bin/env ruby
|
||||
|
|
@ -89,11 +72,12 @@ require "model_context_protocol/transports/stdio"
|
|||
# Create a simple tool
|
||||
class ExampleTool < ModelContextProtocol::Tool
|
||||
description "A simple example tool that echoes back its arguments"
|
||||
input_schema type: "object",
|
||||
input_schema(
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
required: ["message"]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(message:, server_context:)
|
||||
|
|
@ -125,36 +109,13 @@ $ ./stdio_server.rb
|
|||
{"jsonrpc":"2.0","id":"3","result":["ExampleTool"]}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
#### Testing without a transport
|
||||
|
||||
To see sample responses without setting up a client to hit the server, you can simply run
|
||||
|
||||
```ruby
|
||||
server = ModelContextProtocol::Server.new(
|
||||
name: "my_server",
|
||||
tools: [SomeTool, AnotherTool],
|
||||
prompts: [MyPrompt],
|
||||
server_context: { user_id: current_user.id },
|
||||
)
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/list",
|
||||
params: {},
|
||||
id: "1"
|
||||
}
|
||||
server.handle(request)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The gem can be configured using the `ModelContextProtocol.configure` block:
|
||||
|
||||
```ruby
|
||||
ModelContextProtocol.configure do |config|
|
||||
config.exception_reporter = ->(exception, server_context) do
|
||||
config.exception_reporter = do |exception, server_context|
|
||||
# Your exception reporting logic here
|
||||
# For example with Bugsnag:
|
||||
Bugsnag.notify(exception) do |report|
|
||||
|
|
@ -162,15 +123,19 @@ ModelContextProtocol.configure do |config|
|
|||
end
|
||||
end
|
||||
|
||||
config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" }
|
||||
config.instrumentation_callback = do |data|
|
||||
puts "Got instrumentation data #{data.inspect}"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
or by creating an explicit configuration and passing it into the server.
|
||||
This is useful for systems where an application hosts more than one MCP server but
|
||||
they might require different instrumentation callbacks.
|
||||
|
||||
```ruby
|
||||
configuration = ModelContextProtocol::Configuration.new
|
||||
configuration.exception_reporter = ->(exception, server_context) do
|
||||
configuration.exception_reporter = do |exception, server_context|
|
||||
# Your exception reporting logic here
|
||||
# For example with Bugsnag:
|
||||
Bugsnag.notify(exception) do |report|
|
||||
|
|
@ -178,7 +143,9 @@ configuration.exception_reporter = ->(exception, server_context) do
|
|||
end
|
||||
end
|
||||
|
||||
configuration.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" }
|
||||
configuration.instrumentation_callback = do |data|
|
||||
puts "Got instrumentation data #{data.inspect}"
|
||||
end
|
||||
|
||||
server = ModelContextProtocol::Server.new(
|
||||
# ... all other options
|
||||
|
|
@ -186,6 +153,65 @@ server = ModelContextProtocol::Server.new(
|
|||
)
|
||||
```
|
||||
|
||||
### Server Context and Configuration Block Data
|
||||
|
||||
#### `server_context`
|
||||
|
||||
The `server_context` is a user-defined hash that is passed into the server instance and made available to tools, prompts, and exception/instrumentation callbacks. It can be used to provide contextual information such as authentication state, user IDs, or request-specific data.
|
||||
|
||||
**Type:**
|
||||
```ruby
|
||||
server_context: { [String, Symbol] => Any }
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```ruby
|
||||
server = ModelContextProtocol::Server.new(
|
||||
name: "my_server",
|
||||
server_context: { user_id: current_user.id, request_id: request.uuid }
|
||||
)
|
||||
```
|
||||
|
||||
This hash is then passed as the `server_context` argument to tool and prompt calls, and is included in exception and instrumentation callbacks.
|
||||
|
||||
#### Configuration Block Data
|
||||
|
||||
##### Exception Reporter
|
||||
|
||||
The exception reporter receives:
|
||||
|
||||
- `exception`: The Ruby exception object that was raised
|
||||
- `server_context`: The context hash provided to the server
|
||||
|
||||
**Signature:**
|
||||
```ruby
|
||||
exception_reporter = ->(exception, server_context) { ... }
|
||||
```
|
||||
|
||||
##### Instrumentation Callback
|
||||
|
||||
The instrumentation callback receives a hash with the following possible keys:
|
||||
|
||||
- `method`: (String) The protocol method called (e.g., "ping", "tools/list")
|
||||
- `tool_name`: (String, optional) The name of the tool called
|
||||
- `prompt_name`: (String, optional) The name of the prompt called
|
||||
- `resource_uri`: (String, optional) The URI of the resource called
|
||||
- `error`: (String, optional) Error code if a lookup failed
|
||||
- `duration`: (Float) Duration of the call in seconds
|
||||
|
||||
**Type:**
|
||||
```ruby
|
||||
instrumentation_callback = ->(data) { ... }
|
||||
# where data is a Hash with keys as described above
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```ruby
|
||||
config.instrumentation_callback = ->(data) do
|
||||
puts "Instrumentation: #{data.inspect}"
|
||||
end
|
||||
```
|
||||
|
||||
### Server Protocol Version
|
||||
|
||||
The server's protocol version can be overridden using the `protocol_version` class method:
|
||||
|
|
@ -233,7 +259,12 @@ This gem provides a `ModelContextProtocol::Tool` class that can be used to creat
|
|||
```ruby
|
||||
class MyTool < ModelContextProtocol::Tool
|
||||
description "This tool performs specific functionality..."
|
||||
input_schema [{ type: "text", text: "message" }]
|
||||
input_schema(
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
required: ["message"]
|
||||
)
|
||||
annotations(
|
||||
title: "My Tool",
|
||||
read_only_hint: true,
|
||||
|
|
@ -242,12 +273,6 @@ class MyTool < ModelContextProtocol::Tool
|
|||
open_world_hint: false
|
||||
)
|
||||
|
||||
input_schema type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
},
|
||||
required: ['message']
|
||||
|
||||
def self.call(message:, server_context:)
|
||||
Tool::Response.new([{ type: "text", text: "OK" }])
|
||||
end
|
||||
|
|
@ -392,7 +417,9 @@ To register a handler pass a proc/lambda to as `instrumentation_callback` into t
|
|||
|
||||
```ruby
|
||||
ModelContextProtocol.configure do |config|
|
||||
config.instrumentation_callback = -> (data) { puts "Got instrumentation data #{data.inspect}" }
|
||||
config.instrumentation_callback = do |data|
|
||||
puts "Got instrumentation data #{data.inspect}"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
|
@ -407,6 +434,40 @@ The data contains the following keys:
|
|||
`tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered.
|
||||
This is to avoid potential issues with metric cardinality
|
||||
|
||||
## Resources
|
||||
|
||||
MCP spec includes [Resources](https://modelcontextprotocol.io/docs/concepts/resources)
|
||||
|
||||
The `ModelContextProtocol::Resource` class provides a way to register resources with the server.
|
||||
|
||||
```ruby
|
||||
resource = ModelContextProtocol::Resource.new(
|
||||
uri: "example.com/my_resource",
|
||||
mime_type: "text/plain",
|
||||
text: "Lorem ipsum dolor sit amet"
|
||||
)
|
||||
|
||||
server = ModelContextProtocol::Server.new(
|
||||
name: "my_server",
|
||||
resources: [resource],
|
||||
)
|
||||
```
|
||||
|
||||
The server must register a handler for the `resources/read` method to retrieve a resource dynamically.
|
||||
|
||||
```ruby
|
||||
server.resources_read_handler do |params|
|
||||
[{
|
||||
uri: params[:uri],
|
||||
mimeType: "text/plain",
|
||||
text: "Hello, world!",
|
||||
}]
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
otherwise 'resources/read' requests will be a no-op.
|
||||
|
||||
## Releases
|
||||
|
||||
TODO
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
21
lib/model_context_protocol/prompt/argument.rb
Normal file
21
lib/model_context_protocol/prompt/argument.rb
Normal 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
|
||||
19
lib/model_context_protocol/prompt/message.rb
Normal file
19
lib/model_context_protocol/prompt/message.rb
Normal 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
|
||||
19
lib/model_context_protocol/prompt/result.rb
Normal file
19
lib/model_context_protocol/prompt/result.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
24
lib/model_context_protocol/resource_template.rb
Normal file
24
lib/model_context_protocol/resource_template.rb
Normal 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
|
||||
|
|
@ -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 = {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
lib/model_context_protocol/tool/annotations.rb
Normal file
27
lib/model_context_protocol/tool/annotations.rb
Normal 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
|
||||
26
lib/model_context_protocol/tool/input_schema.rb
Normal file
26
lib/model_context_protocol/tool/input_schema.rb
Normal 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
|
||||
18
lib/model_context_protocol/tool/response.rb
Normal file
18
lib/model_context_protocol/tool/response.rb
Normal 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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ModelContextProtocol
|
||||
VERSION = "0.6.0"
|
||||
VERSION = "0.7.0"
|
||||
end
|
||||
|
|
|
|||
68
test/model_context_protocol/methods_test.rb
Normal file
68
test/model_context_protocol/methods_test.rb
Normal 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
Loading…
Reference in a new issue