Internal Release 0.4.0
- Added support for handle_json method from JSON RPC gem to allow consumers to pass JSON strings - Added context parameter to prompt templating system - Updated tool and prompt registration to use class references instead of instances - Changed tool response is_error to isError to match protocol specification
This commit is contained in:
parent
0645351585
commit
2eca3681df
9 changed files with 306 additions and 151 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
PATH
|
PATH
|
||||||
remote: .
|
remote: .
|
||||||
specs:
|
specs:
|
||||||
model_context_protocol (0.3.0)
|
model_context_protocol (0.4.0)
|
||||||
json_rpc_handler (~> 0.1)
|
json_rpc_handler (~> 0.1)
|
||||||
|
|
||||||
GEM
|
GEM
|
||||||
|
|
|
||||||
78
README.md
78
README.md
|
|
@ -46,11 +46,31 @@ module ModelContextProtocol
|
||||||
def index
|
def index
|
||||||
server = ModelContextProtocol::Server.new(
|
server = ModelContextProtocol::Server.new(
|
||||||
name: "my_server",
|
name: "my_server",
|
||||||
tools: [someTool, anotherTool],
|
tools: [SomeTool, AnotherTool],
|
||||||
prompts: [myPrompt],
|
prompts: [MyPrompt],
|
||||||
context: nil,
|
context: { user_id: current_user.id },
|
||||||
)
|
)
|
||||||
render(json: server.handle(request.body.read).to_h)
|
render(json: server.handle_json(request.body.read).to_h)
|
||||||
|
end
|
||||||
|
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],
|
||||||
|
context: { user_id: current_user.id },
|
||||||
|
)
|
||||||
|
render(json: server.handle(JSON.parse(request.body.read)).to_h)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -77,16 +97,19 @@ end
|
||||||
### Exception Reporting
|
### Exception Reporting
|
||||||
|
|
||||||
The exception reporter receives two arguments:
|
The exception reporter receives two arguments:
|
||||||
|
|
||||||
- `exception`: The Ruby exception object that was raised
|
- `exception`: The Ruby exception object that was raised
|
||||||
- `context`: A hash containing contextual information about where the error occurred
|
- `context`: A hash containing contextual information about where the error occurred
|
||||||
|
|
||||||
The context hash includes:
|
The context hash includes:
|
||||||
|
|
||||||
- For tool calls: `{ tool_name: "name", arguments: { ... } }`
|
- For tool calls: `{ tool_name: "name", arguments: { ... } }`
|
||||||
- For general request handling: `{ request: { ... } }`
|
- For general request handling: `{ request: { ... } }`
|
||||||
|
|
||||||
When an exception occurs:
|
When an exception occurs:
|
||||||
|
|
||||||
1. The exception is reported via the configured reporter
|
1. The exception is reported via the configured reporter
|
||||||
2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", is_error: true }`
|
2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", isError: true }`
|
||||||
3. For other requests, the exception is re-raised after reporting
|
3. For other requests, the exception is re-raised after reporting
|
||||||
|
|
||||||
If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions.
|
If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions.
|
||||||
|
|
@ -104,12 +127,12 @@ class MyTool < ModelContextProtocol::Tool
|
||||||
description "This tool performs specific functionality..."
|
description "This tool performs specific functionality..."
|
||||||
input_schema [{ type: "text", name: "message" }]
|
input_schema [{ type: "text", name: "message" }]
|
||||||
|
|
||||||
def call(message, context:)
|
def self.call(message, context:)
|
||||||
Tool::Response.new([{ type: "text", content: "OK" }])
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = MyTool.new
|
tool = MyTool
|
||||||
```
|
```
|
||||||
|
|
||||||
2. By using the `ModelContextProtocol::Tool.define` method with a block:
|
2. By using the `ModelContextProtocol::Tool.define` method with a block:
|
||||||
|
|
@ -143,22 +166,26 @@ class MyPrompt < ModelContextProtocol::Prompt
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def template(args)
|
class << self
|
||||||
Prompt::Result.new(
|
def template(args, context:)
|
||||||
description: "Response description",
|
Prompt::Result.new(
|
||||||
messages: [
|
description: "Response description",
|
||||||
Prompt::Message.new(
|
messages: [
|
||||||
role: "user",
|
Prompt::Message.new(
|
||||||
content: Content::Text.new("User message")
|
role: "user",
|
||||||
),
|
content: Content::Text.new("User message")
|
||||||
Prompt::Message.new(
|
),
|
||||||
role: "assistant",
|
Prompt::Message.new(
|
||||||
content: Content::Text.new(args["message"])
|
role: "assistant",
|
||||||
)
|
content: Content::Text.new(args["message"])
|
||||||
]
|
)
|
||||||
)
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
prompt = MyPrompt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Using the `ModelContextProtocol::Prompt.define` method:
|
2. Using the `ModelContextProtocol::Prompt.define` method:
|
||||||
|
|
@ -174,7 +201,7 @@ prompt = ModelContextProtocol::Prompt.define(
|
||||||
required: true
|
required: true
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
) do |args|
|
) do |args, context:|
|
||||||
Prompt::Result.new(
|
Prompt::Result.new(
|
||||||
description: "Response description",
|
description: "Response description",
|
||||||
messages: [
|
messages: [
|
||||||
|
|
@ -191,6 +218,9 @@ prompt = ModelContextProtocol::Prompt.define(
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The context parameter is the context passed into the server and can be used to pass per request information,
|
||||||
|
e.g. around authentication state or user preferences.
|
||||||
|
|
||||||
### Key Components
|
### Key Components
|
||||||
|
|
||||||
- `Prompt::Argument` - Defines input parameters for the prompt template
|
- `Prompt::Argument` - Defines input parameters for the prompt template
|
||||||
|
|
@ -205,8 +235,8 @@ Register prompts with the MCP server:
|
||||||
```ruby
|
```ruby
|
||||||
server = ModelContextProtocol::Server.new(
|
server = ModelContextProtocol::Server.new(
|
||||||
name: "my_server",
|
name: "my_server",
|
||||||
prompts: [MyPrompt.new],
|
prompts: [MyPrompt],
|
||||||
context: nil,
|
context: { user_id: current_user.id },
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,19 @@ module ModelContextProtocol
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
NOT_SET = Object.new
|
||||||
|
|
||||||
attr_reader :description_value
|
attr_reader :description_value
|
||||||
attr_reader :arguments_value
|
attr_reader :arguments_value
|
||||||
|
|
||||||
|
def template(args, context:)
|
||||||
|
raise NotImplementedError, "Subclasses must implement template"
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{ name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
|
||||||
|
end
|
||||||
|
|
||||||
def inherited(subclass)
|
def inherited(subclass)
|
||||||
super
|
super
|
||||||
subclass.instance_variable_set(:@name_value, nil)
|
subclass.instance_variable_set(:@name_value, nil)
|
||||||
|
|
@ -55,58 +65,57 @@ module ModelContextProtocol
|
||||||
subclass.instance_variable_set(:@arguments_value, nil)
|
subclass.instance_variable_set(:@arguments_value, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def prompt_name(value)
|
def prompt_name(value = NOT_SET)
|
||||||
@name_value = value
|
if value == NOT_SET
|
||||||
|
@name_value
|
||||||
|
else
|
||||||
|
@name_value = value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def name_value
|
def name_value
|
||||||
@name_value || StringUtils.handle_from_class_name(name)
|
@name_value || StringUtils.handle_from_class_name(name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def description(value)
|
def description(value = NOT_SET)
|
||||||
@description_value = value
|
if value == NOT_SET
|
||||||
|
@description_value
|
||||||
|
else
|
||||||
|
@description_value = value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def arguments(value)
|
def arguments(value = NOT_SET)
|
||||||
@arguments_value = value
|
if value == NOT_SET
|
||||||
|
@arguments_value
|
||||||
|
else
|
||||||
|
@arguments_value = value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def define(name: nil, description: nil, arguments: [], &block)
|
def define(name: nil, description: nil, arguments: [], &block)
|
||||||
new(name:, description:, arguments:).tap do |prompt|
|
Class.new(self) do
|
||||||
prompt.define_singleton_method(:template) do |args|
|
prompt_name name
|
||||||
instance_exec(args, &block)
|
description description
|
||||||
|
arguments arguments
|
||||||
|
define_singleton_method(:template) do |args, context:|
|
||||||
|
instance_exec(args, context:, &block)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
attr_reader :name, :description, :arguments
|
def validate_arguments!(args)
|
||||||
|
missing = required_args - args.keys
|
||||||
|
return if missing.empty?
|
||||||
|
|
||||||
def initialize(name: nil, description: nil, arguments: nil)
|
raise ArgumentError, "Missing required arguments: #{missing.join(", ")}"
|
||||||
@name = name || self.class.name_value
|
end
|
||||||
@description = description || self.class.description_value
|
|
||||||
@arguments = arguments || self.class.arguments_value
|
|
||||||
end
|
|
||||||
|
|
||||||
def template(args)
|
private
|
||||||
raise NotImplementedError, "Prompt subclasses must implement template"
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_arguments!(args)
|
def required_args
|
||||||
missing = required_args - args.keys
|
arguments_value.filter_map { |arg| arg.name if arg.required }
|
||||||
return if missing.empty?
|
end
|
||||||
|
|
||||||
raise ArgumentError, "Missing required arguments: #{missing.join(", ")}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_h
|
|
||||||
{ name:, description:, arguments: arguments.map(&:to_h) }.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def required_args
|
|
||||||
arguments.filter_map { |arg| arg.name if arg.required }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ module ModelContextProtocol
|
||||||
|
|
||||||
def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], context: nil)
|
def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], context: nil)
|
||||||
@name = name
|
@name = name
|
||||||
@tools = tools.to_h { |t| [t.name, t] }
|
@tools = tools.to_h { |t| [t.name_value, t] }
|
||||||
@prompts = prompts.to_h { |p| [p.name, p] }
|
@prompts = prompts.to_h { |p| [p.name_value, p] }
|
||||||
@resources = resources
|
@resources = resources
|
||||||
@resource_index = resources.index_by(&:uri)
|
@resource_index = resources.index_by(&:uri)
|
||||||
@context = context
|
@context = context
|
||||||
|
|
@ -39,21 +39,13 @@ module ModelContextProtocol
|
||||||
|
|
||||||
def handle(request)
|
def handle(request)
|
||||||
JsonRpcHandler.handle(request) do |method|
|
JsonRpcHandler.handle(request) do |method|
|
||||||
instrument_call(method) do
|
handle_request(request, method)
|
||||||
case method
|
end
|
||||||
when "tools/list"
|
end
|
||||||
->(params) { { tools: @handlers["tools/list"].call(params) } }
|
|
||||||
when "prompts/list"
|
def handle_json(request)
|
||||||
->(params) { { prompts: @handlers["prompts/list"].call(params) } }
|
JsonRpcHandler.handle_json(request) do |method|
|
||||||
when "resources/list"
|
handle_request(request, method)
|
||||||
->(params) { { resources: @handlers["resources/list"].call(params) } }
|
|
||||||
else
|
|
||||||
@handlers[method]
|
|
||||||
end
|
|
||||||
rescue => e
|
|
||||||
report_exception(e, { request: request })
|
|
||||||
raise RequestHandlerError.new("Internal error handling #{request[:method]} request", request)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -83,6 +75,24 @@ module ModelContextProtocol
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def handle_request(request, method)
|
||||||
|
instrument_call(method) do
|
||||||
|
case method
|
||||||
|
when "tools/list"
|
||||||
|
->(params) { { tools: @handlers["tools/list"].call(params) } }
|
||||||
|
when "prompts/list"
|
||||||
|
->(params) { { prompts: @handlers["prompts/list"].call(params) } }
|
||||||
|
when "resources/list"
|
||||||
|
->(params) { { resources: @handlers["resources/list"].call(params) } }
|
||||||
|
else
|
||||||
|
@handlers[method]
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
report_exception(e, { request: request })
|
||||||
|
raise RequestHandlerError.new("Internal error handling #{request[:method]} request", request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def capabilities
|
def capabilities
|
||||||
@capabilities ||= {
|
@capabilities ||= {
|
||||||
prompts: {},
|
prompts: {},
|
||||||
|
|
@ -152,7 +162,7 @@ module ModelContextProtocol
|
||||||
prompt_args = request[:arguments]
|
prompt_args = request[:arguments]
|
||||||
prompt.validate_arguments!(prompt_args)
|
prompt.validate_arguments!(prompt_args)
|
||||||
|
|
||||||
prompt.template(prompt_args).to_h
|
prompt.template(prompt_args, context:).to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_resources(request)
|
def list_resources(request)
|
||||||
|
|
@ -172,7 +182,6 @@ module ModelContextProtocol
|
||||||
end
|
end
|
||||||
|
|
||||||
add_instrumentation_data(resource_uri:)
|
add_instrumentation_data(resource_uri:)
|
||||||
|
|
||||||
resource.to_h
|
resource.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,24 @@ module ModelContextProtocol
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_h
|
def to_h
|
||||||
{ content:, is_error: }.compact
|
{ content:, isError: is_error }.compact
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
NOT_SET = Object.new
|
||||||
|
|
||||||
attr_reader :description_value
|
attr_reader :description_value
|
||||||
attr_reader :input_schema_value
|
attr_reader :input_schema_value
|
||||||
|
|
||||||
|
def call(*args, context:)
|
||||||
|
raise NotImplementedError, "Subclasses must implement call"
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{ name: name_value, description: description_value, inputSchema: input_schema_value }
|
||||||
|
end
|
||||||
|
|
||||||
def inherited(subclass)
|
def inherited(subclass)
|
||||||
super
|
super
|
||||||
subclass.instance_variable_set(:@name_value, nil)
|
subclass.instance_variable_set(:@name_value, nil)
|
||||||
|
|
@ -26,45 +36,44 @@ module ModelContextProtocol
|
||||||
subclass.instance_variable_set(:@input_schema_value, nil)
|
subclass.instance_variable_set(:@input_schema_value, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tool_name(value)
|
def tool_name(value = NOT_SET)
|
||||||
@name_value = value
|
if value == NOT_SET
|
||||||
|
@name_value
|
||||||
|
else
|
||||||
|
@name_value = value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def name_value
|
def name_value
|
||||||
@name_value || StringUtils.handle_from_class_name(name)
|
@name_value || StringUtils.handle_from_class_name(name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def description(value)
|
def description(value = NOT_SET)
|
||||||
@description_value = value
|
if value == NOT_SET
|
||||||
|
@description_value
|
||||||
|
else
|
||||||
|
@description_value = value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def input_schema(value)
|
def input_schema(value = NOT_SET)
|
||||||
@input_schema_value = value
|
if value == NOT_SET
|
||||||
|
@input_schema_value
|
||||||
|
else
|
||||||
|
@input_schema_value = value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def define(name: nil, description: nil, input_schema: nil, &block)
|
def define(name: nil, description: nil, input_schema: nil, &block)
|
||||||
new(name:, description:, input_schema:).tap do |tool|
|
Class.new(self) do
|
||||||
tool.define_singleton_method(:call) do |*args, context:|
|
tool_name name
|
||||||
|
description description
|
||||||
|
input_schema input_schema
|
||||||
|
define_singleton_method(:call) do |*args, context:|
|
||||||
instance_exec(*args, context:, &block)
|
instance_exec(*args, context:, &block)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :name, :description, :input_schema
|
|
||||||
|
|
||||||
def initialize(name: nil, description: nil, input_schema: nil)
|
|
||||||
@name = name || self.class.name_value
|
|
||||||
@description = description || self.class.description_value
|
|
||||||
@input_schema = input_schema || self.class.input_schema_value
|
|
||||||
end
|
|
||||||
|
|
||||||
def call(*args, context:)
|
|
||||||
raise NotImplementedError, "Subclasses must implement call"
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_h
|
|
||||||
{ name:, description:, inputSchema: input_schema }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module ModelContextProtocol
|
module ModelContextProtocol
|
||||||
VERSION = "0.3.0"
|
VERSION = "0.4.0"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,21 @@ module ModelContextProtocol
|
||||||
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
||||||
]
|
]
|
||||||
|
|
||||||
def template(args)
|
class << self
|
||||||
Prompt::Result.new(
|
def template(args, context:)
|
||||||
description: "Hello, world!",
|
Prompt::Result.new(
|
||||||
messages: [
|
description: "Hello, world!",
|
||||||
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
messages: [
|
||||||
Prompt::Message.new(role: "assistant", content: Content::Text.new("Hello, friend!")),
|
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
||||||
],
|
Prompt::Message.new(role: "assistant", content: Content::Text.new("Hello, friend!")),
|
||||||
)
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#template returns a Result with description and messages" do
|
test "#template returns a Result with description and messages" do
|
||||||
prompt = TestPrompt.new
|
prompt = TestPrompt
|
||||||
|
|
||||||
expected_template_result = {
|
expected_template_result = {
|
||||||
description: "Hello, world!",
|
description: "Hello, world!",
|
||||||
|
|
@ -33,7 +35,7 @@ module ModelContextProtocol
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
result = prompt.template({ "test_argument" => "Hello, friend!" })
|
result = prompt.template({ "test_argument" => "Hello, friend!" }, context: { user_id: 123 })
|
||||||
|
|
||||||
assert_equal expected_template_result, result.to_h
|
assert_equal expected_template_result, result.to_h
|
||||||
end
|
end
|
||||||
|
|
@ -46,20 +48,22 @@ module ModelContextProtocol
|
||||||
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
||||||
]
|
]
|
||||||
|
|
||||||
def template(args)
|
class << self
|
||||||
Prompt::Result.new(
|
def template(args, context:)
|
||||||
description: "Hello, world!",
|
Prompt::Result.new(
|
||||||
messages: [
|
description: "Hello, world!",
|
||||||
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
messages: [
|
||||||
Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])),
|
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
||||||
],
|
Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])),
|
||||||
)
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
prompt = MockPrompt.new
|
prompt = MockPrompt
|
||||||
|
|
||||||
assert_equal "my_mock_prompt", prompt.name
|
assert_equal "my_mock_prompt", prompt.name_value
|
||||||
assert_equal "a mock prompt for testing", prompt.description
|
assert_equal "a mock prompt for testing", prompt.description
|
||||||
assert_equal "test_argument", prompt.arguments.first.name
|
assert_equal "test_argument", prompt.arguments.first.name
|
||||||
assert_equal "Test argument", prompt.arguments.first.description
|
assert_equal "Test argument", prompt.arguments.first.description
|
||||||
|
|
@ -73,7 +77,7 @@ module ModelContextProtocol
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
result = prompt.template({ "test_argument" => "Hello, friend!" })
|
result = prompt.template({ "test_argument" => "Hello, friend!" }, context: { user_id: 123 })
|
||||||
assert_equal expected_template_result, result.to_h
|
assert_equal expected_template_result, result.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -84,20 +88,22 @@ module ModelContextProtocol
|
||||||
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
||||||
]
|
]
|
||||||
|
|
||||||
def template(args)
|
class << self
|
||||||
Prompt::Result.new(
|
def template(args, context:)
|
||||||
description: "Hello, world!",
|
Prompt::Result.new(
|
||||||
messages: [
|
description: "Hello, world!",
|
||||||
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
messages: [
|
||||||
Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])),
|
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
||||||
],
|
Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])),
|
||||||
)
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
prompt = DefaultNamePrompt.new
|
prompt = DefaultNamePrompt
|
||||||
|
|
||||||
assert_equal "default_name_prompt", prompt.name
|
assert_equal "default_name_prompt", prompt.name_value
|
||||||
assert_equal "a mock prompt for testing", prompt.description
|
assert_equal "a mock prompt for testing", prompt.description
|
||||||
assert_equal "test_argument", prompt.arguments.first.name
|
assert_equal "test_argument", prompt.arguments.first.name
|
||||||
end
|
end
|
||||||
|
|
@ -109,17 +115,19 @@ module ModelContextProtocol
|
||||||
arguments: [
|
arguments: [
|
||||||
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true),
|
||||||
],
|
],
|
||||||
) do |args|
|
) do |args, context:|
|
||||||
|
content = Content::Text.new(args["test_argument"] + " user: #{context[:user_id]}")
|
||||||
|
|
||||||
Prompt::Result.new(
|
Prompt::Result.new(
|
||||||
description: "Hello, world!",
|
description: "Hello, world!",
|
||||||
messages: [
|
messages: [
|
||||||
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")),
|
||||||
Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])),
|
Prompt::Message.new(role: "assistant", content:),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "mock_prompt", prompt.name
|
assert_equal "mock_prompt", prompt.name_value
|
||||||
assert_equal "a mock prompt for testing", prompt.description
|
assert_equal "a mock prompt for testing", prompt.description
|
||||||
assert_equal "test_argument", prompt.arguments.first.name
|
assert_equal "test_argument", prompt.arguments.first.name
|
||||||
|
|
||||||
|
|
@ -127,11 +135,11 @@ module ModelContextProtocol
|
||||||
description: "Hello, world!",
|
description: "Hello, world!",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: { text: "Hello, world!" } },
|
{ role: "user", content: { text: "Hello, world!" } },
|
||||||
{ role: "assistant", content: { text: "Hello, friend!" } },
|
{ role: "assistant", content: { text: "Hello, friend! user: 123" } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
result = prompt.template({ "test_argument" => "Hello, friend!" })
|
result = prompt.template({ "test_argument" => "Hello, friend!" }, context: { user_id: 123 })
|
||||||
assert_equal expected, result.to_h
|
assert_equal expected, result.to_h
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
require "json"
|
||||||
|
|
||||||
module ModelContextProtocol
|
module ModelContextProtocol
|
||||||
class ServerTest < ActiveSupport::TestCase
|
class ServerTest < ActiveSupport::TestCase
|
||||||
include InstrumentationTestHelper
|
include InstrumentationTestHelper
|
||||||
setup do
|
setup do
|
||||||
@tool = Tool.new(name: "test_tool", description: "Test tool", input_schema: {})
|
@tool = Tool.define(name: "test_tool", description: "Test tool", input_schema: {})
|
||||||
|
|
||||||
@prompt = Prompt.define(
|
@prompt = Prompt.define(
|
||||||
name: "test_prompt",
|
name: "test_prompt",
|
||||||
|
|
@ -64,6 +65,25 @@ module ModelContextProtocol
|
||||||
assert_instrumentation_data({ method: "ping" })
|
assert_instrumentation_data({ method: "ping" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle_json ping request returns empty response" do
|
||||||
|
request = JSON.generate({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "ping",
|
||||||
|
id: "123",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
|
||||||
|
assert_equal(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "123",
|
||||||
|
"result": {},
|
||||||
|
},
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
assert_instrumentation_data({ method: "ping" })
|
||||||
|
end
|
||||||
|
|
||||||
test "#handle initialize request returns protocol info, server info, and capabilities" do
|
test "#handle initialize request returns protocol info, server info, and capabilities" do
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -121,6 +141,20 @@ module ModelContextProtocol
|
||||||
assert_instrumentation_data({ method: "tools/list" })
|
assert_instrumentation_data({ method: "tools/list" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle_json tools/list returns available tools" do
|
||||||
|
request = JSON.generate({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/list",
|
||||||
|
id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
|
||||||
|
result = response[:result]
|
||||||
|
assert_kind_of Array, result[:tools]
|
||||||
|
assert_equal "test_tool", result[:tools][0][:name]
|
||||||
|
assert_equal "Test tool", result[:tools][0][:description]
|
||||||
|
end
|
||||||
|
|
||||||
test "#tools_list_handler sets the tools/list handler" do
|
test "#tools_list_handler sets the tools/list handler" do
|
||||||
@server.tools_list_handler do
|
@server.tools_list_handler do
|
||||||
[{ name: "hammer", description: "Hammer time!" }]
|
[{ name: "hammer", description: "Hammer time!" }]
|
||||||
|
|
@ -160,6 +194,25 @@ module ModelContextProtocol
|
||||||
assert_instrumentation_data({ method: "tools/call", tool_name: })
|
assert_instrumentation_data({ method: "tools/call", tool_name: })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle_json tools/call executes tool and returns result" do
|
||||||
|
tool_name = "test_tool"
|
||||||
|
tool_args = { arg: "value" }
|
||||||
|
tool_response = Tool::Response.new([{ result: "success" }])
|
||||||
|
|
||||||
|
@tool.expects(:call).with(**tool_args, context: @server.context).returns(tool_response)
|
||||||
|
|
||||||
|
request = JSON.generate({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: tool_name, arguments: tool_args },
|
||||||
|
id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
|
||||||
|
assert_equal tool_response.to_h, response[:result]
|
||||||
|
assert_instrumentation_data({ method: "tools/call", tool_name: })
|
||||||
|
end
|
||||||
|
|
||||||
test "#handle tools/call returns internal error and reports exception if the tool raises an error" do
|
test "#handle tools/call returns internal error and reports exception if the tool raises an error" do
|
||||||
exception = StandardError.new("Tool error")
|
exception = StandardError.new("Tool error")
|
||||||
@tool.expects(:call).raises(exception)
|
@tool.expects(:call).raises(exception)
|
||||||
|
|
@ -189,6 +242,26 @@ module ModelContextProtocol
|
||||||
assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", error: :internal_error })
|
assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", error: :internal_error })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle_json returns internal error and reports exception if the tool raises an error" do
|
||||||
|
exception = StandardError.new("Tool error")
|
||||||
|
@tool.expects(:call).raises(exception)
|
||||||
|
|
||||||
|
request = JSON.generate({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "test_tool",
|
||||||
|
arguments: {},
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
|
||||||
|
assert_equal "Internal error", response[:error][:message]
|
||||||
|
assert_equal "Internal error calling tool test_tool", response[:error][:data]
|
||||||
|
assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", error: :internal_error })
|
||||||
|
end
|
||||||
|
|
||||||
test "#handle tools/call returns error for unknown tool" do
|
test "#handle tools/call returns error for unknown tool" do
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -206,6 +279,21 @@ module ModelContextProtocol
|
||||||
assert_instrumentation_data({ method: "tools/call", error: :tool_not_found })
|
assert_instrumentation_data({ method: "tools/call", error: :tool_not_found })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle_json returns error for unknown tool" do
|
||||||
|
request = JSON.generate({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "unknown_tool",
|
||||||
|
arguments: {},
|
||||||
|
},
|
||||||
|
id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = JSON.parse(@server.handle_json(request), symbolize_names: true)
|
||||||
|
assert_equal "Internal error", response[:error][:message]
|
||||||
|
end
|
||||||
|
|
||||||
test "#tools_call_handler sets the tools/call handler" do
|
test "#tools_call_handler sets the tools/call handler" do
|
||||||
@server.tools_call_handler do |request|
|
@server.tools_call_handler do |request|
|
||||||
tool_name = request[:name]
|
tool_name = request[:name]
|
||||||
|
|
@ -220,7 +308,7 @@ module ModelContextProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
response = @server.handle(request)
|
response = @server.handle(request)
|
||||||
assert_equal({ content: "my_tool called successfully", is_error: false }, response[:result])
|
assert_equal({ content: "my_tool called successfully", isError: false }, response[:result])
|
||||||
assert_instrumentation_data({ method: "tools/call", tool_name: "my_tool" })
|
assert_instrumentation_data({ method: "tools/call", tool_name: "my_tool" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,20 @@ module ModelContextProtocol
|
||||||
description "a test tool for testing"
|
description "a test tool for testing"
|
||||||
input_schema [{ type: "text", name: "message" }]
|
input_schema [{ type: "text", name: "message" }]
|
||||||
|
|
||||||
def call(message)
|
class << self
|
||||||
Tool::Response.new([{ type: "text", content: "OK" }])
|
def call(message)
|
||||||
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#to_h returns a hash with name, description, and inputSchema" do
|
test "#to_h returns a hash with name, description, and inputSchema" do
|
||||||
tool = Tool.new(name: "mock_tool", description: "a mock tool for testing")
|
tool = Tool.define(name: "mock_tool", description: "a mock tool for testing")
|
||||||
assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: nil }
|
assert_equal tool.to_h, { name: "mock_tool", description: "a mock tool for testing", inputSchema: nil }
|
||||||
end
|
end
|
||||||
|
|
||||||
test "#call invokes the tool block and returns the response" do
|
test "#call invokes the tool block and returns the response" do
|
||||||
tool = TestTool.new
|
tool = TestTool
|
||||||
response = tool.call("test")
|
response = tool.call("test")
|
||||||
assert_equal response.content, [{ type: "text", content: "OK" }]
|
assert_equal response.content, [{ type: "text", content: "OK" }]
|
||||||
assert_equal response.is_error, false
|
assert_equal response.is_error, false
|
||||||
|
|
@ -33,8 +35,8 @@ module ModelContextProtocol
|
||||||
input_schema [{ type: "text", name: "message" }]
|
input_schema [{ type: "text", name: "message" }]
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = MockTool.new
|
tool = MockTool
|
||||||
assert_equal tool.name, "my_mock_tool"
|
assert_equal tool.name_value, "my_mock_tool"
|
||||||
assert_equal tool.description, "a mock tool for testing"
|
assert_equal tool.description, "a mock tool for testing"
|
||||||
assert_equal tool.input_schema, [{ type: "text", name: "message" }]
|
assert_equal tool.input_schema, [{ type: "text", name: "message" }]
|
||||||
end
|
end
|
||||||
|
|
@ -45,9 +47,9 @@ module ModelContextProtocol
|
||||||
input_schema [{ type: "text", name: "message" }]
|
input_schema [{ type: "text", name: "message" }]
|
||||||
end
|
end
|
||||||
|
|
||||||
tool = DefaultNameTool.new
|
tool = DefaultNameTool
|
||||||
|
|
||||||
assert_equal tool.name, "default_name_tool"
|
assert_equal tool.name_value, "default_name_tool"
|
||||||
assert_equal tool.description, "a mock tool for testing"
|
assert_equal tool.description, "a mock tool for testing"
|
||||||
assert_equal tool.input_schema, [{ type: "text", name: "message" }]
|
assert_equal tool.input_schema, [{ type: "text", name: "message" }]
|
||||||
end
|
end
|
||||||
|
|
@ -57,7 +59,7 @@ module ModelContextProtocol
|
||||||
Tool::Response.new([{ type: "text", content: "OK" }])
|
Tool::Response.new([{ type: "text", content: "OK" }])
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal tool.name, "mock_tool"
|
assert_equal tool.name_value, "mock_tool"
|
||||||
assert_equal tool.description, "a mock tool for testing"
|
assert_equal tool.description, "a mock tool for testing"
|
||||||
assert_equal tool.input_schema, nil
|
assert_equal tool.input_schema, nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue