From 2eca3681dfd73f31476fbd2238c2868221a11179 Mon Sep 17 00:00:00 2001 From: Topher Bullock Date: Tue, 25 Mar 2025 10:28:31 -0400 Subject: [PATCH] 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 --- Gemfile.lock | 2 +- README.md | 78 ++++++++++++------ lib/model_context_protocol/prompt.rb | 77 ++++++++++-------- lib/model_context_protocol/server.rb | 47 ++++++----- lib/model_context_protocol/tool.rb | 59 ++++++++------ lib/model_context_protocol/version.rb | 2 +- test/model_context_protocol/prompt_test.rb | 80 ++++++++++--------- test/model_context_protocol/server_test.rb | 92 +++++++++++++++++++++- test/model_context_protocol/tool_test.rb | 20 ++--- 9 files changed, 306 insertions(+), 151 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index de021d3..25f497b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - model_context_protocol (0.3.0) + model_context_protocol (0.4.0) json_rpc_handler (~> 0.1) GEM diff --git a/README.md b/README.md index 6920053..cd62b12 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,31 @@ module ModelContextProtocol def index server = ModelContextProtocol::Server.new( name: "my_server", - tools: [someTool, anotherTool], - prompts: [myPrompt], - context: nil, + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], + 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 @@ -77,16 +97,19 @@ end ### Exception Reporting The exception reporter receives two arguments: + - `exception`: The Ruby exception object that was raised - `context`: A hash containing contextual information about where the error occurred The context hash includes: + - For tool calls: `{ tool_name: "name", arguments: { ... } }` - For general request handling: `{ request: { ... } }` When an exception occurs: + 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 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..." input_schema [{ type: "text", name: "message" }] - def call(message, context:) + def self.call(message, context:) Tool::Response.new([{ type: "text", content: "OK" }]) end end -tool = MyTool.new +tool = MyTool ``` 2. By using the `ModelContextProtocol::Tool.define` method with a block: @@ -143,22 +166,26 @@ class MyPrompt < ModelContextProtocol::Prompt ) ] - def template(args) - Prompt::Result.new( - description: "Response description", - messages: [ - Prompt::Message.new( - role: "user", - content: Content::Text.new("User message") - ), - Prompt::Message.new( - role: "assistant", - content: Content::Text.new(args["message"]) - ) - ] - ) + class << self + def template(args, context:) + Prompt::Result.new( + description: "Response description", + messages: [ + Prompt::Message.new( + role: "user", + content: Content::Text.new("User message") + ), + Prompt::Message.new( + role: "assistant", + content: Content::Text.new(args["message"]) + ) + ] + ) + end end end + +prompt = MyPrompt ``` 2. Using the `ModelContextProtocol::Prompt.define` method: @@ -174,7 +201,7 @@ prompt = ModelContextProtocol::Prompt.define( required: true ) ] -) do |args| +) do |args, context:| Prompt::Result.new( description: "Response description", messages: [ @@ -191,6 +218,9 @@ prompt = ModelContextProtocol::Prompt.define( 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 - `Prompt::Argument` - Defines input parameters for the prompt template @@ -205,8 +235,8 @@ Register prompts with the MCP server: ```ruby server = ModelContextProtocol::Server.new( name: "my_server", - prompts: [MyPrompt.new], - context: nil, + prompts: [MyPrompt], + context: { user_id: current_user.id }, ) ``` diff --git a/lib/model_context_protocol/prompt.rb b/lib/model_context_protocol/prompt.rb index 7bb329a..877bc6a 100644 --- a/lib/model_context_protocol/prompt.rb +++ b/lib/model_context_protocol/prompt.rb @@ -45,9 +45,19 @@ module ModelContextProtocol end class << self + NOT_SET = Object.new + attr_reader :description_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) super subclass.instance_variable_set(:@name_value, nil) @@ -55,58 +65,57 @@ module ModelContextProtocol subclass.instance_variable_set(:@arguments_value, nil) end - def prompt_name(value) - @name_value = value + def prompt_name(value = NOT_SET) + if value == NOT_SET + @name_value + else + @name_value = value + end end def name_value @name_value || StringUtils.handle_from_class_name(name) end - def description(value) - @description_value = value + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end end - def arguments(value) - @arguments_value = value + def arguments(value = NOT_SET) + if value == NOT_SET + @arguments_value + else + @arguments_value = value + end end def define(name: nil, description: nil, arguments: [], &block) - new(name:, description:, arguments:).tap do |prompt| - prompt.define_singleton_method(:template) do |args| - instance_exec(args, &block) + Class.new(self) do + prompt_name name + description description + arguments arguments + define_singleton_method(:template) do |args, context:| + instance_exec(args, context:, &block) 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) - @name = name || self.class.name_value - @description = description || self.class.description_value - @arguments = arguments || self.class.arguments_value - end + raise ArgumentError, "Missing required arguments: #{missing.join(", ")}" + end - def template(args) - raise NotImplementedError, "Prompt subclasses must implement template" - end + private - def validate_arguments!(args) - missing = required_args - args.keys - return if missing.empty? - - 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 } + def required_args + arguments_value.filter_map { |arg| arg.name if arg.required } + end end end end diff --git a/lib/model_context_protocol/server.rb b/lib/model_context_protocol/server.rb index 08f3ed3..c1d3a8a 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/model_context_protocol/server.rb @@ -20,8 +20,8 @@ module ModelContextProtocol def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], context: nil) @name = name - @tools = tools.to_h { |t| [t.name, t] } - @prompts = prompts.to_h { |p| [p.name, p] } + @tools = tools.to_h { |t| [t.name_value, t] } + @prompts = prompts.to_h { |p| [p.name_value, p] } @resources = resources @resource_index = resources.index_by(&:uri) @context = context @@ -39,21 +39,13 @@ module ModelContextProtocol def handle(request) JsonRpcHandler.handle(request) do |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 + handle_request(request, method) + end + end + + def handle_json(request) + JsonRpcHandler.handle_json(request) do |method| + handle_request(request, method) end end @@ -83,6 +75,24 @@ module ModelContextProtocol 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 @capabilities ||= { prompts: {}, @@ -152,7 +162,7 @@ module ModelContextProtocol prompt_args = request[:arguments] prompt.validate_arguments!(prompt_args) - prompt.template(prompt_args).to_h + prompt.template(prompt_args, context:).to_h end def list_resources(request) @@ -172,7 +182,6 @@ module ModelContextProtocol end add_instrumentation_data(resource_uri:) - resource.to_h end diff --git a/lib/model_context_protocol/tool.rb b/lib/model_context_protocol/tool.rb index c6acfe9..7d7320e 100644 --- a/lib/model_context_protocol/tool.rb +++ b/lib/model_context_protocol/tool.rb @@ -11,14 +11,24 @@ module ModelContextProtocol end def to_h - { content:, is_error: }.compact + { content:, isError: is_error }.compact end end class << self + NOT_SET = Object.new + attr_reader :description_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) super subclass.instance_variable_set(:@name_value, nil) @@ -26,45 +36,44 @@ module ModelContextProtocol subclass.instance_variable_set(:@input_schema_value, nil) end - def tool_name(value) - @name_value = value + def tool_name(value = NOT_SET) + if value == NOT_SET + @name_value + else + @name_value = value + end end def name_value @name_value || StringUtils.handle_from_class_name(name) end - def description(value) - @description_value = value + def description(value = NOT_SET) + if value == NOT_SET + @description_value + else + @description_value = value + end end - def input_schema(value) - @input_schema_value = value + def input_schema(value = NOT_SET) + if value == NOT_SET + @input_schema_value + else + @input_schema_value = value + end end def define(name: nil, description: nil, input_schema: nil, &block) - new(name:, description:, input_schema:).tap do |tool| - tool.define_singleton_method(:call) do |*args, context:| + Class.new(self) do + tool_name name + description description + input_schema input_schema + define_singleton_method(:call) do |*args, context:| instance_exec(*args, context:, &block) 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 diff --git a/lib/model_context_protocol/version.rb b/lib/model_context_protocol/version.rb index b40e57b..3378961 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.3.0" + VERSION = "0.4.0" end diff --git a/test/model_context_protocol/prompt_test.rb b/test/model_context_protocol/prompt_test.rb index c5129fa..5098d23 100644 --- a/test/model_context_protocol/prompt_test.rb +++ b/test/model_context_protocol/prompt_test.rb @@ -11,19 +11,21 @@ module ModelContextProtocol Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] - def template(args) - Prompt::Result.new( - description: "Hello, world!", - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content: Content::Text.new("Hello, friend!")), - ], - ) + class << self + def template(args, context:) + Prompt::Result.new( + description: "Hello, world!", + messages: [ + 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 test "#template returns a Result with description and messages" do - prompt = TestPrompt.new + prompt = TestPrompt expected_template_result = { 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 end @@ -46,20 +48,22 @@ module ModelContextProtocol Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] - def template(args) - Prompt::Result.new( - description: "Hello, world!", - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])), - ], - ) + class << self + def template(args, context:) + Prompt::Result.new( + description: "Hello, world!", + messages: [ + 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 - 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 "test_argument", prompt.arguments.first.name 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 end @@ -84,20 +88,22 @@ module ModelContextProtocol Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ] - def template(args) - Prompt::Result.new( - description: "Hello, world!", - messages: [ - Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), - Prompt::Message.new(role: "assistant", content: Content::Text.new(args["test_argument"])), - ], - ) + class << self + def template(args, context:) + Prompt::Result.new( + description: "Hello, world!", + messages: [ + 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 - 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 "test_argument", prompt.arguments.first.name end @@ -109,17 +115,19 @@ module ModelContextProtocol arguments: [ 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( description: "Hello, world!", messages: [ 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 - assert_equal "mock_prompt", prompt.name + assert_equal "mock_prompt", prompt.name_value assert_equal "a mock prompt for testing", prompt.description assert_equal "test_argument", prompt.arguments.first.name @@ -127,11 +135,11 @@ module ModelContextProtocol description: "Hello, world!", messages: [ { 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 end end diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb index 1b2ae44..ca49cb3 100644 --- a/test/model_context_protocol/server_test.rb +++ b/test/model_context_protocol/server_test.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true require "test_helper" +require "json" module ModelContextProtocol class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper 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( name: "test_prompt", @@ -64,6 +65,25 @@ module ModelContextProtocol assert_instrumentation_data({ method: "ping" }) 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 request = { jsonrpc: "2.0", @@ -121,6 +141,20 @@ module ModelContextProtocol assert_instrumentation_data({ method: "tools/list" }) 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 @server.tools_list_handler do [{ name: "hammer", description: "Hammer time!" }] @@ -160,6 +194,25 @@ module ModelContextProtocol assert_instrumentation_data({ method: "tools/call", tool_name: }) 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 exception = StandardError.new("Tool error") @tool.expects(:call).raises(exception) @@ -189,6 +242,26 @@ module ModelContextProtocol assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", 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", + 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 request = { jsonrpc: "2.0", @@ -206,6 +279,21 @@ module ModelContextProtocol assert_instrumentation_data({ method: "tools/call", error: :tool_not_found }) 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 @server.tools_call_handler do |request| tool_name = request[:name] @@ -220,7 +308,7 @@ module ModelContextProtocol } 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" }) end diff --git a/test/model_context_protocol/tool_test.rb b/test/model_context_protocol/tool_test.rb index 2800ab2..f9d6e44 100644 --- a/test/model_context_protocol/tool_test.rb +++ b/test/model_context_protocol/tool_test.rb @@ -9,18 +9,20 @@ module ModelContextProtocol description "a test tool for testing" input_schema [{ type: "text", name: "message" }] - def call(message) - Tool::Response.new([{ type: "text", content: "OK" }]) + class << self + def call(message) + Tool::Response.new([{ type: "text", content: "OK" }]) + end end end 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 } end test "#call invokes the tool block and returns the response" do - tool = TestTool.new + tool = TestTool response = tool.call("test") assert_equal response.content, [{ type: "text", content: "OK" }] assert_equal response.is_error, false @@ -33,8 +35,8 @@ module ModelContextProtocol input_schema [{ type: "text", name: "message" }] end - tool = MockTool.new - assert_equal tool.name, "my_mock_tool" + tool = MockTool + assert_equal tool.name_value, "my_mock_tool" assert_equal tool.description, "a mock tool for testing" assert_equal tool.input_schema, [{ type: "text", name: "message" }] end @@ -45,9 +47,9 @@ module ModelContextProtocol input_schema [{ type: "text", name: "message" }] 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.input_schema, [{ type: "text", name: "message" }] end @@ -57,7 +59,7 @@ module ModelContextProtocol Tool::Response.new([{ type: "text", content: "OK" }]) 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.input_schema, nil end