From e08c65992b530e4f4c8c3e229f22f8a4ae543904 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 28 May 2025 10:49:09 -0400 Subject: [PATCH 01/24] refactor cdoe in prep for client support --- .DS_Store | Bin 0 -> 6148 bytes mcp.gemspec | 6 ++---- model_context_protocol-1.0.0.gem | Bin 0 -> 10752 bytes 3 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 .DS_Store create mode 100644 model_context_protocol-1.0.0.gem diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 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 X!2e5X@$W Date: Wed, 28 May 2025 10:49:47 -0400 Subject: [PATCH 02/24] remove gem --- model_context_protocol-1.0.0.gem | Bin 10752 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 model_context_protocol-1.0.0.gem diff --git a/model_context_protocol-1.0.0.gem b/model_context_protocol-1.0.0.gem deleted file mode 100644 index 472757d2cad1c0fb02d5a131f941a718713068db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10752 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 X!2e5X@$W Date: Wed, 28 May 2025 11:02:27 -0400 Subject: [PATCH 03/24] remove DS_Store --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 3 +++ 2 files changed, 3 insertions(+) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 Date: Wed, 28 May 2025 11:05:25 -0400 Subject: [PATCH 04/24] add original spec files code back --- mcp.gemspec | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp.gemspec b/mcp.gemspec index d9c2f1a..943ae28 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -19,7 +19,9 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage - spec.files = Dir.glob("lib/**/*.rb").reject { |f| f.match(%r{^(test|spec|features)/}) } + spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do + %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } From 5eacee62cebe4f6ca43bddde3e1ab4705132500a Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 28 May 2025 12:19:56 -0400 Subject: [PATCH 05/24] move and fix some tests --- .../server/transports/stdio_transport_test.rb | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 test/model_context_protocol/server/transports/stdio_transport_test.rb diff --git a/test/model_context_protocol/server/transports/stdio_transport_test.rb b/test/model_context_protocol/server/transports/stdio_transport_test.rb new file mode 100644 index 0000000..2b05d5f --- /dev/null +++ b/test/model_context_protocol/server/transports/stdio_transport_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" +require "model_context_protocol/server/transports/stdio" +require "json" + +module ModelContextProtocol + class Server + module Transports + class StdioTransportTest < ActiveSupport::TestCase + include InstrumentationTestHelper + + setup do + configuration = ModelContextProtocol::Configuration.new + configuration.instrumentation_callback = instrumentation_helper.callback + @server = Server.new(name: "test_server", configuration: configuration) + @transport = StdioTransport.new(@server) + end + + test "initializes with server and closed state" do + server = @transport.instance_variable_get(:@server) + assert_equal @server.object_id, server.object_id + refute @transport.instance_variable_get(:@open) + end + + test "processes JSON-RPC requests from stdin and sends responses to stdout" do + request = { + jsonrpc: "2.0", + method: "ping", + id: "123", + } + input = StringIO.new(JSON.generate(request) + "\n") + output = StringIO.new + + original_stdin = $stdin + original_stdout = $stdout + + begin + $stdin = input + $stdout = output + + thread = Thread.new { @transport.open } + sleep(0.1) + @transport.close + thread.join + + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("2.0", response[:jsonrpc]) + assert_equal("123", response[:id]) + assert_equal({}, response[:result]) + refute(@transport.instance_variable_get(:@open)) + ensure + $stdin = original_stdin + $stdout = original_stdout + end + end + + test "sends string responses to stdout" do + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + @transport.send_response("test response") + assert_equal("test response\n", output.string) + ensure + $stdout = original_stdout + end + end + + test "sends JSON responses to stdout" do + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + response = { key: "value" } + @transport.send_response(response) + assert_equal(JSON.generate(response) + "\n", output.string) + ensure + $stdout = original_stdout + end + end + + test "handles valid JSON-RPC requests" do + request = { + jsonrpc: "2.0", + method: "ping", + id: "123", + } + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + @transport.send(:handle_request, JSON.generate(request)) + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("2.0", response[:jsonrpc]) + assert_nil(response[:id]) + assert_nil(response[:result]) + ensure + $stdout = original_stdout + end + end + + test "handles invalid JSON requests" do + invalid_json = "invalid json" + output = StringIO.new + original_stdout = $stdout + + begin + $stdout = output + @transport.send(:handle_request, invalid_json) + response = JSON.parse(output.string, symbolize_names: true) + assert_equal("2.0", response[:jsonrpc]) + assert_nil(response[:id]) + assert_equal(-32600, response[:error][:code]) + assert_equal("Invalid Request", response[:error][:message]) + assert_equal("Request must be an array or a hash", response[:error][:data]) + ensure + $stdout = original_stdout + end + end + end + end + end +end From 9ad0d998bc625932c9c48960295ed526306c92d5 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 28 May 2025 16:35:00 -0400 Subject: [PATCH 06/24] Add basic HTTP client support --- Gemfile | 4 + lib/mcp/client.rb | 11 ++ lib/mcp/client/http.rb | 61 ++++++ lib/mcp/client/tool.rb | 26 +++ lib/mcp/client/tools.rb | 30 +++ mcp.gemspec | 1 + .../client/http_test.rb | 186 ++++++++++++++++++ .../client/tool_test.rb | 46 +++++ .../client/tools_test.rb | 96 +++++++++ test/model_context_protocol/client_test.rb | 8 + 10 files changed, 469 insertions(+) create mode 100644 lib/mcp/client.rb create mode 100644 lib/mcp/client/http.rb create mode 100644 lib/mcp/client/tool.rb create mode 100644 lib/mcp/client/tools.rb create mode 100644 test/model_context_protocol/client/http_test.rb create mode 100644 test/model_context_protocol/client/tool_test.rb create mode 100644 test/model_context_protocol/client/tools_test.rb create mode 100644 test/model_context_protocol/client_test.rb diff --git a/Gemfile b/Gemfile index c8a7a26..04ea5fb 100644 --- a/Gemfile +++ b/Gemfile @@ -22,3 +22,7 @@ gem "activesupport" gem "debug" gem "rake", "~> 13.0" gem "sorbet-static-and-runtime" + +group :test do + gem "webmock" +end diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb new file mode 100644 index 0000000..b321f7a --- /dev/null +++ b/lib/mcp/client.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# require "json_rpc_handler" +# require_relative "shared/instrumentation" +# require_relative "shared/methods" + +module ModelContextProtocol + module Client + # Can be made an abstract class if we need shared behavior + end +end diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb new file mode 100644 index 0000000..714fc5f --- /dev/null +++ b/lib/mcp/client/http.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# require "json_rpc_handler" +# require_relative "shared/instrumentation" +# require_relative "shared/methods" + +module ModelContextProtocol + module Client + class Http + DEFAULT_VERSION = "0.1.0" + + attr_reader :url, :version + + def initialize(url:, version: DEFAULT_VERSION) + @url = url + @version = version + end + + def tools + response = client.post( + "", + method: "tools/list", + jsonrpc: "2.0", + id: request_id, + mcp: { method: "tools/list", jsonrpc: "2.0", id: request_id }, + ).body + + ::ModelContextProtocol::Client::Tools.new(response) + end + + def call_tool(tool:, input:) + response = client.post( + "", + { + jsonrpc: "2.0", + id: request_id, + method: "tools/call", + params: { name: tool.name, arguments: input }, + mcp: { jsonrpc: "2.0", id: request_id, method: "tools/call", params: { name: tool.name, arguments: input } }, + }, + ).body + + response.dig("result", "content", 0, "text") + end + + private + + def client + @client ||= Faraday.new(url) do |faraday| + faraday.request(:json) + faraday.response(:json) + # TODO: error middleware? + end + end + + def request_id + SecureRandom.uuid_v7 + end + end + end +end diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb new file mode 100644 index 0000000..156a5b7 --- /dev/null +++ b/lib/mcp/client/tool.rb @@ -0,0 +1,26 @@ +# typed: false +# frozen_string_literal: true + +module ModelContextProtocol + module Client + class Tool + attr_reader :payload + + def initialize(payload) + @payload = payload + end + + def name + payload["name"] + end + + def description + payload["description"] + end + + def input_schema + payload["inputSchema"] + end + end + end +end diff --git a/lib/mcp/client/tools.rb b/lib/mcp/client/tools.rb new file mode 100644 index 0000000..a63f33f --- /dev/null +++ b/lib/mcp/client/tools.rb @@ -0,0 +1,30 @@ +# typed: false +# frozen_string_literal: true + +module ModelContextProtocol + module Client + class Tools + include Enumerable + + attr_reader :response + + def initialize(response) + @response = response + end + + def each(&block) + tools.each(&block) + end + + def all + tools + end + + private + + def tools + @tools ||= @response.dig("result", "tools")&.map { |tool| Tool.new(tool) } || [] + end + end + end +end diff --git a/mcp.gemspec b/mcp.gemspec index 943ae28..8c30519 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency("faraday", ">= 2.0") spec.add_dependency("json_rpc_handler", "~> 0.1") spec.add_dependency("json-schema", ">= 4.1") end diff --git a/test/model_context_protocol/client/http_test.rb b/test/model_context_protocol/client/http_test.rb new file mode 100644 index 0000000..0fd4413 --- /dev/null +++ b/test/model_context_protocol/client/http_test.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "test_helper" +require "faraday" +require "securerandom" +require "webmock/minitest" + +module ModelContextProtocol + module Client + class HttpTest < Minitest::Test + def test_initialization_with_default_version + assert_equal("0.1.0", client.version) + assert_equal(url, client.url) + end + + def test_initialization_with_custom_version + custom_version = "1.2.3" + client = Http.new(url:, version: custom_version) + assert_equal(custom_version, client.version) + end + + def test_tools_returns_tools_instance + stub_request(:post, url) + .with( + body: { + method: "tools/list", + jsonrpc: "2.0", + id: mock_request_id, + mcp: { + method: "tools/list", + jsonrpc: "2.0", + id: mock_request_id, + }, + }, + ) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + }, + body: { + result: { + tools: [ + { + name: "test_tool", + description: "A test tool", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ], + }, + }.to_json, + ) + + tools = client.tools + assert_instance_of(Tools, tools) + assert_equal(1, tools.count) + assert_equal("test_tool", tools.first.name) + end + + def test_call_tool_returns_tool_response + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + }, + body: { + result: { + content: [ + { + text: "Tool response", + }, + ], + }, + }.to_json, + ) + + response = client.call_tool(tool: tool, input: input) + assert_equal("Tool response", response) + end + + def test_call_tool_handles_empty_response + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + }, + body: { + result: { + content: [], + }, + }.to_json, + ) + + response = client.call_tool(tool: tool, input: input) + assert_nil(response) + end + + private + + def stub_request(method, url) + WebMock.stub_request(method, url) + end + + def mock_request_id + "random_request_id" + end + + def url + "http://example.com" + end + + def client + @client ||= begin + client = Http.new(url:) + client.stubs(:request_id).returns(mock_request_id) + client + end + end + end + end +end diff --git a/test/model_context_protocol/client/tool_test.rb b/test/model_context_protocol/client/tool_test.rb new file mode 100644 index 0000000..6dbcbc1 --- /dev/null +++ b/test/model_context_protocol/client/tool_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + module Client + class ToolTest < Minitest::Test + def test_name_returns_name_from_payload + tool = Tool.new("name" => "test_tool") + assert_equal("test_tool", tool.name) + end + + def test_name_returns_nil_when_not_in_payload + tool = Tool.new({}) + assert_nil(tool.name) + end + + def test_description_returns_description_from_payload + tool = Tool.new("description" => "A test tool") + assert_equal("A test tool", tool.description) + end + + def test_description_returns_nil_when_not_in_payload + tool = Tool.new({}) + assert_nil(tool.description) + end + + def test_input_schema_returns_input_schema_from_payload + schema = { "type" => "object", "properties" => { "foo" => { "type" => "string" } } } + tool = Tool.new("inputSchema" => schema) + assert_equal(schema, tool.input_schema) + end + + def test_input_schema_returns_nil_when_not_in_payload + tool = Tool.new({}) + assert_nil(tool.input_schema) + end + + def test_payload_is_accessible + payload = { "name" => "test", "description" => "desc", "inputSchema" => {} } + tool = Tool.new(payload) + assert_equal(payload, tool.payload) + end + end + end +end diff --git a/test/model_context_protocol/client/tools_test.rb b/test/model_context_protocol/client/tools_test.rb new file mode 100644 index 0000000..c832ecd --- /dev/null +++ b/test/model_context_protocol/client/tools_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + module Client + class ToolsTest < Minitest::Test + def test_each_iterates_over_tools + response = { + "result" => { + "tools" => [ + { "name" => "tool1", "description" => "First tool" }, + { "name" => "tool2", "description" => "Second tool" }, + ], + }, + } + tools = Tools.new(response) + + tool_names = [] + tools.each { |tool| tool_names << tool.name } + + assert_equal(["tool1", "tool2"], tool_names) + end + + def test_all_returns_array_of_tools + response = { + "result" => { + "tools" => [ + { "name" => "tool1", "description" => "First tool" }, + { "name" => "tool2", "description" => "Second tool" }, + ], + }, + } + tools = Tools.new(response) + + all_tools = tools.all + assert_equal(2, all_tools.length) + assert(all_tools.all? { |tool| tool.is_a?(Tool) }) + assert_equal(["tool1", "tool2"], all_tools.map(&:name)) + end + + def test_handles_empty_tools_array + response = { "result" => { "tools" => [] } } + tools = Tools.new(response) + + assert_equal([], tools.all) + assert_equal(0, tools.count) + end + + def test_handles_missing_tools_key + response = { "result" => {} } + tools = Tools.new(response) + + assert_equal([], tools.all) + assert_equal(0, tools.count) + end + + def test_handles_missing_result_key + response = {} + tools = Tools.new(response) + + assert_equal([], tools.all) + assert_equal(0, tools.count) + end + + def test_tools_are_initialized_with_correct_payload + response = { + "result" => { + "tools" => [ + { + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { "type" => "object" }, + }, + ], + }, + } + tools = Tools.new(response) + tool = tools.all.first + + assert_equal("test_tool", tool.name) + assert_equal("A test tool", tool.description) + assert_equal({ "type" => "object" }, tool.input_schema) + end + + def test_includes_enumerable + response = { "result" => { "tools" => [] } } + tools = Tools.new(response) + + assert(tools.respond_to?(:map)) + assert(tools.respond_to?(:select)) + assert(tools.respond_to?(:find)) + end + end + end +end diff --git a/test/model_context_protocol/client_test.rb b/test/model_context_protocol/client_test.rb new file mode 100644 index 0000000..01522bf --- /dev/null +++ b/test/model_context_protocol/client_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "test_helper" + +module ModelContextProtocol + class ClientTest < Minitest::Test + end +end From 309aba532f26b9ee4483fa31879607aaf598a368 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 29 May 2025 11:22:52 -0400 Subject: [PATCH 07/24] add some client docs --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index ac0919f..1c8433b 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,47 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":"2","method":"tools/list"} ``` +## MCP Client + +The `ModelContextProtocol::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. + +### HTTP Client + +The `ModelContextProtocol::Client::Http` class provides a simple HTTP client for interacting with MCP servers: + +```ruby +client = ModelContextProtocol::Client::Http.new(url: "https://api.example.com/mcp") + +# List available tools +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name}" + puts "Description: #{tool.description}" + puts "Input Schema: #{tool.input_schema}" +end + +# Call a specific tool +response = client.call_tool( + tool: tools.first, + input: { message: "Hello, world!" } +) +``` + +The HTTP client supports: +- Tool listing via the `tools/list` method +- Tool invocation via the `tools/call` method +- Automatic JSON-RPC 2.0 message formatting +- UUID v7 request ID generation + +### Tool Objects + +The client provides wrapper objects for tools returned by the server: + +- `ModelContextProtocol::Client::Tool` - Represents a single tool with its metadata +- `ModelContextProtocol::Client::Tools` - Collection of tools with enumerable functionality + +These objects provide easy access to tool properties like name, description, and input schema. + ## Configuration The gem can be configured using the `MCP.configure` block: From d0f6b4c0b5b73ec06fee6c9e45e9fcc7c9735a6d Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 29 May 2025 12:19:47 -0400 Subject: [PATCH 08/24] add more robust error handling --- lib/mcp/client.rb | 11 + lib/mcp/client/http.rb | 82 ++++-- .../client/http_test.rb | 258 ++++++++++++++++++ 3 files changed, 330 insertions(+), 21 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index b321f7a..bd454ca 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -7,5 +7,16 @@ module ModelContextProtocol module Client # Can be made an abstract class if we need shared behavior + + class RequestHandlerError < StandardError + attr_reader :error_type, :original_error, :request + + 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 end end diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 714fc5f..f15a221 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -# require "json_rpc_handler" -# require_relative "shared/instrumentation" -# require_relative "shared/methods" - module ModelContextProtocol module Client class Http @@ -17,27 +13,15 @@ def initialize(url:, version: DEFAULT_VERSION) end def tools - response = client.post( - "", - method: "tools/list", - jsonrpc: "2.0", - id: request_id, - mcp: { method: "tools/list", jsonrpc: "2.0", id: request_id }, - ).body + response = make_request(method: "tools/list").body ::ModelContextProtocol::Client::Tools.new(response) end def call_tool(tool:, input:) - response = client.post( - "", - { - jsonrpc: "2.0", - id: request_id, - method: "tools/call", - params: { name: tool.name, arguments: input }, - mcp: { jsonrpc: "2.0", id: request_id, method: "tools/call", params: { name: tool.name, arguments: input } }, - }, + response = make_request( + method: "tools/call", + params: { name: tool.name, arguments: input }, ).body response.dig("result", "content", 0, "text") @@ -45,14 +29,70 @@ def call_tool(tool:, input:) private + # TODO: support auth def client @client ||= Faraday.new(url) do |faraday| faraday.request(:json) faraday.response(:json) - # TODO: error middleware? + faraday.response(:raise_error) end end + def make_request(method:, params: nil) + client.post( + "", + { + jsonrpc: "2.0", + id: request_id, + method:, + params:, + mcp: { jsonrpc: "2.0", id: request_id, method:, params: }.compact, + }.compact, + ) + rescue Faraday::BadRequestError => e + raise RequestHandlerError.new( + "The #{method} request is invalid", + { method:, params: }, + error_type: :bad_request, + original_error: e, + ) + rescue Faraday::UnauthorizedError => e + raise RequestHandlerError.new( + "You are unauthorized to make #{method} requests", + { method:, params: }, + error_type: :unauthorized, + original_error: e, + ) + rescue Faraday::ForbiddenError => e + raise RequestHandlerError.new( + "You are forbidden to make #{method} requests", + { method:, params: }, + error_type: :forbidden, + original_error: e, + ) + rescue Faraday::ResourceNotFound => e + raise RequestHandlerError.new( + "The #{method} request is not found", + { method:, params: }, + error_type: :not_found, + original_error: e, + ) + rescue Faraday::UnprocessableEntityError => e + raise RequestHandlerError.new( + "The #{method} request is unprocessable", + { method:, params: }, + error_type: :unprocessable_entity, + original_error: e, + ) + rescue Faraday::Error => e # Catch-all + raise RequestHandlerError.new( + "Internal error handling #{method} request", + { method:, params: }, + error_type: :internal_error, + original_error: e, + ) + end + def request_id SecureRandom.uuid_v7 end diff --git a/test/model_context_protocol/client/http_test.rb b/test/model_context_protocol/client/http_test.rb index 0fd4413..e84ed8c 100644 --- a/test/model_context_protocol/client/http_test.rb +++ b/test/model_context_protocol/client/http_test.rb @@ -160,6 +160,264 @@ def test_call_tool_handles_empty_response assert_nil(response) end + def test_raises_bad_request_error + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return(status: 400) + + error = assert_raises(RequestHandlerError) do + client.call_tool(tool: tool, input: input) + end + + assert_equal("The tools/call request is invalid", error.message) + assert_equal(:bad_request, error.error_type) + assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + end + + def test_raises_unauthorized_error + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return(status: 401) + + error = assert_raises(RequestHandlerError) do + client.call_tool(tool: tool, input: input) + end + + assert_equal("You are unauthorized to make tools/call requests", error.message) + assert_equal(:unauthorized, error.error_type) + assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + end + + def test_raises_forbidden_error + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return(status: 403) + + error = assert_raises(RequestHandlerError) do + client.call_tool(tool: tool, input: input) + end + + assert_equal("You are forbidden to make tools/call requests", error.message) + assert_equal(:forbidden, error.error_type) + assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + end + + def test_raises_not_found_error + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return(status: 404) + + error = assert_raises(RequestHandlerError) do + client.call_tool(tool: tool, input: input) + end + + assert_equal("The tools/call request is not found", error.message) + assert_equal(:not_found, error.error_type) + assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + end + + def test_raises_unprocessable_entity_error + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return(status: 422) + + error = assert_raises(RequestHandlerError) do + client.call_tool(tool: tool, input: input) + end + + assert_equal("The tools/call request is unprocessable", error.message) + assert_equal(:unprocessable_entity, error.error_type) + assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + end + + def test_raises_internal_error + tool = Tool.new( + "name" => "test_tool", + "description" => "A test tool", + "inputSchema" => { + "type" => "object", + "properties" => {}, + }, + ) + input = { "param" => "value" } + + stub_request(:post, url) + .with( + body: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + mcp: { + jsonrpc: "2.0", + id: mock_request_id, + method: "tools/call", + params: { + name: "test_tool", + arguments: input, + }, + }, + }, + ) + .to_return(status: 500) + + error = assert_raises(RequestHandlerError) do + client.call_tool(tool: tool, input: input) + end + + assert_equal("Internal error handling tools/call request", error.message) + assert_equal(:internal_error, error.error_type) + assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + end + private def stub_request(method, url) From 3aa961c577f33e3a23ef01f448c6ee957e05ccaa Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 29 May 2025 13:53:28 -0400 Subject: [PATCH 09/24] Add basic HTTP client support --- README.md | 18 ++++++++++ lib/mcp/client/http.rb | 10 ++++-- .../client/http_test.rb | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c8433b..7aebd64 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,24 @@ The HTTP client supports: - Tool invocation via the `tools/call` method - Automatic JSON-RPC 2.0 message formatting - UUID v7 request ID generation +- Setting headers for things like authorization + +### HTTP Authorization + +By default, the HTTP client has no authentication, but it supports custom headers for authentication. For example, to use Bearer token authentication: + +```ruby +client = ModelContextProtocol::Client::Http.new( + url: "https://api.example.com/mcp", + headers: { + "Authorization" => "Bearer my_token" + } +) + +client.tools # will make the call using Bearer auth +``` + +You can add any custom headers needed for your authentication scheme. The client will include these headers in all requests. ### Tool Objects diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index f15a221..05f663d 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -7,9 +7,10 @@ class Http attr_reader :url, :version - def initialize(url:, version: DEFAULT_VERSION) + def initialize(url:, version: DEFAULT_VERSION, headers: {}) @url = url @version = version + @headers = headers end def tools @@ -29,12 +30,17 @@ def call_tool(tool:, input:) private - # TODO: support auth + attr_reader :headers + def client @client ||= Faraday.new(url) do |faraday| faraday.request(:json) faraday.response(:json) faraday.response(:raise_error) + + headers.each do |key, value| + faraday.headers[key] = value + end end end diff --git a/test/model_context_protocol/client/http_test.rb b/test/model_context_protocol/client/http_test.rb index e84ed8c..d5336e1 100644 --- a/test/model_context_protocol/client/http_test.rb +++ b/test/model_context_protocol/client/http_test.rb @@ -19,6 +19,39 @@ def test_initialization_with_custom_version assert_equal(custom_version, client.version) end + def test_headers_are_added_to_the_request + headers = { "Authorization" => "Bearer token" } + client = Http.new(url:, headers:) + client.stubs(:request_id).returns(mock_request_id) + + stub_request(:post, url) + .with( + headers: { + "Authorization" => "Bearer token", + "Content-Type" => "application/json", + }, + body: { + method: "tools/list", + jsonrpc: "2.0", + id: mock_request_id, + mcp: { + method: "tools/list", + jsonrpc: "2.0", + id: mock_request_id, + }, + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + # The test passes if the request is made with the correct headers + # If headers are wrong, the stub_request won't match and will raise + client.tools + end + def test_tools_returns_tools_instance stub_request(:post, url) .with( From 3a0b9b84978ce50800caa30b0b369f4508ae81f2 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 11:30:22 -0500 Subject: [PATCH 10/24] fix gemspec --- mcp.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp.gemspec b/mcp.gemspec index 8c30519..b503086 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "lib/mcp/shared/version" +require_relative "lib/mcp/version" Gem::Specification.new do |spec| spec.name = "mcp" From efac2877a89150665e3d6fe8ae25125037ea3b0f Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 11:33:59 -0500 Subject: [PATCH 11/24] patch up old reference --- .../server/transports/stdio_transport_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/model_context_protocol/server/transports/stdio_transport_test.rb b/test/model_context_protocol/server/transports/stdio_transport_test.rb index 2b05d5f..5483967 100644 --- a/test/model_context_protocol/server/transports/stdio_transport_test.rb +++ b/test/model_context_protocol/server/transports/stdio_transport_test.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "test_helper" -require "model_context_protocol/server/transports/stdio" +require "mcp/server/transports/stdio" require "json" module ModelContextProtocol @@ -47,7 +47,7 @@ class StdioTransportTest < ActiveSupport::TestCase response = JSON.parse(output.string, symbolize_names: true) assert_equal("2.0", response[:jsonrpc]) assert_equal("123", response[:id]) - assert_equal({}, response[:result]) + assert_empty(response[:result]) refute(@transport.instance_variable_get(:@open)) ensure $stdin = original_stdin From f43c340c11ed3d4e1837f4261984f400ec8f2739 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 11:47:43 -0500 Subject: [PATCH 12/24] fix tests --- lib/mcp/client.rb | 2 +- lib/mcp/client/http.rb | 4 +- lib/mcp/client/tool.rb | 2 +- lib/mcp/client/tools.rb | 2 +- .../client/http_test.rb | 12 +- .../client/tool_test.rb | 3 +- .../client/tools_test.rb | 16 ++- .../client_test.rb | 2 +- .../server/transports/stdio_transport_test.rb | 127 ------------------ 9 files changed, 25 insertions(+), 145 deletions(-) rename test/{model_context_protocol => mcp}/client/http_test.rb (98%) rename test/{model_context_protocol => mcp}/client/tool_test.rb (97%) rename test/{model_context_protocol => mcp}/client/tools_test.rb (89%) rename test/{model_context_protocol => mcp}/client_test.rb (78%) delete mode 100644 test/model_context_protocol/server/transports/stdio_transport_test.rb diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index bd454ca..a73558c 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -4,7 +4,7 @@ # require_relative "shared/instrumentation" # require_relative "shared/methods" -module ModelContextProtocol +module MCP module Client # Can be made an abstract class if we need shared behavior diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 05f663d..0245de8 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP module Client class Http DEFAULT_VERSION = "0.1.0" @@ -16,7 +16,7 @@ def initialize(url:, version: DEFAULT_VERSION, headers: {}) def tools response = make_request(method: "tools/list").body - ::ModelContextProtocol::Client::Tools.new(response) + ::MCP::Client::Tools.new(response) end def call_tool(tool:, input:) diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb index 156a5b7..c02dbca 100644 --- a/lib/mcp/client/tool.rb +++ b/lib/mcp/client/tool.rb @@ -1,7 +1,7 @@ # typed: false # frozen_string_literal: true -module ModelContextProtocol +module MCP module Client class Tool attr_reader :payload diff --git a/lib/mcp/client/tools.rb b/lib/mcp/client/tools.rb index a63f33f..c2cdc2a 100644 --- a/lib/mcp/client/tools.rb +++ b/lib/mcp/client/tools.rb @@ -1,7 +1,7 @@ # typed: false # frozen_string_literal: true -module ModelContextProtocol +module MCP module Client class Tools include Enumerable diff --git a/test/model_context_protocol/client/http_test.rb b/test/mcp/client/http_test.rb similarity index 98% rename from test/model_context_protocol/client/http_test.rb rename to test/mcp/client/http_test.rb index d5336e1..a24f5dc 100644 --- a/test/model_context_protocol/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -4,8 +4,12 @@ require "faraday" require "securerandom" require "webmock/minitest" +require "mcp/client/http" +require "mcp/client/tool" +require "mcp/client/tools" +require "mcp/client" -module ModelContextProtocol +module MCP module Client class HttpTest < Minitest::Test def test_initialization_with_default_version @@ -15,13 +19,13 @@ def test_initialization_with_default_version def test_initialization_with_custom_version custom_version = "1.2.3" - client = Http.new(url:, version: custom_version) + client = Http.new(url: url, version: custom_version) assert_equal(custom_version, client.version) end def test_headers_are_added_to_the_request headers = { "Authorization" => "Bearer token" } - client = Http.new(url:, headers:) + client = Http.new(url: url, headers: headers) client.stubs(:request_id).returns(mock_request_id) stub_request(:post, url) @@ -467,7 +471,7 @@ def url def client @client ||= begin - client = Http.new(url:) + client = Http.new(url: url) client.stubs(:request_id).returns(mock_request_id) client end diff --git a/test/model_context_protocol/client/tool_test.rb b/test/mcp/client/tool_test.rb similarity index 97% rename from test/model_context_protocol/client/tool_test.rb rename to test/mcp/client/tool_test.rb index 6dbcbc1..8214382 100644 --- a/test/model_context_protocol/client/tool_test.rb +++ b/test/mcp/client/tool_test.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true require "test_helper" +require "mcp/client/tool" -module ModelContextProtocol +module MCP module Client class ToolTest < Minitest::Test def test_name_returns_name_from_payload diff --git a/test/model_context_protocol/client/tools_test.rb b/test/mcp/client/tools_test.rb similarity index 89% rename from test/model_context_protocol/client/tools_test.rb rename to test/mcp/client/tools_test.rb index c832ecd..0866981 100644 --- a/test/model_context_protocol/client/tools_test.rb +++ b/test/mcp/client/tools_test.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true require "test_helper" +require "mcp/client/tools" +require "mcp/client/tool" -module ModelContextProtocol +module MCP module Client class ToolsTest < Minitest::Test def test_each_iterates_over_tools @@ -43,7 +45,7 @@ def test_handles_empty_tools_array response = { "result" => { "tools" => [] } } tools = Tools.new(response) - assert_equal([], tools.all) + assert_empty(tools.all) assert_equal(0, tools.count) end @@ -51,7 +53,7 @@ def test_handles_missing_tools_key response = { "result" => {} } tools = Tools.new(response) - assert_equal([], tools.all) + assert_empty(tools.all) assert_equal(0, tools.count) end @@ -59,7 +61,7 @@ def test_handles_missing_result_key response = {} tools = Tools.new(response) - assert_equal([], tools.all) + assert_empty(tools.all) assert_equal(0, tools.count) end @@ -87,9 +89,9 @@ def test_includes_enumerable response = { "result" => { "tools" => [] } } tools = Tools.new(response) - assert(tools.respond_to?(:map)) - assert(tools.respond_to?(:select)) - assert(tools.respond_to?(:find)) + assert_respond_to(tools, :map) + assert_respond_to(tools, :select) + assert_respond_to(tools, :find) end end end diff --git a/test/model_context_protocol/client_test.rb b/test/mcp/client_test.rb similarity index 78% rename from test/model_context_protocol/client_test.rb rename to test/mcp/client_test.rb index 01522bf..2dd0901 100644 --- a/test/model_context_protocol/client_test.rb +++ b/test/mcp/client_test.rb @@ -2,7 +2,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class ClientTest < Minitest::Test end end diff --git a/test/model_context_protocol/server/transports/stdio_transport_test.rb b/test/model_context_protocol/server/transports/stdio_transport_test.rb deleted file mode 100644 index 5483967..0000000 --- a/test/model_context_protocol/server/transports/stdio_transport_test.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "mcp/server/transports/stdio" -require "json" - -module ModelContextProtocol - class Server - module Transports - class StdioTransportTest < ActiveSupport::TestCase - include InstrumentationTestHelper - - setup do - configuration = ModelContextProtocol::Configuration.new - configuration.instrumentation_callback = instrumentation_helper.callback - @server = Server.new(name: "test_server", configuration: configuration) - @transport = StdioTransport.new(@server) - end - - test "initializes with server and closed state" do - server = @transport.instance_variable_get(:@server) - assert_equal @server.object_id, server.object_id - refute @transport.instance_variable_get(:@open) - end - - test "processes JSON-RPC requests from stdin and sends responses to stdout" do - request = { - jsonrpc: "2.0", - method: "ping", - id: "123", - } - input = StringIO.new(JSON.generate(request) + "\n") - output = StringIO.new - - original_stdin = $stdin - original_stdout = $stdout - - begin - $stdin = input - $stdout = output - - thread = Thread.new { @transport.open } - sleep(0.1) - @transport.close - thread.join - - response = JSON.parse(output.string, symbolize_names: true) - assert_equal("2.0", response[:jsonrpc]) - assert_equal("123", response[:id]) - assert_empty(response[:result]) - refute(@transport.instance_variable_get(:@open)) - ensure - $stdin = original_stdin - $stdout = original_stdout - end - end - - test "sends string responses to stdout" do - output = StringIO.new - original_stdout = $stdout - - begin - $stdout = output - @transport.send_response("test response") - assert_equal("test response\n", output.string) - ensure - $stdout = original_stdout - end - end - - test "sends JSON responses to stdout" do - output = StringIO.new - original_stdout = $stdout - - begin - $stdout = output - response = { key: "value" } - @transport.send_response(response) - assert_equal(JSON.generate(response) + "\n", output.string) - ensure - $stdout = original_stdout - end - end - - test "handles valid JSON-RPC requests" do - request = { - jsonrpc: "2.0", - method: "ping", - id: "123", - } - output = StringIO.new - original_stdout = $stdout - - begin - $stdout = output - @transport.send(:handle_request, JSON.generate(request)) - response = JSON.parse(output.string, symbolize_names: true) - assert_equal("2.0", response[:jsonrpc]) - assert_nil(response[:id]) - assert_nil(response[:result]) - ensure - $stdout = original_stdout - end - end - - test "handles invalid JSON requests" do - invalid_json = "invalid json" - output = StringIO.new - original_stdout = $stdout - - begin - $stdout = output - @transport.send(:handle_request, invalid_json) - response = JSON.parse(output.string, symbolize_names: true) - assert_equal("2.0", response[:jsonrpc]) - assert_nil(response[:id]) - assert_equal(-32600, response[:error][:code]) - assert_equal("Invalid Request", response[:error][:message]) - assert_equal("Request must be an array or a hash", response[:error][:data]) - ensure - $stdout = original_stdout - end - end - end - end - end -end From 72e07aa78887b68ea81a2262faec8d9b31f3ce15 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 12:11:30 -0500 Subject: [PATCH 13/24] make faraday optional --- Gemfile | 1 + README.md | 17 +++++++++++++++++ lib/mcp/client/http.rb | 8 ++++++++ mcp.gemspec | 3 ++- test/mcp/client/http_test.rb | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 04ea5fb..54748c0 100644 --- a/Gemfile +++ b/Gemfile @@ -25,4 +25,5 @@ gem "sorbet-static-and-runtime" group :test do gem "webmock" + gem "faraday", ">= 2.0" end diff --git a/README.md b/README.md index 7aebd64..bf7895b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,21 @@ Add this line to your application's Gemfile: gem 'mcp' ``` +### Optional Dependencies + +The MCP gem has different dependency requirements depending on your use case: + +**For Server-only usage:** +```ruby +gem 'mcp' +``` + +**For client HTTP transport usage:** +```ruby +gem 'mcp' +gem 'faraday', '>= 2.0' +``` + And then execute: ```console @@ -220,6 +235,8 @@ $ ruby examples/stdio_server.rb The `ModelContextProtocol::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. +**Note:** The client HTTP transport requires the `faraday` gem. Add `gem 'faraday', '>= 2.0'` to your Gemfile if you plan to use the client HTTP transport functionality. + ### HTTP Client The `ModelContextProtocol::Client::Http` class provides a simple HTTP client for interacting with MCP servers: diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 0245de8..846cce7 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -33,6 +33,7 @@ def call_tool(tool:, input:) attr_reader :headers def client + require_faraday! @client ||= Faraday.new(url) do |faraday| faraday.request(:json) faraday.response(:json) @@ -44,6 +45,13 @@ def client end end + def require_faraday! + require "faraday" + rescue LoadError + raise LoadError, "The 'faraday' gem is required to use the MCP client HTTP transport. " \ + "Add it to your Gemfile: gem 'faraday', '>= 2.0'" + end + def make_request(method:, params: nil) client.post( "", diff --git a/mcp.gemspec b/mcp.gemspec index b503086..a3c1245 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -27,7 +27,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency("faraday", ">= 2.0") spec.add_dependency("json_rpc_handler", "~> 0.1") spec.add_dependency("json-schema", ">= 4.1") + + # Faraday is required for the client HTTP transport layer end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index a24f5dc..b33a885 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -23,6 +23,20 @@ def test_initialization_with_custom_version assert_equal(custom_version, client.version) end + def test_raises_load_error_when_faraday_not_available + client = Http.new(url: url) + + # simulate Faraday not being available + Http.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday") + + error = assert_raises(LoadError) do + client.send(:client) # Call the private method that triggers require_faraday! + end + + assert_includes(error.message, "The 'faraday' gem is required to use the MCP client HTTP transport") + assert_includes(error.message, "Add it to your Gemfile: gem 'faraday', '>= 2.0'") + end + def test_headers_are_added_to_the_request headers = { "Authorization" => "Bearer token" } client = Http.new(url: url, headers: headers) From d5718303c4626475cb8396e91fe60227ddf325b0 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 12:13:08 -0500 Subject: [PATCH 14/24] patch up lingering ModelContextProtocol references --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bf7895b..defccb2 100644 --- a/README.md +++ b/README.md @@ -233,16 +233,16 @@ $ ruby examples/stdio_server.rb ## MCP Client -The `ModelContextProtocol::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. +The `MCP::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. **Note:** The client HTTP transport requires the `faraday` gem. Add `gem 'faraday', '>= 2.0'` to your Gemfile if you plan to use the client HTTP transport functionality. ### HTTP Client -The `ModelContextProtocol::Client::Http` class provides a simple HTTP client for interacting with MCP servers: +The `MCP::Client::Http` class provides a simple HTTP client for interacting with MCP servers: ```ruby -client = ModelContextProtocol::Client::Http.new(url: "https://api.example.com/mcp") +client = MCP::Client::Http.new(url: "https://api.example.com/mcp") # List available tools tools = client.tools @@ -271,7 +271,7 @@ The HTTP client supports: By default, the HTTP client has no authentication, but it supports custom headers for authentication. For example, to use Bearer token authentication: ```ruby -client = ModelContextProtocol::Client::Http.new( +client = MCP::Client::Http.new( url: "https://api.example.com/mcp", headers: { "Authorization" => "Bearer my_token" @@ -287,8 +287,8 @@ You can add any custom headers needed for your authentication scheme. The client The client provides wrapper objects for tools returned by the server: -- `ModelContextProtocol::Client::Tool` - Represents a single tool with its metadata -- `ModelContextProtocol::Client::Tools` - Collection of tools with enumerable functionality +- `MCP::Client::Tool` - Represents a single tool with its metadata +- `MCP::Client::Tools` - Collection of tools with enumerable functionality These objects provide easy access to tool properties like name, description, and input schema. From 35a17c2a6e05eda037eff30eb358ee73e25de989 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 12:14:45 -0500 Subject: [PATCH 15/24] ew, stop calling private method in tests --- test/mcp/client/http_test.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index b33a885..c7847bc 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -30,7 +30,9 @@ def test_raises_load_error_when_faraday_not_available Http.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday") error = assert_raises(LoadError) do - client.send(:client) # Call the private method that triggers require_faraday! + # I picked #tools arbritarily. + # This should immediately try to instantiate the client and fail + client.tools end assert_includes(error.message, "The 'faraday' gem is required to use the MCP client HTTP transport") From ce5ad24e17f9b7f07d5e72689d72667baab1b2a1 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 12:15:38 -0500 Subject: [PATCH 16/24] I need a spellcheck extension --- test/mcp/client/http_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index c7847bc..d0c0554 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -30,7 +30,7 @@ def test_raises_load_error_when_faraday_not_available Http.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday") error = assert_raises(LoadError) do - # I picked #tools arbritarily. + # I picked #tools arbitrarily. # This should immediately try to instantiate the client and fail client.tools end From 51680032a1e32fff66ca719ef92a26d082c73674 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 15:03:43 -0500 Subject: [PATCH 17/24] rename private method --- lib/mcp/client/http.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 846cce7..ed76f28 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -14,13 +14,13 @@ def initialize(url:, version: DEFAULT_VERSION, headers: {}) end def tools - response = make_request(method: "tools/list").body + response = send_request(method: "tools/list").body ::MCP::Client::Tools.new(response) end def call_tool(tool:, input:) - response = make_request( + response = send_request( method: "tools/call", params: { name: tool.name, arguments: input }, ).body @@ -52,7 +52,7 @@ def require_faraday! "Add it to your Gemfile: gem 'faraday', '>= 2.0'" end - def make_request(method:, params: nil) + def send_request(method:, params: nil) client.post( "", { From 50ad8c80c3bf92510fc0f4d61f00340d35cb2911 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 30 Jul 2025 16:00:41 -0500 Subject: [PATCH 18/24] return all responses, not just text property of first one --- lib/mcp/client/http.rb | 2 +- test/mcp/client/http_test.rb | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index ed76f28..80ad7e8 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -25,7 +25,7 @@ def call_tool(tool:, input:) params: { name: tool.name, arguments: input }, ).body - response.dig("result", "content", 0, "text") + response.dig("result", "content") end private diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index d0c0554..5b7ff38 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -156,13 +156,18 @@ def test_call_tool_returns_tool_response { text: "Tool response", }, + { + custom_property: "woah, something different", + }, ], }, }.to_json, ) response = client.call_tool(tool: tool, input: input) - assert_equal("Tool response", response) + assert_equal(2, response.size) + assert_equal("Tool response", response.dig(0, "text")) + assert_equal("woah, something different", response.dig(1, "custom_property")) end def test_call_tool_handles_empty_response @@ -210,7 +215,7 @@ def test_call_tool_handles_empty_response ) response = client.call_tool(tool: tool, input: input) - assert_nil(response) + assert_empty(response) end def test_raises_bad_request_error From 9ed51ffd679a654939e461133fd280e4ceec3220 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 31 Jul 2025 09:01:56 -0500 Subject: [PATCH 19/24] attempt to break up readme between server and client --- README.md | 158 ++++++++++++++++++++++++------------------------------ 1 file changed, 70 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index defccb2..40c8332 100644 --- a/README.md +++ b/README.md @@ -10,34 +10,9 @@ Add this line to your application's Gemfile: gem 'mcp' ``` -### Optional Dependencies +You may need to add additional dependencies depending on which features you wish to access. -The MCP gem has different dependency requirements depending on your use case: - -**For Server-only usage:** -```ruby -gem 'mcp' -``` - -**For client HTTP transport usage:** -```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' -``` - -And then execute: - -```console -$ bundle install -``` - -Or install it yourself as: - -```console -$ gem install mcp -``` - -## MCP Server +## Building an MCP Server The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. It implements the Model Context Protocol specification, handling model context requests and responses. @@ -231,67 +206,6 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":"2","method":"tools/list"} ``` -## MCP Client - -The `MCP::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. - -**Note:** The client HTTP transport requires the `faraday` gem. Add `gem 'faraday', '>= 2.0'` to your Gemfile if you plan to use the client HTTP transport functionality. - -### HTTP Client - -The `MCP::Client::Http` class provides a simple HTTP client for interacting with MCP servers: - -```ruby -client = MCP::Client::Http.new(url: "https://api.example.com/mcp") - -# List available tools -tools = client.tools -tools.each do |tool| - puts "Tool: #{tool.name}" - puts "Description: #{tool.description}" - puts "Input Schema: #{tool.input_schema}" -end - -# Call a specific tool -response = client.call_tool( - tool: tools.first, - input: { message: "Hello, world!" } -) -``` - -The HTTP client supports: -- Tool listing via the `tools/list` method -- Tool invocation via the `tools/call` method -- Automatic JSON-RPC 2.0 message formatting -- UUID v7 request ID generation -- Setting headers for things like authorization - -### HTTP Authorization - -By default, the HTTP client has no authentication, but it supports custom headers for authentication. For example, to use Bearer token authentication: - -```ruby -client = MCP::Client::Http.new( - url: "https://api.example.com/mcp", - headers: { - "Authorization" => "Bearer my_token" - } -) - -client.tools # will make the call using Bearer auth -``` - -You can add any custom headers needed for your authentication scheme. The client will include these headers in all requests. - -### Tool Objects - -The client provides wrapper objects for tools returned by the server: - -- `MCP::Client::Tool` - Represents a single tool with its metadata -- `MCP::Client::Tools` - Collection of tools with enumerable functionality - -These objects provide easy access to tool properties like name, description, and input schema. - ## Configuration The gem can be configured using the `MCP.configure` block: @@ -659,6 +573,74 @@ end otherwise `resources/read` requests will be a no-op. +## Building an MCP Client + +The `MCP::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. + +**Note:** The client HTTP transport requires the `faraday` gem. Add `gem 'faraday', '>= 2.0'` to your Gemfile if you plan to use the client HTTP transport functionality. + +### HTTP Transport Layer + +You'll need to add `faraday` as a dependency to use the HTTP transport layer. + +```ruby +gem 'mcp' +gem 'faraday', '>= 2.0' +``` + +The `MCP::Client::Http` class provides a simple HTTP client for interacting with MCP servers: + +```ruby +client = MCP::Client::Http.new(url: "https://api.example.com/mcp") + +# List available tools +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name}" + puts "Description: #{tool.description}" + puts "Input Schema: #{tool.input_schema}" +end + +# Call a specific tool +response = client.call_tool( + tool: tools.first, + input: { message: "Hello, world!" } +) +``` + +The HTTP client supports: +- Tool listing via the `tools/list` method +- Tool invocation via the `tools/call` method +- Automatic JSON-RPC 2.0 message formatting +- UUID v7 request ID generation +- Setting headers for things like authorization + +#### HTTP Authorization + +By default, the HTTP client has no authentication, but it supports custom headers for authentication. For example, to use Bearer token authentication: + +```ruby +client = MCP::Client::Http.new( + url: "https://api.example.com/mcp", + headers: { + "Authorization" => "Bearer my_token" + } +) + +client.tools # will make the call using Bearer auth +``` + +You can add any custom headers needed for your authentication scheme. The client will include these headers in all requests. + +### Tool Objects + +The client provides wrapper objects for tools returned by the server: + +- `MCP::Client::Tool` - Represents a single tool with its metadata +- `MCP::Client::Tools` - Collection of tools with enumerable functionality + +These objects provide easy access to tool properties like name, description, and input schema. + ## Releases This gem is published to [RubyGems.org](https://rubygems.org/gems/mcp) From 0f9cb08424f9cdf33e63669a8e4bdc892aee3c32 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 31 Jul 2025 09:08:01 -0500 Subject: [PATCH 20/24] patch up sizing --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 40c8332..411f2a0 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ $ ruby examples/stdio_server.rb {"jsonrpc":"2.0","id":"2","method":"tools/list"} ``` -## Configuration +### Configuration The gem can be configured using the `MCP.configure` block: @@ -352,7 +352,7 @@ When an exception occurs: If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. -## Tools +### Tools MCP spec includes [Tools](https://modelcontextprotocol.io/docs/concepts/tools) which provide functionality to LLM apps. @@ -415,7 +415,7 @@ Tools can include annotations that provide additional metadata about their behav Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method. -## Prompts +### Prompts MCP spec includes [Prompts](https://modelcontextprotocol.io/docs/concepts/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. @@ -538,7 +538,7 @@ The data contains the following keys: `tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered. This is to avoid potential issues with metric cardinality -## Resources +### Resources MCP spec includes [Resources](https://modelcontextprotocol.io/docs/concepts/resources) From 15bcc6a34bb92484fdb06d02f3d77d2e54a0a9f7 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 31 Jul 2025 09:09:06 -0500 Subject: [PATCH 21/24] add install instructions back --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 411f2a0..4718fac 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ Add this line to your application's Gemfile: gem 'mcp' ``` +And then execute: + +```console +$ bundle install +``` + +Or install it yourself as: + +```console +$ gem install mcp +``` + You may need to add additional dependencies depending on which features you wish to access. ## Building an MCP Server From 289e462eee03bfd98465cf00795d0f2c43ee8402 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 31 Jul 2025 09:49:22 -0500 Subject: [PATCH 22/24] add comment ackknowledging empty test --- test/mcp/client_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 2dd0901..3afd50f 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -4,5 +4,6 @@ module MCP class ClientTest < Minitest::Test + # no functionality to test yet end end From d097729c2f063050fbd976329488eb7d4da61f34 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Tue, 5 Aug 2025 16:06:58 -0500 Subject: [PATCH 23/24] remove version and Tools, make client a wrapper class --- README.md | 9 ++-- lib/mcp.rb | 3 ++ lib/mcp/client.rb | 58 ++++++++++++++++++--- lib/mcp/client/http.rb | 19 ++++--- lib/mcp/client/tool.rb | 22 +++----- lib/mcp/client/tools.rb | 30 ----------- test/mcp/client/http_test.rb | 83 +++++++++++++++-------------- test/mcp/client/tool_test.rb | 46 ++++++---------- test/mcp/client/tools_test.rb | 98 ----------------------------------- 9 files changed, 133 insertions(+), 235 deletions(-) delete mode 100644 lib/mcp/client/tools.rb delete mode 100644 test/mcp/client/tools_test.rb diff --git a/README.md b/README.md index 4718fac..af97d24 100644 --- a/README.md +++ b/README.md @@ -603,7 +603,7 @@ gem 'faraday', '>= 2.0' The `MCP::Client::Http` class provides a simple HTTP client for interacting with MCP servers: ```ruby -client = MCP::Client::Http.new(url: "https://api.example.com/mcp") +client = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") # List available tools tools = client.tools @@ -632,7 +632,7 @@ The HTTP client supports: By default, the HTTP client has no authentication, but it supports custom headers for authentication. For example, to use Bearer token authentication: ```ruby -client = MCP::Client::Http.new( +client = MCP::Client::HTTP.new( url: "https://api.example.com/mcp", headers: { "Authorization" => "Bearer my_token" @@ -646,12 +646,11 @@ You can add any custom headers needed for your authentication scheme. The client ### Tool Objects -The client provides wrapper objects for tools returned by the server: +The client provides a wrapper class for tools returned by the server: - `MCP::Client::Tool` - Represents a single tool with its metadata -- `MCP::Client::Tools` - Collection of tools with enumerable functionality -These objects provide easy access to tool properties like name, description, and input schema. +This class provide easy access to tool properties like name, description, and input schema. ## Releases diff --git a/lib/mcp.rb b/lib/mcp.rb index 84ccce4..d511c98 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -22,6 +22,9 @@ require_relative "mcp/tool/annotations" require_relative "mcp/transport" require_relative "mcp/version" +require_relative "mcp/client" +require_relative "mcp/client/http" +require_relative "mcp/client/tool" module MCP class << self diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index a73558c..07d1018 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,12 +1,58 @@ # frozen_string_literal: true -# require "json_rpc_handler" -# require_relative "shared/instrumentation" -# require_relative "shared/methods" - module MCP - module Client - # Can be made an abstract class if we need shared behavior + class Client + # Initializes a new MCP::Client instance. + # + # @param transport [Object] The transport object to use for communication with the server. + # The transport should be a duck type that responds to both `#tools` and `#call_tool`. + # This allows the client to list available tools and invoke tool calls via the transport. + # + # @example + # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") + # client = MCP::Client.new(transport: transport) + # + # @note + # The transport does not need to be a specific class, but must implement: + # - #tools + # - #call_tool(tool:, input:) + def initialize(transport:) + @transport = transport + end + + # The user may want to access additional transport-specific methods/attributes + # So keeping it public + attr_reader :transport + + # Returns the list of tools available from the server. + # + # @return [Array] An array of available tools. + # + # @example + # tools = client.tools + # tools.each do |tool| + # puts tool.name + # end + def tools + @tools ||= transport.tools + end + + # Calls a tool via the transport layer. + # + # @param tool [MCP::Client::Tool] The tool to be called. + # @param input [Object, nil] The input to pass to the tool. + # @return [Object] The result of the tool call, as returned by the transport. + # + # @example + # tool = client.tools.first + # result = client.call_tool(tool: tool, input: { foo: "bar" }) + # + # @note + # The exact requirements for `input` are determined by the transport layer in use. + # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. + def call_tool(tool:, input: nil) + transport.call_tool(tool: tool, input: input) + end class RequestHandlerError < StandardError attr_reader :error_type, :original_error, :request diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 80ad7e8..cb287c1 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,22 +1,25 @@ # frozen_string_literal: true module MCP - module Client - class Http - DEFAULT_VERSION = "0.1.0" + class Client + class HTTP + attr_reader :url - attr_reader :url, :version - - def initialize(url:, version: DEFAULT_VERSION, headers: {}) + def initialize(url:, headers: {}) @url = url - @version = version @headers = headers end def tools response = send_request(method: "tools/list").body - ::MCP::Client::Tools.new(response) + response.dig("result", "tools")&.map do |tool| + Tool.new( + name: tool["name"], + description: tool["description"], + input_schema: tool["inputSchema"], + ) + end || [] end def call_tool(tool:, input:) diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb index c02dbca..ffec38a 100644 --- a/lib/mcp/client/tool.rb +++ b/lib/mcp/client/tool.rb @@ -2,24 +2,14 @@ # frozen_string_literal: true module MCP - module Client + class Client class Tool - attr_reader :payload + attr_reader :name, :description, :input_schema - def initialize(payload) - @payload = payload - end - - def name - payload["name"] - end - - def description - payload["description"] - end - - def input_schema - payload["inputSchema"] + def initialize(name:, description:, input_schema:) + @name = name + @description = description + @input_schema = input_schema end end end diff --git a/lib/mcp/client/tools.rb b/lib/mcp/client/tools.rb deleted file mode 100644 index c2cdc2a..0000000 --- a/lib/mcp/client/tools.rb +++ /dev/null @@ -1,30 +0,0 @@ -# typed: false -# frozen_string_literal: true - -module MCP - module Client - class Tools - include Enumerable - - attr_reader :response - - def initialize(response) - @response = response - end - - def each(&block) - tools.each(&block) - end - - def all - tools - end - - private - - def tools - @tools ||= @response.dig("result", "tools")&.map { |tool| Tool.new(tool) } || [] - end - end - end -end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 5b7ff38..449c91c 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -6,28 +6,27 @@ require "webmock/minitest" require "mcp/client/http" require "mcp/client/tool" -require "mcp/client/tools" require "mcp/client" module MCP - module Client - class HttpTest < Minitest::Test - def test_initialization_with_default_version - assert_equal("0.1.0", client.version) - assert_equal(url, client.url) - end - - def test_initialization_with_custom_version - custom_version = "1.2.3" - client = Http.new(url: url, version: custom_version) - assert_equal(custom_version, client.version) - end + class Client + class HTTPTest < Minitest::Test + # def test_initialization_with_default_version + # assert_equal("0.1.0", client.version) + # assert_equal(url, client.url) + # end + + # def test_initialization_with_custom_version + # custom_version = "1.2.3" + # client = HTTP.new(url: url, version: custom_version) + # assert_equal(custom_version, client.version) + # end def test_raises_load_error_when_faraday_not_available - client = Http.new(url: url) + client = HTTP.new(url: url) # simulate Faraday not being available - Http.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday") + HTTP.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday") error = assert_raises(LoadError) do # I picked #tools arbitrarily. @@ -41,7 +40,7 @@ def test_raises_load_error_when_faraday_not_available def test_headers_are_added_to_the_request headers = { "Authorization" => "Bearer token" } - client = Http.new(url: url, headers: headers) + client = HTTP.new(url: url, headers: headers) client.stubs(:request_id).returns(mock_request_id) stub_request(:post, url) @@ -108,16 +107,16 @@ def test_tools_returns_tools_instance ) tools = client.tools - assert_instance_of(Tools, tools) + assert_instance_of(Array, tools) assert_equal(1, tools.count) assert_equal("test_tool", tools.first.name) end def test_call_tool_returns_tool_response tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -172,9 +171,9 @@ def test_call_tool_returns_tool_response def test_call_tool_handles_empty_response tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -220,9 +219,9 @@ def test_call_tool_handles_empty_response def test_raises_bad_request_error tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -263,9 +262,9 @@ def test_raises_bad_request_error def test_raises_unauthorized_error tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -306,9 +305,9 @@ def test_raises_unauthorized_error def test_raises_forbidden_error tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -349,9 +348,9 @@ def test_raises_forbidden_error def test_raises_not_found_error tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -392,9 +391,9 @@ def test_raises_not_found_error def test_raises_unprocessable_entity_error tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -435,9 +434,9 @@ def test_raises_unprocessable_entity_error def test_raises_internal_error tool = Tool.new( - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => {}, }, @@ -492,7 +491,7 @@ def url def client @client ||= begin - client = Http.new(url: url) + client = HTTP.new(url: url) client.stubs(:request_id).returns(mock_request_id) client end diff --git a/test/mcp/client/tool_test.rb b/test/mcp/client/tool_test.rb index 8214382..6ffde6f 100644 --- a/test/mcp/client/tool_test.rb +++ b/test/mcp/client/tool_test.rb @@ -4,43 +4,29 @@ require "mcp/client/tool" module MCP - module Client + class Client class ToolTest < Minitest::Test - def test_name_returns_name_from_payload - tool = Tool.new("name" => "test_tool") - assert_equal("test_tool", tool.name) + def setup + @tool = Tool.new( + name: "test_tool", + description: "A test tool", + input_schema: { "type" => "object", "properties" => { "foo" => { "type" => "string" } } }, + ) end - def test_name_returns_nil_when_not_in_payload - tool = Tool.new({}) - assert_nil(tool.name) + def test_name_returns_name + assert_equal("test_tool", @tool.name) end - def test_description_returns_description_from_payload - tool = Tool.new("description" => "A test tool") - assert_equal("A test tool", tool.description) + def test_description_returns_description + assert_equal("A test tool", @tool.description) end - def test_description_returns_nil_when_not_in_payload - tool = Tool.new({}) - assert_nil(tool.description) - end - - def test_input_schema_returns_input_schema_from_payload - schema = { "type" => "object", "properties" => { "foo" => { "type" => "string" } } } - tool = Tool.new("inputSchema" => schema) - assert_equal(schema, tool.input_schema) - end - - def test_input_schema_returns_nil_when_not_in_payload - tool = Tool.new({}) - assert_nil(tool.input_schema) - end - - def test_payload_is_accessible - payload = { "name" => "test", "description" => "desc", "inputSchema" => {} } - tool = Tool.new(payload) - assert_equal(payload, tool.payload) + def test_input_schema_returns_input_schema + assert_equal( + { "type" => "object", "properties" => { "foo" => { "type" => "string" } } }, + @tool.input_schema, + ) end end end diff --git a/test/mcp/client/tools_test.rb b/test/mcp/client/tools_test.rb deleted file mode 100644 index 0866981..0000000 --- a/test/mcp/client/tools_test.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "mcp/client/tools" -require "mcp/client/tool" - -module MCP - module Client - class ToolsTest < Minitest::Test - def test_each_iterates_over_tools - response = { - "result" => { - "tools" => [ - { "name" => "tool1", "description" => "First tool" }, - { "name" => "tool2", "description" => "Second tool" }, - ], - }, - } - tools = Tools.new(response) - - tool_names = [] - tools.each { |tool| tool_names << tool.name } - - assert_equal(["tool1", "tool2"], tool_names) - end - - def test_all_returns_array_of_tools - response = { - "result" => { - "tools" => [ - { "name" => "tool1", "description" => "First tool" }, - { "name" => "tool2", "description" => "Second tool" }, - ], - }, - } - tools = Tools.new(response) - - all_tools = tools.all - assert_equal(2, all_tools.length) - assert(all_tools.all? { |tool| tool.is_a?(Tool) }) - assert_equal(["tool1", "tool2"], all_tools.map(&:name)) - end - - def test_handles_empty_tools_array - response = { "result" => { "tools" => [] } } - tools = Tools.new(response) - - assert_empty(tools.all) - assert_equal(0, tools.count) - end - - def test_handles_missing_tools_key - response = { "result" => {} } - tools = Tools.new(response) - - assert_empty(tools.all) - assert_equal(0, tools.count) - end - - def test_handles_missing_result_key - response = {} - tools = Tools.new(response) - - assert_empty(tools.all) - assert_equal(0, tools.count) - end - - def test_tools_are_initialized_with_correct_payload - response = { - "result" => { - "tools" => [ - { - "name" => "test_tool", - "description" => "A test tool", - "inputSchema" => { "type" => "object" }, - }, - ], - }, - } - tools = Tools.new(response) - tool = tools.all.first - - assert_equal("test_tool", tool.name) - assert_equal("A test tool", tool.description) - assert_equal({ "type" => "object" }, tool.input_schema) - end - - def test_includes_enumerable - response = { "result" => { "tools" => [] } } - tools = Tools.new(response) - - assert_respond_to(tools, :map) - assert_respond_to(tools, :select) - assert_respond_to(tools, :find) - end - end - end -end From 687c2aeebef15a5bcc4386dd773d1cb5c9f3735e Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Tue, 5 Aug 2025 16:39:00 -0500 Subject: [PATCH 24/24] simple client test --- test/mcp/client_test.rb | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 3afd50f..c7346dc 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -4,6 +4,24 @@ module MCP class ClientTest < Minitest::Test - # no functionality to test yet + def test_tools_delegates_to_transport_and_caches_result + transport = mock + mock_tools = [ + MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}), + MCP::Client::Tool.new(name: "tool2", description: "tool2", input_schema: {}), + ] + transport.expects(:tools).returns(mock_tools).once + client = Client.new(transport: transport) + assert_equal(mock_tools, client.tools) + assert_equal(mock_tools, client.tools) + end + + def test_call_tool_delegates_to_transport + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + transport.expects(:call_tool).with(tool: tool, input: { foo: "bar" }).returns("result") + client = Client.new(transport: transport) + assert_equal("result", client.call_tool(tool: tool, input: { foo: "bar" })) + end end end