Merge pull request #28 from jcat4/add-basic-http-client-support

Add basic http client support
This commit is contained in:
Koichi ITO 2025-09-09 18:51:07 +09:00 • committed by GitHub
commit 97640ed626
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 609 additions and 8 deletions

10
Gemfile
View file

@ -6,9 +6,6 @@ source "https://rubygems.org"
gemspec
# Specify development dependencies below
gem "minitest", "~> 5.1", require: false
gem "mocha"
gem "rubocop-minitest", require: false
gem "rubocop-rake", require: false
gem "rubocop-shopify", require: false
@ -21,3 +18,10 @@ gem "activesupport"
gem "debug"
gem "rake", "~> 13.0"
gem "sorbet-static-and-runtime"
group :test do
gem "faraday", ">= 2.0"
gem "minitest", "~> 5.1", require: false
gem "mocha"
gem "webmock"
end

108
README.md
View file

@ -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.
@ -218,7 +220,7 @@ $ ruby examples/stdio_server.rb
{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}}
```
## Configuration
### Configuration
The gem can be configured using the `MCP.configure` block:
@ -365,7 +367,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/specification/2025-06-18/server/tools) which provide functionality to LLM apps.
@ -430,7 +432,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/specification/2025-06-18/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs.
@ -556,7 +558,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/specification/2025-06-18/server/resources).
@ -612,6 +614,102 @@ server = MCP::Server.new(
)
```
## Building an MCP Client
The `MCP::Client` class provides an interface for interacting with MCP servers.
This class 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
Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
Authorization is handled by the transport layer.
## 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.
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_INFORMATION
Tool: #{tool.name}
Description: #{tool.description}
Input Schema: #{tool.input_schema}
TOOL_INFORMATION
end
# Call a specific tool
response = client.call_tool(
tool: tools.first,
arguments: { 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)

View file

@ -22,6 +22,9 @@ require_relative "mcp/tool/response"
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

88
lib/mcp/client.rb Normal file
View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
module MCP
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 `send_request`. See the README for more details.
#
# @example
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
# client = MCP::Client.new(transport: transport)
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<MCP::Client::Tool>] 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: JsonRpcHandler::Version::V2_0,
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 arguments [Object, nil] The arguments 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, arguments: { foo: "bar" })
#
# @note
# The exact requirements for `arguments` 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:, arguments: nil)
response = transport.send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: request_id,
method: "tools/call",
params: { name: tool.name, arguments: arguments },
})
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

88
lib/mcp/client/http.rb Normal file
View file

@ -0,0 +1,88 @@
# 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'" \
"See https://rubygems.org/gems/faraday for more details."
end
end
end
end

15
lib/mcp/client/tool.rb Normal file
View file

@ -0,0 +1,15 @@
# 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

View file

@ -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

View file

@ -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

60
test/mcp/client_test.rb Normal file
View file

@ -0,0 +1,60 @@
# 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 in { request: { method: "tools/list", 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: {})
arguments = { foo: "bar" }
mock_response = {
"result" => { "content" => "result" },
}
# Only checking for the essential parts of the request
transport.expects(:send_request).with do |args|
args in {
request: {
method: "tools/call",
jsonrpc: "2.0",
params: {
name: "tool1",
arguments: arguments,
},
},
}
end.returns(mock_response).once
client = Client.new(transport: transport)
result = client.call_tool(tool: tool, arguments: arguments)
assert_equal("result", result)
end
end
end