diff --git a/.gitignore b/.gitignore index 750a5a9..521721a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ /spec/reports/ /tmp/ Gemfile.lock + +# Mac stuff +.DS_Store diff --git a/Gemfile b/Gemfile index c8a7a26..54748c0 100644 --- a/Gemfile +++ b/Gemfile @@ -22,3 +22,8 @@ gem "activesupport" gem "debug" gem "rake", "~> 13.0" gem "sorbet-static-and-runtime" + +group :test do + gem "webmock" + gem "faraday", ">= 2.0" +end diff --git a/README.md b/README.md index ac0919f..f988dce 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Or install it yourself as: $ gem install mcp ``` -## MCP Server +You may need to add additional dependencies depending on which features you wish to access. + +## 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. @@ -216,7 +218,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: @@ -362,7 +364,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. @@ -425,7 +427,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. @@ -548,7 +550,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) @@ -583,6 +585,98 @@ end 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 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 + # 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 +``` + +### 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 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' +``` + +Example usage: + +```ruby +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 +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!" } +) +``` + +#### HTTP Authorization + +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 +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, or for any other purpose. The client will include these headers on every request. + +### Tool Objects + +The client provides a wrapper class for tools returned by the server: + +- `MCP::Client::Tool` - Represents a single tool with its metadata + +This class 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) @@ -595,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.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 new file mode 100644 index 0000000..909a2c3 --- /dev/null +++ b/lib/mcp/client.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +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. + # 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. + # Each call will make a new request – the result is not cached. + # + # @return [Array] An array of available tools. + # + # @example + # tools = client.tools + # tools.each do |tool| + # puts tool.name + # end + def tools + response = transport.send_request(request: { + jsonrpc: JSON_RPC_VERSION, + id: request_id, + method: "tools/list", + }) + + 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. + # + # @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) + response = transport.send_request(request: { + jsonrpc: JSON_RPC_VERSION, + id: request_id, + method: "tools/call", + params: { name: tool.name, arguments: input }, + }) + + response.dig("result", "content") + end + + private + + def request_id + SecureRandom.uuid + end + + 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 new file mode 100644 index 0000000..54d4d9d --- /dev/null +++ b/lib/mcp/client/http.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module MCP + class Client + class HTTP + attr_reader :url + + def initialize(url:, headers: {}) + @url = url + @headers = headers + end + + def send_request(request:) + method = request[:method] || request["method"] + params = request[:params] || request["params"] + + client.post("", request).body + 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 + + 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 +end diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb new file mode 100644 index 0000000..ffec38a --- /dev/null +++ b/lib/mcp/client/tool.rb @@ -0,0 +1,16 @@ +# typed: false +# frozen_string_literal: true + +module MCP + class Client + class Tool + attr_reader :name, :description, :input_schema + + def initialize(name:, description:, input_schema:) + @name = name + @description = description + @input_schema = input_schema + end + end + end +end diff --git a/mcp.gemspec b/mcp.gemspec index fefe3b3..a3c1245 100644 --- a/mcp.gemspec +++ b/mcp.gemspec @@ -29,4 +29,6 @@ Gem::Specification.new do |spec| 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 new file mode 100644 index 0000000..e7f5b58 --- /dev/null +++ b/test/mcp/client/http_test.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require "test_helper" +require "faraday" +require "webmock/minitest" +require "mcp/client/http" +require "mcp/client/tool" +require "mcp/client" + +module MCP + class Client + class HTTPTest < Minitest::Test + 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 + # This should immediately try to instantiate the client and fail + client.send_request(request: {}) + 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) + + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with( + headers: { + "Authorization" => "Bearer token", + "Content-Type" => "application/json", + }, + body: request.to_json, + ) + .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.send_request(request: request) + end + + def test_send_request_returns_faraday_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + response = client.send_request(request: request) + assert_instance_of(Hash, response) + assert_equal({ "result" => { "tools" => [] } }, response) + end + + def test_send_request_raises_bad_request_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 400) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal("The tools/list request is invalid", error.message) + assert_equal(:bad_request, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_send_request_raises_unauthorized_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 401) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal("You are unauthorized to make tools/list requests", error.message) + assert_equal(:unauthorized, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_send_request_raises_forbidden_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 403) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal("You are forbidden to make tools/list requests", error.message) + assert_equal(:forbidden, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_send_request_raises_not_found_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 404) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal("The tools/list request is not found", error.message) + assert_equal(:not_found, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_send_request_raises_unprocessable_entity_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 422) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal("The tools/list request is unprocessable", error.message) + assert_equal(:unprocessable_entity, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_send_request_raises_internal_error + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 500) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal("Internal error handling tools/list request", error.message) + assert_equal(:internal_error, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + + private + + def stub_request(method, url) + WebMock.stub_request(method, url) + end + + def url + "http://example.com" + end + + def client + @client ||= HTTP.new(url: url) + end + end + end +end diff --git a/test/mcp/client/tool_test.rb b/test/mcp/client/tool_test.rb new file mode 100644 index 0000000..6ffde6f --- /dev/null +++ b/test/mcp/client/tool_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/tool" + +module MCP + class Client + class ToolTest < Minitest::Test + 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_name + assert_equal("test_tool", @tool.name) + end + + def test_description_returns_description + assert_equal("A test tool", @tool.description) + end + + def test_input_schema_returns_input_schema + assert_equal( + { "type" => "object", "properties" => { "foo" => { "type" => "string" } } }, + @tool.input_schema, + ) + end + end + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb new file mode 100644 index 0000000..07e4b63 --- /dev/null +++ b/test/mcp/client_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "test_helper" +require "securerandom" + +module MCP + class ClientTest < Minitest::Test + def test_tools_sends_request_to_transport_and_returns_tools_array + transport = mock + 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" + end.returns(mock_response).once + + client = Client.new(transport: transport) + 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_sends_request_to_transport_and_returns_content + transport = mock + tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) + 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) + result = client.call_tool(tool: tool, input: input) + + assert_equal("result", result) + end + end +end