Internal Release 0.5.1

- Allow protocol version to be set at the server level through configuration instead of a class method, providing more flexibility in protocol version management [#43](https://github.com/Shopify/mcp-ruby/pull/43) @topherbullock

- Fixed instrumentation handling to ensure accurate timing information and proper error reporting [#42](https://github.com/Shopify/mcp-ruby/pull/42) @tobiasbales
  - Improved error handling and reporting in the server
  - Fixed timing information collection in instrumentation
  - Added proper error types for different failure scenarios
  - Enhanced test coverage for error cases

- [Allow protocol version to be set at the server level #43](https://github.com/Shopify/mcp-ruby/pull/43) @topherbullock
- [Fix instrumentation handling #42](https://github.com/Shopify/mcp-ruby/pull/42) @tobiasbales
This commit is contained in:
Topher Bullock 2025-04-02 14:47:31 -04:00
commit d2b6a33788
13 changed files with 248 additions and 86 deletions

View file

@ -0,0 +1,32 @@
---
description: writing changelog markdown when cutting a new release of the gem
globs:
alwaysApply: false
---
- output the changelog as markdown when asked.
- git tags are used to mark the commit that cut a new release of the gem
- the gem version is located in [version.rb](mdc:lib/model_context_protocol/version.rb)
- use the git history, especially merge commits from PRs to construct the changelog
- when necessary, look at the diff of files changed to determine whether a PR should be listed in
- ## Added; adds new functionality
- ## Changed; alters functionality; especially backward compatible changes
- ## Fixed; bugfixes that are forward compatible
use the following format for changelogs:
https://cloudsmith.io/~shopify/repos/gems/packages/detail/ruby/mcp-ruby/{gem version}/
# Changelog
## Added
- New functionality added that was not present before
## Changed
- Alterations to functionality that may indicate breaking changes
## Fixed
- Bug fixes
#### Full change list:
- [Name of the PR #123](mdc:https:/github.com/Shopify/mcp-ruby/pull/123) @github-author-username
- [Name of the PR #456](mdc:https:/github.com/Shopify/mcp-ruby/pull/456) @another-github-author

View file

@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
model_context_protocol (0.5.0) model_context_protocol (0.5.1)
json_rpc_handler (~> 0.1) json_rpc_handler (~> 0.1)
GEM GEM

View file

@ -132,6 +132,22 @@ server = ModelContextProtocol::Server.new(
) )
``` ```
### Server Protocol Version
The server's protocol version can be overridden using the `protocol_version` class method:
```ruby
ModelContextProtocol::Server.protocol_version = "2024-11-05"
```
This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`:
```ruby
ModelContextProtocol::Server.protocol_version = nil
```
Be sure to check the [MCP spec](https://spec.modelcontextprotocol.io/specification/2024-11-05/) for the protocol version to understand the supported features for the version being set.
### Exception Reporting ### Exception Reporting
The exception reporter receives two arguments: The exception reporter receives two arguments:

View file

@ -9,6 +9,7 @@ require "model_context_protocol/resource"
require "model_context_protocol/prompt" require "model_context_protocol/prompt"
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"
module ModelContextProtocol module ModelContextProtocol
class << self class << self

View file

@ -2,11 +2,22 @@
module ModelContextProtocol module ModelContextProtocol
class Configuration class Configuration
attr_writer :exception_reporter, :instrumentation_callback DEFAULT_PROTOCOL_VERSION = "2025-03-26"
def initialize(exception_reporter: nil, instrumentation_callback: nil) attr_writer :exception_reporter, :instrumentation_callback, :protocol_version
def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil)
@exception_reporter = exception_reporter @exception_reporter = exception_reporter
@instrumentation_callback = instrumentation_callback @instrumentation_callback = instrumentation_callback
@protocol_version = protocol_version
end
def protocol_version
@protocol_version || DEFAULT_PROTOCOL_VERSION
end
def protocol_version?
!@protocol_version.nil?
end end
def exception_reporter def exception_reporter
@ -38,10 +49,16 @@ module ModelContextProtocol
else else
@instrumentation_callback @instrumentation_callback
end end
protocol_version = if other.protocol_version?
other.protocol_version
else
@protocol_version
end
Configuration.new( Configuration.new(
exception_reporter:, exception_reporter:,
instrumentation_callback:, instrumentation_callback:,
protocol_version:,
) )
end end

View file

@ -4,17 +4,19 @@ module ModelContextProtocol
module Instrumentation module Instrumentation
def instrument_call(method, &block) def instrument_call(method, &block)
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@instrumentation_data = {} begin
add_instrumentation_data(method:) @instrumentation_data = {}
add_instrumentation_data(method:)
result = yield block result = yield block
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) result
add_instrumentation_data(duration: end_time - start_time) ensure
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
add_instrumentation_data(duration: end_time - start_time)
configuration.instrumentation_callback.call(@instrumentation_data) configuration.instrumentation_callback.call(@instrumentation_data)
end
result
end end
def add_instrumentation_data(**kwargs) def add_instrumentation_data(**kwargs)

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
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"
end
end

View file

@ -108,7 +108,9 @@ module ModelContextProtocol
missing = required_args - args.keys missing = required_args - args.keys
return if missing.empty? return if missing.empty?
raise ArgumentError, "Missing required arguments: #{missing.join(", ")}" raise ModelContextProtocol::Server::RequestHandlerError.new(
"Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments
)
end end
private private

View file

@ -2,18 +2,22 @@
require "json_rpc_handler" require "json_rpc_handler"
require_relative "instrumentation" require_relative "instrumentation"
require_relative "methods"
module ModelContextProtocol module ModelContextProtocol
class Server class Server
class RequestHandlerError < StandardError class RequestHandlerError < StandardError
def initialize(message, request) attr_reader :error_type
attr_reader :original_error
def initialize(message, request, error_type: :internal_error, original_error: nil)
super(message) super(message)
@request = request @request = request
@error_type = error_type
@original_error = original_error
end end
end end
PROTOCOL_VERSION = "2025-03-26"
include Instrumentation include Instrumentation
attr_accessor :name, :tools, :prompts, :resources, :context, :configuration attr_accessor :name, :tools, :prompts, :resources, :context, :configuration
@ -28,14 +32,14 @@ module ModelContextProtocol
@context = context @context = context
@configuration = ModelContextProtocol.configuration.merge(configuration) @configuration = ModelContextProtocol.configuration.merge(configuration)
@handlers = { @handlers = {
"resources/list" => method(:list_resources), Methods::RESOURCES_LIST => method(:list_resources),
"resources/read" => method(:read_resource), Methods::RESOURCES_READ => method(:read_resource),
"tools/list" => method(:list_tools), Methods::TOOLS_LIST => method(:list_tools),
"tools/call" => method(:call_tool), Methods::TOOLS_CALL => method(:call_tool),
"prompts/list" => method(:list_prompts), Methods::PROMPTS_LIST => method(:list_prompts),
"prompts/get" => method(:get_prompt), Methods::PROMPTS_GET => method(:get_prompt),
"initialize" => method(:init), Methods::INITIALIZE => method(:init),
"ping" => ->(_) { {} }, Methods::PING => ->(_) { {} },
} }
end end
@ -52,47 +56,61 @@ module ModelContextProtocol
end end
def resources_list_handler(&block) def resources_list_handler(&block)
@handlers["resources/list"] = block @handlers[Methods::RESOURCES_LIST] = block
end end
def resources_read_handler(&block) def resources_read_handler(&block)
@handlers["resources/read"] = block @handlers[Methods::RESOURCES_READ] = block
end end
def tools_list_handler(&block) def tools_list_handler(&block)
@handlers["tools/list"] = block @handlers[Methods::TOOLS_LIST] = block
end end
def tools_call_handler(&block) def tools_call_handler(&block)
@handlers["tools/call"] = block @handlers[Methods::TOOLS_CALL] = block
end end
def prompts_list_handler(&block) def prompts_list_handler(&block)
@handlers["prompts/list"] = block @handlers[Methods::PROMPTS_LIST] = block
end end
def prompts_get_handler(&block) def prompts_get_handler(&block)
@handlers["prompts/get"] = block @handlers[Methods::PROMPTS_GET] = block
end end
private private
def handle_request(request, method) def handle_request(request, method)
instrument_call(method) do handler = @handlers[method]
case method unless handler
when "tools/list" instrument_call("unsupported_method") {}
->(params) { { tools: @handlers["tools/list"].call(params) } } return
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
->(params) {
instrument_call(method) do
case method
when Methods::TOOLS_LIST
{ tools: @handlers[Methods::TOOLS_LIST].call(params) }
when Methods::PROMPTS_LIST
{ prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
when Methods::RESOURCES_LIST
{ resources: @handlers[Methods::RESOURCES_LIST].call(params) }
else
@handlers[method].call(params)
end
rescue => e
report_exception(e, { request: request })
if e.is_a?(RequestHandlerError)
add_instrumentation_data(error: e.error_type)
raise e
end
add_instrumentation_data(error: :internal_error)
raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
end
}
end end
def capabilities def capabilities
@ -111,52 +129,49 @@ module ModelContextProtocol
end end
def init(request) def init(request)
add_instrumentation_data(method: "initialize") add_instrumentation_data(method: Methods::INITIALIZE)
{ {
protocolVersion: PROTOCOL_VERSION, protocolVersion: configuration.protocol_version,
capabilities: capabilities, capabilities: capabilities,
serverInfo: server_info, serverInfo: server_info,
} }
end end
def list_tools(request) def list_tools(request)
add_instrumentation_data(method: "tools/list") add_instrumentation_data(method: Methods::TOOLS_LIST)
@tools.map { |_, tool| tool.to_h } @tools.map { |_, tool| tool.to_h }
end end
def call_tool(request) def call_tool(request)
add_instrumentation_data(method: "tools/call") add_instrumentation_data(method: Methods::TOOLS_CALL)
tool_name = request[:name] tool_name = request[:name]
tool = tools[tool_name] tool = tools[tool_name]
unless tool unless tool
add_instrumentation_data(error: :tool_not_found) add_instrumentation_data(error: :tool_not_found)
raise "Tool not found #{tool_name}" raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found)
end end
add_instrumentation_data(tool_name:) add_instrumentation_data(tool_name:)
begin begin
result = tool.call(**request[:arguments], context:) tool.call(**request[:arguments], context:).to_h
result.to_h
rescue => e rescue => e
report_exception(e, { tool_name: tool_name, arguments: request[:arguments] }) raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
add_instrumentation_data(error: :internal_error)
raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request)
end end
end end
def list_prompts(request) def list_prompts(request)
add_instrumentation_data(method: "prompts/list") add_instrumentation_data(method: Methods::PROMPTS_LIST)
@prompts.map { |_, prompt| prompt.to_h } @prompts.map { |_, prompt| prompt.to_h }
end end
def get_prompt(request) def get_prompt(request)
add_instrumentation_data(method: "prompts/get") add_instrumentation_data(method: Methods::PROMPTS_GET)
prompt_name = request[:name] prompt_name = request[:name]
prompt = @prompts[prompt_name] prompt = @prompts[prompt_name]
unless prompt unless prompt
add_instrumentation_data(error: :prompt_not_found) add_instrumentation_data(error: :prompt_not_found)
raise "Prompt not found #{prompt_name}" raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found)
end end
add_instrumentation_data(prompt_name:) add_instrumentation_data(prompt_name:)
@ -168,19 +183,19 @@ module ModelContextProtocol
end end
def list_resources(request) def list_resources(request)
add_instrumentation_data(method: "resources/list") add_instrumentation_data(method: Methods::RESOURCES_LIST)
@resources.map(&:to_h) @resources.map(&:to_h)
end end
def read_resource(request) def read_resource(request)
add_instrumentation_data(method: "resources/read") add_instrumentation_data(method: Methods::RESOURCES_READ)
resource_uri = request[:uri] resource_uri = request[:uri]
resource = @resource_index[resource_uri] resource = @resource_index[resource_uri]
unless resource unless resource
add_instrumentation_data(error: :resource_not_found) add_instrumentation_data(error: :resource_not_found)
raise "Resource not found #{resource_uri}" raise RequestHandlerError.new("Resource not found #{resource_uri}", request, error_type: :resource_not_found)
end end
add_instrumentation_data(resource_uri:) add_instrumentation_data(resource_uri:)

View file

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

View file

@ -15,10 +15,10 @@ module InstrumentationTestHelper
end end
def assert_instrumentation_data(expected_data) def assert_instrumentation_data(expected_data)
data = instrumentation_helper.data.dup data = instrumentation_helper.data.dup || {}
duration = data.delete(:duration) duration = data.delete(:duration)
assert_not_nil(duration, "Duration is not set") assert_not_nil(duration, "Duration should always be set")
assert_operator(duration, :>=, 0, "Duration is not positive or zero") assert_operator(duration, :>=, 0, "Duration should be positive or zero")
assert_equal(expected_data, data, "Instrumentation data does not match expected data") assert_equal(expected_data, data, "Instrumentation data does not match expected data")
end end
end end

View file

@ -34,5 +34,32 @@ module ModelContextProtocol
assert_equal test_exception, reported_exception assert_equal test_exception, reported_exception
assert_equal test_context, reported_context assert_equal test_context, reported_context
end end
test "initializes with default protocol version" do
config = Configuration.new
assert_equal Configuration::DEFAULT_PROTOCOL_VERSION, config.protocol_version
end
test "allows setting a custom protocol version" do
config = Configuration.new
custom_version = "2025-03-27"
config.protocol_version = custom_version
assert_equal custom_version, config.protocol_version
end
test "merges protocol version from other configuration" do
config1 = Configuration.new(protocol_version: "2025-03-27")
config2 = Configuration.new(protocol_version: "2025-03-28")
config3 = Configuration.new
merged = config1.merge(config2)
assert_equal "2025-03-28", merged.protocol_version
merged = config1.merge(config3)
assert_equal "2025-03-27", merged.protocol_version
merged = config3.merge(config1)
assert_equal "2025-03-27", merged.protocol_version
end
end end
end end

View file

@ -8,6 +8,11 @@ module ModelContextProtocol
include InstrumentationTestHelper include InstrumentationTestHelper
setup do setup do
@tool = Tool.define(name: "test_tool", description: "Test tool", input_schema: {}) @tool = Tool.define(name: "test_tool", description: "Test tool", input_schema: {})
@tool_that_raises = Tool.define(
name: "tool_that_raises",
description: "Tool that raises",
input_schema: {},
) { raise StandardError, "Tool error" }
@prompt = Prompt.define( @prompt = Prompt.define(
name: "test_prompt", name: "test_prompt",
@ -38,7 +43,7 @@ module ModelContextProtocol
@server = Server.new( @server = Server.new(
name: @server_name, name: @server_name,
tools: [@tool], tools: [@tool, @tool_that_raises],
prompts: [@prompt], prompts: [@prompt],
resources: [@resource], resources: [@resource],
configuration:, configuration:,
@ -122,7 +127,7 @@ module ModelContextProtocol
} }
assert_nil @server.handle(request) assert_nil @server.handle(request)
assert_instrumentation_data({ method: "some_notification" }) assert_instrumentation_data({ method: "unsupported_method" })
end end
test "#handle tools/list returns available tools" do test "#handle tools/list returns available tools" do
@ -214,22 +219,26 @@ module ModelContextProtocol
end 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") @server.configuration.exception_reporter.expects(:call).with do |exception, context|
@tool.expects(:call).raises(exception) assert_not_nil exception
assert_equal(
@server.configuration.exception_reporter.expects(:call).with( {
exception, request: {
{ jsonrpc: "2.0",
tool_name: "test_tool", method: "tools/call",
arguments: {}, params: { name: "tool_that_raises", arguments: {} },
}, id: 1,
) },
},
context,
)
end
request = { request = {
jsonrpc: "2.0", jsonrpc: "2.0",
method: "tools/call", method: "tools/call",
params: { params: {
name: "test_tool", name: "tool_that_raises",
arguments: {}, arguments: {},
}, },
id: 1, id: 1,
@ -238,19 +247,16 @@ module ModelContextProtocol
response = @server.handle(request) response = @server.handle(request)
assert_equal "Internal error", response[:error][:message] assert_equal "Internal error", response[:error][:message]
assert_equal "Internal error calling tool test_tool", response[:error][:data] assert_equal "Internal error calling tool tool_that_raises", response[:error][:data]
assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", error: :internal_error }) assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error })
end end
test "#handle_json returns internal error and reports exception if the tool raises an error" do 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({ request = JSON.generate({
jsonrpc: "2.0", jsonrpc: "2.0",
method: "tools/call", method: "tools/call",
params: { params: {
name: "test_tool", name: "tool_that_raises",
arguments: {}, arguments: {},
}, },
id: 1, id: 1,
@ -258,8 +264,8 @@ module ModelContextProtocol
response = JSON.parse(@server.handle_json(request), symbolize_names: true) response = JSON.parse(@server.handle_json(request), symbolize_names: true)
assert_equal "Internal error", response[:error][:message] assert_equal "Internal error", response[:error][:message]
assert_equal "Internal error calling tool test_tool", response[:error][:data] assert_equal "Internal error calling tool tool_that_raises", response[:error][:data]
assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", error: :internal_error }) assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error })
end end
test "#handle tools/call returns error for unknown tool" do test "#handle tools/call returns error for unknown tool" do
@ -392,7 +398,11 @@ module ModelContextProtocol
response = @server.handle(request) response = @server.handle(request)
assert_equal "Missing required arguments: test_argument", response[:error][:data] assert_equal "Missing required arguments: test_argument", response[:error][:data]
assert_instrumentation_data({ method: "prompts/get", prompt_name: "test_prompt" }) assert_instrumentation_data({
method: "prompts/get",
prompt_name: "test_prompt",
error: :missing_required_arguments,
})
end end
test "#prompts_get_handler sets the prompts/get handler" do test "#prompts_get_handler sets the prompts/get handler" do
@ -529,7 +539,7 @@ module ModelContextProtocol
assert_equal "Method not found", response[:error][:message] assert_equal "Method not found", response[:error][:message]
assert_equal "unknown_method", response[:error][:data] assert_equal "unknown_method", response[:error][:data]
assert_instrumentation_data({ method: "unknown_method" }) assert_instrumentation_data({ method: "unsupported_method" })
end end
test "the global configuration is used if no configuration is passed to the server" do test "the global configuration is used if no configuration is passed to the server" do
@ -554,5 +564,31 @@ module ModelContextProtocol
assert_equal local_callback, server.configuration.instrumentation_callback assert_equal local_callback, server.configuration.instrumentation_callback
assert_equal local_exception_reporter, server.configuration.exception_reporter assert_equal local_exception_reporter, server.configuration.exception_reporter
end end
test "server uses default protocol version when not configured" do
request = {
jsonrpc: "2.0",
method: "initialize",
id: 1,
}
response = @server.handle(request)
assert_equal Configuration::DEFAULT_PROTOCOL_VERSION, response[:result][:protocolVersion]
end
test "server protocol version can be overridden via configuration" do
custom_version = "2025-03-27"
configuration = Configuration.new(protocol_version: custom_version)
server = Server.new(name: "test_server", configuration: configuration)
request = {
jsonrpc: "2.0",
method: "initialize",
id: 1,
}
response = server.handle(request)
assert_equal custom_version, response[:result][:protocolVersion]
end
end end
end end