diff --git a/.cursor/rules/release-changelogs.mdc b/.cursor/rules/release-changelogs.mdc new file mode 100644 index 0000000..f7309a9 --- /dev/null +++ b/.cursor/rules/release-changelogs.mdc @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 7384cd7..e412c55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - model_context_protocol (0.5.0) + model_context_protocol (0.5.1) json_rpc_handler (~> 0.1) GEM diff --git a/README.md b/README.md index c0ebf90..1a0f612 100644 --- a/README.md +++ b/README.md @@ -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 The exception reporter receives two arguments: diff --git a/lib/model_context_protocol.rb b/lib/model_context_protocol.rb index c5be614..468b198 100644 --- a/lib/model_context_protocol.rb +++ b/lib/model_context_protocol.rb @@ -9,6 +9,7 @@ require "model_context_protocol/resource" require "model_context_protocol/prompt" require "model_context_protocol/version" require "model_context_protocol/configuration" +require "model_context_protocol/methods" module ModelContextProtocol class << self diff --git a/lib/model_context_protocol/configuration.rb b/lib/model_context_protocol/configuration.rb index 01e1d10..36222d1 100644 --- a/lib/model_context_protocol/configuration.rb +++ b/lib/model_context_protocol/configuration.rb @@ -2,11 +2,22 @@ module ModelContextProtocol 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 @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 def exception_reporter @@ -38,10 +49,16 @@ module ModelContextProtocol else @instrumentation_callback end + protocol_version = if other.protocol_version? + other.protocol_version + else + @protocol_version + end Configuration.new( exception_reporter:, instrumentation_callback:, + protocol_version:, ) end diff --git a/lib/model_context_protocol/instrumentation.rb b/lib/model_context_protocol/instrumentation.rb index a29b0c9..73d1209 100644 --- a/lib/model_context_protocol/instrumentation.rb +++ b/lib/model_context_protocol/instrumentation.rb @@ -4,17 +4,19 @@ module ModelContextProtocol module Instrumentation def instrument_call(method, &block) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @instrumentation_data = {} - add_instrumentation_data(method:) + begin + @instrumentation_data = {} + add_instrumentation_data(method:) - result = yield block + result = yield block - end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - add_instrumentation_data(duration: end_time - start_time) + result + ensure + end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + add_instrumentation_data(duration: end_time - start_time) - configuration.instrumentation_callback.call(@instrumentation_data) - - result + configuration.instrumentation_callback.call(@instrumentation_data) + end end def add_instrumentation_data(**kwargs) diff --git a/lib/model_context_protocol/methods.rb b/lib/model_context_protocol/methods.rb new file mode 100644 index 0000000..9c73f1e --- /dev/null +++ b/lib/model_context_protocol/methods.rb @@ -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 diff --git a/lib/model_context_protocol/prompt.rb b/lib/model_context_protocol/prompt.rb index 8375775..8e40280 100644 --- a/lib/model_context_protocol/prompt.rb +++ b/lib/model_context_protocol/prompt.rb @@ -108,7 +108,9 @@ module ModelContextProtocol missing = required_args - args.keys 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 private diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb index 184f1f5..4654639 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/model_context_protocol/server.rb @@ -2,18 +2,22 @@ require "json_rpc_handler" require_relative "instrumentation" +require_relative "methods" module ModelContextProtocol class Server 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) @request = request + @error_type = error_type + @original_error = original_error end end - PROTOCOL_VERSION = "2025-03-26" - include Instrumentation attr_accessor :name, :tools, :prompts, :resources, :context, :configuration @@ -28,14 +32,14 @@ module ModelContextProtocol @context = context @configuration = ModelContextProtocol.configuration.merge(configuration) @handlers = { - "resources/list" => method(:list_resources), - "resources/read" => method(:read_resource), - "tools/list" => method(:list_tools), - "tools/call" => method(:call_tool), - "prompts/list" => method(:list_prompts), - "prompts/get" => method(:get_prompt), - "initialize" => method(:init), - "ping" => ->(_) { {} }, + Methods::RESOURCES_LIST => method(:list_resources), + Methods::RESOURCES_READ => method(:read_resource), + 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 => ->(_) { {} }, } end @@ -52,47 +56,61 @@ module ModelContextProtocol end def resources_list_handler(&block) - @handlers["resources/list"] = block + @handlers[Methods::RESOURCES_LIST] = block end def resources_read_handler(&block) - @handlers["resources/read"] = block + @handlers[Methods::RESOURCES_READ] = block end def tools_list_handler(&block) - @handlers["tools/list"] = block + @handlers[Methods::TOOLS_LIST] = block end def tools_call_handler(&block) - @handlers["tools/call"] = block + @handlers[Methods::TOOLS_CALL] = block end def prompts_list_handler(&block) - @handlers["prompts/list"] = block + @handlers[Methods::PROMPTS_LIST] = block end def prompts_get_handler(&block) - @handlers["prompts/get"] = block + @handlers[Methods::PROMPTS_GET] = block end 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) + handler = @handlers[method] + unless handler + instrument_call("unsupported_method") {} + return 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 def capabilities @@ -111,52 +129,49 @@ module ModelContextProtocol end def init(request) - add_instrumentation_data(method: "initialize") + add_instrumentation_data(method: Methods::INITIALIZE) { - protocolVersion: PROTOCOL_VERSION, + protocolVersion: configuration.protocol_version, capabilities: capabilities, serverInfo: server_info, } end def list_tools(request) - add_instrumentation_data(method: "tools/list") + add_instrumentation_data(method: Methods::TOOLS_LIST) @tools.map { |_, tool| tool.to_h } end def call_tool(request) - add_instrumentation_data(method: "tools/call") + add_instrumentation_data(method: Methods::TOOLS_CALL) tool_name = request[:name] tool = tools[tool_name] unless tool 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 add_instrumentation_data(tool_name:) begin - result = tool.call(**request[:arguments], context:) - result.to_h + tool.call(**request[:arguments], context:).to_h rescue => e - report_exception(e, { tool_name: tool_name, arguments: request[:arguments] }) - add_instrumentation_data(error: :internal_error) - raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request) + raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e) end end def list_prompts(request) - add_instrumentation_data(method: "prompts/list") + add_instrumentation_data(method: Methods::PROMPTS_LIST) @prompts.map { |_, prompt| prompt.to_h } end def get_prompt(request) - add_instrumentation_data(method: "prompts/get") + add_instrumentation_data(method: Methods::PROMPTS_GET) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt 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 add_instrumentation_data(prompt_name:) @@ -168,19 +183,19 @@ module ModelContextProtocol end def list_resources(request) - add_instrumentation_data(method: "resources/list") + add_instrumentation_data(method: Methods::RESOURCES_LIST) @resources.map(&:to_h) end def read_resource(request) - add_instrumentation_data(method: "resources/read") + add_instrumentation_data(method: Methods::RESOURCES_READ) resource_uri = request[:uri] resource = @resource_index[resource_uri] unless resource 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 add_instrumentation_data(resource_uri:) diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb index 719ada2..452becd 100644 --- a/lib/model_context_protocol/version.rb +++ b/lib/model_context_protocol/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ModelContextProtocol - VERSION = "0.5.0" + VERSION = "0.5.1" end diff --git a/test/instrumentation_test_helper.rb b/test/instrumentation_test_helper.rb index de65be2..fbc7cfa 100644 --- a/test/instrumentation_test_helper.rb +++ b/test/instrumentation_test_helper.rb @@ -15,10 +15,10 @@ module InstrumentationTestHelper end def assert_instrumentation_data(expected_data) - data = instrumentation_helper.data.dup + data = instrumentation_helper.data.dup || {} duration = data.delete(:duration) - assert_not_nil(duration, "Duration is not set") - assert_operator(duration, :>=, 0, "Duration is not positive or zero") + assert_not_nil(duration, "Duration should always be set") + assert_operator(duration, :>=, 0, "Duration should be positive or zero") assert_equal(expected_data, data, "Instrumentation data does not match expected data") end end diff --git a/test/model_context_protocol/configuration_test.rb b/test/model_context_protocol/configuration_test.rb index dfdde5c..b021ecb 100644 --- a/test/model_context_protocol/configuration_test.rb +++ b/test/model_context_protocol/configuration_test.rb @@ -34,5 +34,32 @@ module ModelContextProtocol assert_equal test_exception, reported_exception assert_equal test_context, reported_context 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 diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb index bf88d5d..92fb9e9 100644 --- a/test/model_context_protocol/server_test.rb +++ b/test/model_context_protocol/server_test.rb @@ -8,6 +8,11 @@ module ModelContextProtocol include InstrumentationTestHelper setup do @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( name: "test_prompt", @@ -38,7 +43,7 @@ module ModelContextProtocol @server = Server.new( name: @server_name, - tools: [@tool], + tools: [@tool, @tool_that_raises], prompts: [@prompt], resources: [@resource], configuration:, @@ -122,7 +127,7 @@ module ModelContextProtocol } assert_nil @server.handle(request) - assert_instrumentation_data({ method: "some_notification" }) + assert_instrumentation_data({ method: "unsupported_method" }) end test "#handle tools/list returns available tools" do @@ -214,22 +219,26 @@ module ModelContextProtocol end test "#handle tools/call returns internal error and reports exception if the tool raises an error" do - exception = StandardError.new("Tool error") - @tool.expects(:call).raises(exception) - - @server.configuration.exception_reporter.expects(:call).with( - exception, - { - tool_name: "test_tool", - arguments: {}, - }, - ) + @server.configuration.exception_reporter.expects(:call).with do |exception, context| + assert_not_nil exception + assert_equal( + { + request: { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "tool_that_raises", arguments: {} }, + id: 1, + }, + }, + context, + ) + end request = { jsonrpc: "2.0", method: "tools/call", params: { - name: "test_tool", + name: "tool_that_raises", arguments: {}, }, id: 1, @@ -238,19 +247,16 @@ module ModelContextProtocol response = @server.handle(request) 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 }) + assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] + assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error }) 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", + name: "tool_that_raises", arguments: {}, }, id: 1, @@ -258,8 +264,8 @@ module ModelContextProtocol 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 }) + assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] + assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", error: :internal_error }) end test "#handle tools/call returns error for unknown tool" do @@ -392,7 +398,11 @@ module ModelContextProtocol response = @server.handle(request) 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 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 "unknown_method", response[:error][:data] - assert_instrumentation_data({ method: "unknown_method" }) + assert_instrumentation_data({ method: "unsupported_method" }) end 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_exception_reporter, server.configuration.exception_reporter 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