From e08c65992b530e4f4c8c3e229f22f8a4ae543904 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Wed, 28 May 2025 10:49:09 -0400 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 From dccb29eec5eb719a03e462a39797b4a651ac7076 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 08:42:10 -0500 Subject: [PATCH 25/34] stop memoizing tools --- lib/mcp/client.rb | 3 ++- test/mcp/client_test.rb | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 07d1018..d10e7fa 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -25,6 +25,7 @@ def initialize(transport:) attr_reader :transport # Returns the list of tools available from the server. + # Each call will make a new request – the result is not cached. # # @return [Array] An array of available tools. # @@ -34,7 +35,7 @@ def initialize(transport:) # puts tool.name # end def tools - @tools ||= transport.tools + transport.tools end # Calls a tool via the transport layer. diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index c7346dc..c2f6944 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -4,7 +4,7 @@ module MCP class ClientTest < Minitest::Test - def test_tools_delegates_to_transport_and_caches_result + def test_tools_delegates_to_transport transport = mock mock_tools = [ MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}), @@ -13,7 +13,6 @@ def test_tools_delegates_to_transport_and_caches_result 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 From 281cb4c61cb764392997072fb15c38d243bcd814 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 09:12:40 -0500 Subject: [PATCH 26/34] fix up README with new interface and examples --- README.md | 51 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index af97d24..0fe01be 100644 --- a/README.md +++ b/README.md @@ -587,23 +587,50 @@ 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. +The `MCP::Client` class provides an interface for interacting with MCP servers. +Clients are initialized with a transport layer instance that instructs them how to interact with the server. -**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. +If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: + +```ruby +class CustomTransport + def tools + # Must return Array + end + + def call_tool(tool:, input:) + # tool: MCP::Client::Tool + # input: Hash - the arguments to pass to the tool + # Returns: Hash - the content from the response (typically response.dig("result", "content")) + end +end +``` + +**Note:** We strongly recommend returning `MCP::Client::Tool` instances rather than custom tool objects with the same interface, as this ensures compatibility with future SDK features and provides a consistent interface. ### HTTP Transport Layer -You'll need to add `faraday` as a dependency to use the HTTP transport layer. +Use the `MCP::Client::Http` transport to interact with MCP servers using simple HTTP requests. + +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 + +You'll need to add `faraday` as a dependency in order 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: +Example usage: ```ruby -client = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") +client = MCP::Client.new(transport: http_transport) # List available tools tools = client.tools @@ -620,29 +647,23 @@ response = client.call_tool( ) ``` -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: +By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: ```ruby -client = MCP::Client::HTTP.new( +http_transport = MCP::Client::HTTP.new( url: "https://api.example.com/mcp", headers: { "Authorization" => "Bearer my_token" } ) +client = MCP::Client.new(transport: http_transport) 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. +You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every requests. ### Tool Objects From e92e4eca2dc8555ececb4b133d07945bfeb41a25 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 11:23:17 -0500 Subject: [PATCH 27/34] make transports implement template methods instead --- README.md | 27 ++- lib/mcp/client.rb | 44 +++- lib/mcp/client/http.rb | 83 ++----- test/mcp/client/http_test.rb | 452 +++++++---------------------------- test/mcp/client_test.rb | 49 +++- 5 files changed, 205 insertions(+), 450 deletions(-) diff --git a/README.md b/README.md index 0fe01be..f3a593d 100644 --- a/README.md +++ b/README.md @@ -588,26 +588,30 @@ otherwise `resources/read` requests will be a no-op. ## Building an MCP Client The `MCP::Client` class provides an interface for interacting with MCP servers. -Clients are initialized with a transport layer instance that instructs them how to interact with the server. +Clients are initialized with a transport layer instance that handles the low-level communication mechanics. + +## Transport Layer Interface If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: ```ruby class CustomTransport - def tools - # Must return Array - end - - def call_tool(tool:, input:) - # tool: MCP::Client::Tool - # input: Hash - the arguments to pass to the tool - # Returns: Hash - the content from the response (typically response.dig("result", "content")) + # Sends a JSON-RPC request to the server and returns the raw response + # + # @param request [Hash] A complete JSON-RPC request object. + # https://www.jsonrpc.org/specification#request_object + # @return [Hash] A hash modeling a JSON-RPC response object: + # https://www.jsonrpc.org/specification#response_object + def send_request(request:) + # Your transport-specific logic here + # - HTTP: POST to endpoint with JSON body + # - WebSocket: Send message over WebSocket + # - stdio: Write to stdout, read from stdin + # - etc. end end ``` -**Note:** We strongly recommend returning `MCP::Client::Tool` instances rather than custom tool objects with the same interface, as this ensures compatibility with future SDK features and provides a consistent interface. - ### HTTP Transport Layer Use the `MCP::Client::Http` transport to interact with MCP servers using simple HTTP requests. @@ -685,3 +689,4 @@ Releases are triggered by PRs to the `main` branch updating the version number i 1. **Merge your PR to the main branch** - This will automatically trigger the release workflow via GitHub Actions When changes are merged to the `main` branch, the GitHub Actions workflow (`.github/workflows/release.yml`) is triggered and the gem is published to RubyGems. + diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index d10e7fa..46ec799 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -2,6 +2,8 @@ module MCP class Client + JSON_RPC_VERSION = "2.0" + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. @@ -35,7 +37,25 @@ def initialize(transport:) # puts tool.name # end def tools - transport.tools + request = { + jsonrpc: JSON_RPC_VERSION, + id: request_id, + method: "tools/list", + mcp: { + jsonrpc: JSON_RPC_VERSION, + id: request_id, + method: "tools/list", + }.compact, + } + + response = transport.send_request(request: request) + response.dig("result", "tools")&.map do |tool| + Tool.new( + name: tool["name"], + description: tool["description"], + input_schema: tool["inputSchema"], + ) + end || [] end # Calls a tool via the transport layer. @@ -52,7 +72,27 @@ def tools # 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) + request = { + jsonrpc: JSON_RPC_VERSION, + id: request_id, + method: "tools/call", + params: { name: tool.name, arguments: input }, + mcp: { + jsonrpc: JSON_RPC_VERSION, + id: request_id, + method: "tools/call", + params: { name: tool.name, arguments: input }, + }.compact, + } + + response = transport.send_request(request: request) + response.dig("result", "content") + end + + private + + def request_id + SecureRandom.uuid_v7 end class RequestHandlerError < StandardError diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index cb287c1..54d4d9d 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -10,62 +10,11 @@ def initialize(url:, headers: {}) @headers = headers end - def tools - response = send_request(method: "tools/list").body + def send_request(request:) + method = request[:method] || request["method"] + params = request[:params] || request["params"] - 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:) - response = send_request( - method: "tools/call", - params: { name: tool.name, arguments: input }, - ).body - - response.dig("result", "content") - end - - private - - attr_reader :headers - - def client - require_faraday! - @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 - - 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 send_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, - ) + client.post("", request).body rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -110,8 +59,28 @@ def send_request(method:, params: nil) ) end - def request_id - SecureRandom.uuid_v7 + private + + attr_reader :headers + + def client + require_faraday! + @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 + + 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 end end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 449c91c..02d1396 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -11,17 +11,6 @@ module MCP 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) @@ -29,9 +18,8 @@ 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 arbitrarily. # This should immediately try to instantiate the client and fail - client.tools + client.send_request(request: {}) end assert_includes(error.message, "The 'faraday' gem is required to use the MCP client HTTP transport") @@ -41,7 +29,12 @@ 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.stubs(:request_id).returns(mock_request_id) + + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } stub_request(:post, url) .with( @@ -49,16 +42,7 @@ def test_headers_are_added_to_the_request "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, - }, - }, + body: request.to_json, ) .to_return( status: 200, @@ -68,411 +52,147 @@ def test_headers_are_added_to_the_request # 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( - 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(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", - input_schema: { - "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", - }, - { - custom_property: "woah, something different", - }, - ], - }, - }.to_json, - ) - - response = client.call_tool(tool: tool, input: input) - assert_equal(2, response.size) - assert_equal("Tool response", response.dig(0, "text")) - assert_equal("woah, something different", response.dig(1, "custom_property")) + client.send_request(request: request) end - def test_call_tool_handles_empty_response - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_returns_faraday_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return( status: 200, - headers: { - "Content-Type" => "application/json", - }, - body: { - result: { - content: [], - }, - }.to_json, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, ) - response = client.call_tool(tool: tool, input: input) - assert_empty(response) + response = client.send_request(request: request) + assert_instance_of(Hash, response) + assert_equal({ "result" => { "tools" => [] } }, response) end - def test_raises_bad_request_error - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_raises_bad_request_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return(status: 400) error = assert_raises(RequestHandlerError) do - client.call_tool(tool: tool, input: input) + client.send_request(request: request) end - assert_equal("The tools/call request is invalid", error.message) + assert_equal("The tools/list 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) + assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_raises_unauthorized_error - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_raises_unauthorized_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return(status: 401) error = assert_raises(RequestHandlerError) do - client.call_tool(tool: tool, input: input) + client.send_request(request: request) end - assert_equal("You are unauthorized to make tools/call requests", error.message) + assert_equal("You are unauthorized to make tools/list requests", error.message) assert_equal(:unauthorized, error.error_type) - assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_raises_forbidden_error - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_raises_forbidden_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return(status: 403) error = assert_raises(RequestHandlerError) do - client.call_tool(tool: tool, input: input) + client.send_request(request: request) end - assert_equal("You are forbidden to make tools/call requests", error.message) + assert_equal("You are forbidden to make tools/list requests", error.message) assert_equal(:forbidden, error.error_type) - assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_raises_not_found_error - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_raises_not_found_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return(status: 404) error = assert_raises(RequestHandlerError) do - client.call_tool(tool: tool, input: input) + client.send_request(request: request) end - assert_equal("The tools/call request is not found", error.message) + assert_equal("The tools/list 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) + assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_raises_unprocessable_entity_error - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_raises_unprocessable_entity_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return(status: 422) error = assert_raises(RequestHandlerError) do - client.call_tool(tool: tool, input: input) + client.send_request(request: request) end - assert_equal("The tools/call request is unprocessable", error.message) + assert_equal("The tools/list 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) + assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_raises_internal_error - tool = Tool.new( - name: "test_tool", - description: "A test tool", - input_schema: { - "type" => "object", - "properties" => {}, - }, - ) - input = { "param" => "value" } + def test_send_request_raises_internal_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } 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, - }, - }, - }, - ) + .with(body: request.to_json) .to_return(status: 500) error = assert_raises(RequestHandlerError) do - client.call_tool(tool: tool, input: input) + client.send_request(request: request) end - assert_equal("Internal error handling tools/call request", error.message) + assert_equal("Internal error handling tools/list request", error.message) assert_equal(:internal_error, error.error_type) - assert_equal({ method: "tools/call", params: { name: "test_tool", arguments: input } }, error.request) + assert_equal({ method: "tools/list", params: nil }, error.request) end private @@ -481,20 +201,12 @@ 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: url) - client.stubs(:request_id).returns(mock_request_id) - client - end + @client ||= HTTP.new(url: url) end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index c2f6944..968e12b 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -4,23 +4,52 @@ module MCP class ClientTest < Minitest::Test - def test_tools_delegates_to_transport + def test_tools_sends_request_to_transport_and_returns_tools_array 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 + mock_response = { + "result" => { + "tools" => [ + { "name" => "tool1", "description" => "tool1", "inputSchema" => {} }, + { "name" => "tool2", "description" => "tool2", "inputSchema" => {} }, + ], + }, + } + + # Only checking for the essential parts of the request + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/list" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :mcp, :method) == "tools/list" + end.returns(mock_response).once + client = Client.new(transport: transport) - assert_equal(mock_tools, client.tools) + tools = client.tools + + assert_equal(2, tools.size) + assert_equal("tool1", tools.first.name) + assert_equal("tool2", tools.last.name) end - def test_call_tool_delegates_to_transport + def test_call_tool_sends_request_to_transport_and_returns_content 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") + input = { foo: "bar" } + mock_response = { + "result" => { "content" => "result" }, + } + + # Only checking for the essential parts of the request + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "tools/call" && + args.dig(:request, :jsonrpc) == "2.0" && + args.dig(:request, :params, :name) == "tool1" && + args.dig(:request, :params, :arguments) == input + end.returns(mock_response).once + client = Client.new(transport: transport) - assert_equal("result", client.call_tool(tool: tool, input: { foo: "bar" })) + result = client.call_tool(tool: tool, input: input) + + assert_equal("result", result) end end end From e05e502f0ea421a63f57f108d481a41533a9b4ba Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 11:25:20 -0500 Subject: [PATCH 28/34] fix imports --- test/mcp/client/http_test.rb | 1 - test/mcp/client_test.rb | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 02d1396..e7f5b58 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -2,7 +2,6 @@ require "test_helper" require "faraday" -require "securerandom" require "webmock/minitest" require "mcp/client/http" require "mcp/client/tool" diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 968e12b..0101516 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "securerandom" module MCP class ClientTest < Minitest::Test From f3f13e45c77debda63639a3df4966bfd2046cd48 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 11:29:14 -0500 Subject: [PATCH 29/34] just use uuid, that's what we use everywhere else anyway --- lib/mcp/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 46ec799..f6356a6 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -92,7 +92,7 @@ def call_tool(tool:, input: nil) private def request_id - SecureRandom.uuid_v7 + SecureRandom.uuid end class RequestHandlerError < StandardError From f83e5617b950801d234d57bd672abe9fc801f5de Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 11:35:28 -0500 Subject: [PATCH 30/34] remove duplicate mcp property --- lib/mcp/client.rb | 11 ----------- test/mcp/client_test.rb | 3 +-- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index f6356a6..539bc5e 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -41,11 +41,6 @@ def tools jsonrpc: JSON_RPC_VERSION, id: request_id, method: "tools/list", - mcp: { - jsonrpc: JSON_RPC_VERSION, - id: request_id, - method: "tools/list", - }.compact, } response = transport.send_request(request: request) @@ -77,12 +72,6 @@ def call_tool(tool:, input: nil) id: request_id, method: "tools/call", params: { name: tool.name, arguments: input }, - mcp: { - jsonrpc: JSON_RPC_VERSION, - id: request_id, - method: "tools/call", - params: { name: tool.name, arguments: input }, - }.compact, } response = transport.send_request(request: request) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 0101516..07e4b63 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -19,8 +19,7 @@ def test_tools_sends_request_to_transport_and_returns_tools_array # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "tools/list" && - args.dig(:request, :jsonrpc) == "2.0" && - args.dig(:request, :mcp, :method) == "tools/list" + args.dig(:request, :jsonrpc) == "2.0" end.returns(mock_response).once client = Client.new(transport: transport) From f3bc852be80638674255cd762fccb2cf7d78710e Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 11:40:44 -0500 Subject: [PATCH 31/34] indent json spec docs --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f3a593d..6272fdd 100644 --- a/README.md +++ b/README.md @@ -599,9 +599,9 @@ class CustomTransport # Sends a JSON-RPC request to the server and returns the raw response # # @param request [Hash] A complete JSON-RPC request object. - # https://www.jsonrpc.org/specification#request_object - # @return [Hash] A hash modeling a JSON-RPC response object: - # https://www.jsonrpc.org/specification#response_object + # https://www.jsonrpc.org/specification#request_object + # @return [Hash] A hash modeling a JSON-RPC response object. + # https://www.jsonrpc.org/specification#response_object def send_request(request:) # Your transport-specific logic here # - HTTP: POST to endpoint with JSON body From 644cf1c6a95561b3fd95338034a4720fb20241a4 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 14:04:55 -0500 Subject: [PATCH 32/34] =?UTF-8?q?not=20using=20uuid=20v7=20anymore=20?= =?UTF-8?q?=F0=9F=99=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6272fdd..7a03f6a 100644 --- a/README.md +++ b/README.md @@ -620,7 +620,7 @@ 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 +- UUID request ID generation - Setting headers for things like authorization You'll need to add `faraday` as a dependency in order to use the HTTP transport layer: From 3a878560f41cc7196128e272928f2f8c5f80291a Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 14:05:54 -0500 Subject: [PATCH 33/34] typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a03f6a..f988dce 100644 --- a/README.md +++ b/README.md @@ -667,7 +667,7 @@ client = MCP::Client.new(transport: http_transport) client.tools # will make the call using Bearer auth ``` -You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every requests. +You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. ### Tool Objects From c2bf9040b472e500d45414f354b84ab7d344b131 Mon Sep 17 00:00:00 2001 From: Joey Cardosi Date: Thu, 14 Aug 2025 14:07:40 -0500 Subject: [PATCH 34/34] eh, no need for tempvar rn --- lib/mcp/client.rb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 539bc5e..909a2c3 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -37,13 +37,12 @@ def initialize(transport:) # puts tool.name # end def tools - request = { + response = transport.send_request(request: { jsonrpc: JSON_RPC_VERSION, id: request_id, method: "tools/list", - } + }) - response = transport.send_request(request: request) response.dig("result", "tools")&.map do |tool| Tool.new( name: tool["name"], @@ -67,14 +66,13 @@ def tools # 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) - request = { + response = transport.send_request(request: { jsonrpc: JSON_RPC_VERSION, id: request_id, method: "tools/call", params: { name: tool.name, arguments: input }, - } + }) - response = transport.send_request(request: request) response.dig("result", "content") end