Skip to content

Commit 79a7127

Browse files
committed
Add basic HTTP client support with pluggable transports
1 parent eb0d9c0 commit 79a7127

File tree

9 files changed

+609
-8
lines changed

9 files changed

+609
-8
lines changed

Gemfile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ source "https://rubygems.org"
66
gemspec
77

88
# Specify development dependencies below
9-
gem "minitest", "~> 5.1", require: false
10-
gem "mocha"
11-
129
gem "rubocop-minitest", require: false
1310
gem "rubocop-rake", require: false
1411
gem "rubocop-shopify", require: false
@@ -21,3 +18,10 @@ gem "activesupport"
2118
gem "debug"
2219
gem "rake", "~> 13.0"
2320
gem "sorbet-static-and-runtime"
21+
22+
group :test do
23+
gem "faraday", ">= 2.0"
24+
gem "minitest", "~> 5.1", require: false
25+
gem "mocha"
26+
gem "webmock"
27+
end

README.md

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ Or install it yourself as:
2222
$ gem install mcp
2323
```
2424

25-
## MCP Server
25+
You may need to add additional dependencies depending on which features you wish to access.
26+
27+
## Building an MCP Server
2628

2729
The `MCP::Server` class is the core component that handles JSON-RPC requests and responses.
2830
It implements the Model Context Protocol specification, handling model context requests and responses.
@@ -218,7 +220,7 @@ $ ruby examples/stdio_server.rb
218220
{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}}
219221
```
220222

221-
## Configuration
223+
### Configuration
222224

223225
The gem can be configured using the `MCP.configure` block:
224226

@@ -365,7 +367,7 @@ When an exception occurs:
365367

366368
If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions.
367369

368-
## Tools
370+
### Tools
369371

370372
MCP spec includes [Tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) which provide functionality to LLM apps.
371373

@@ -430,7 +432,7 @@ Tools can include annotations that provide additional metadata about their behav
430432

431433
Annotations can be set either through the class definition using the `annotations` class method or when defining a tool using the `define` method.
432434

433-
## Prompts
435+
### Prompts
434436

435437
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.
436438

@@ -556,7 +558,7 @@ The data contains the following keys:
556558
`tool_name`, `prompt_name` and `resource_uri` are only populated if a matching handler is registered.
557559
This is to avoid potential issues with metric cardinality
558560

559-
## Resources
561+
### Resources
560562

561563
MCP spec includes [Resources](https://modelcontextprotocol.io/specification/2025-06-18/server/resources).
562564

@@ -612,6 +614,102 @@ server = MCP::Server.new(
612614
)
613615
```
614616

617+
## Building an MCP Client
618+
619+
The `MCP::Client` class provides an interface for interacting with MCP servers.
620+
621+
This class supports:
622+
623+
- Tool listing via the `tools/list` method
624+
- Tool invocation via the `tools/call` method
625+
- Automatic JSON-RPC 2.0 message formatting
626+
- UUID request ID generation
627+
628+
Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
629+
Authorization is handled by the transport layer.
630+
631+
## Transport Layer Interface
632+
633+
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:
634+
635+
```ruby
636+
class CustomTransport
637+
# Sends a JSON-RPC request to the server and returns the raw response.
638+
#
639+
# @param request [Hash] A complete JSON-RPC request object.
640+
# https://www.jsonrpc.org/specification#request_object
641+
# @return [Hash] A hash modeling a JSON-RPC response object.
642+
# https://www.jsonrpc.org/specification#response_object
643+
def send_request(request:)
644+
# Your transport-specific logic here
645+
# - HTTP: POST to endpoint with JSON body
646+
# - WebSocket: Send message over WebSocket
647+
# - stdio: Write to stdout, read from stdin
648+
# - etc.
649+
end
650+
end
651+
```
652+
653+
### HTTP Transport Layer
654+
655+
Use the `MCP::Client::Http` transport to interact with MCP servers using simple HTTP requests.
656+
657+
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer:
658+
659+
```ruby
660+
gem 'mcp'
661+
gem 'faraday', '>= 2.0'
662+
```
663+
664+
Example usage:
665+
666+
```ruby
667+
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
668+
client = MCP::Client.new(transport: http_transport)
669+
670+
# List available tools
671+
tools = client.tools
672+
tools.each do |tool|
673+
puts <<~TOOL_INFORMATION
674+
Tool: #{tool.name}
675+
Description: #{tool.description}
676+
Input Schema: #{tool.input_schema}
677+
TOOL_INFORMATION
678+
end
679+
680+
# Call a specific tool
681+
response = client.call_tool(
682+
tool: tools.first,
683+
arguments: { message: "Hello, world!" }
684+
)
685+
```
686+
687+
#### HTTP Authorization
688+
689+
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:
690+
691+
```ruby
692+
http_transport = MCP::Client::HTTP.new(
693+
url: "https://api.example.com/mcp",
694+
headers: {
695+
"Authorization" => "Bearer my_token"
696+
}
697+
)
698+
699+
client = MCP::Client.new(transport: http_transport)
700+
client.tools # will make the call using Bearer auth
701+
```
702+
703+
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.
704+
705+
### Tool Objects
706+
707+
The client provides a wrapper class for tools returned by the server:
708+
709+
- `MCP::Client::Tool` - Represents a single tool with its metadata
710+
711+
This class provide easy access to tool properties like name, description, and input schema.
712+
615713
## Releases
616714

617715
This gem is published to [RubyGems.org](https://rubygems.org/gems/mcp)

lib/mcp.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
require_relative "mcp/tool/annotations"
2323
require_relative "mcp/transport"
2424
require_relative "mcp/version"
25+
require_relative "mcp/client"
26+
require_relative "mcp/client/http"
27+
require_relative "mcp/client/tool"
2528

2629
module MCP
2730
class << self

lib/mcp/client.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
# Initializes a new MCP::Client instance.
6+
#
7+
# @param transport [Object] The transport object to use for communication with the server.
8+
# The transport should be a duck type that responds to `send_request`. See the README for more details.
9+
#
10+
# @example
11+
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
12+
# client = MCP::Client.new(transport: transport)
13+
def initialize(transport:)
14+
@transport = transport
15+
end
16+
17+
# The user may want to access additional transport-specific methods/attributes
18+
# So keeping it public
19+
attr_reader :transport
20+
21+
# Returns the list of tools available from the server.
22+
# Each call will make a new request – the result is not cached.
23+
#
24+
# @return [Array<MCP::Client::Tool>] An array of available tools.
25+
#
26+
# @example
27+
# tools = client.tools
28+
# tools.each do |tool|
29+
# puts tool.name
30+
# end
31+
def tools
32+
response = transport.send_request(request: {
33+
jsonrpc: JsonRpcHandler::Version::V2_0,
34+
id: request_id,
35+
method: "tools/list",
36+
})
37+
38+
response.dig("result", "tools")&.map do |tool|
39+
Tool.new(
40+
name: tool["name"],
41+
description: tool["description"],
42+
input_schema: tool["inputSchema"],
43+
)
44+
end || []
45+
end
46+
47+
# Calls a tool via the transport layer.
48+
#
49+
# @param tool [MCP::Client::Tool] The tool to be called.
50+
# @param arguments [Object, nil] The arguments to pass to the tool.
51+
# @return [Object] The result of the tool call, as returned by the transport.
52+
#
53+
# @example
54+
# tool = client.tools.first
55+
# result = client.call_tool(tool: tool, arguments: { foo: "bar" })
56+
#
57+
# @note
58+
# The exact requirements for `arguments` are determined by the transport layer in use.
59+
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
60+
def call_tool(tool:, arguments: nil)
61+
response = transport.send_request(request: {
62+
jsonrpc: JsonRpcHandler::Version::V2_0,
63+
id: request_id,
64+
method: "tools/call",
65+
params: { name: tool.name, arguments: arguments },
66+
})
67+
68+
response.dig("result", "content")
69+
end
70+
71+
private
72+
73+
def request_id
74+
SecureRandom.uuid
75+
end
76+
77+
class RequestHandlerError < StandardError
78+
attr_reader :error_type, :original_error, :request
79+
80+
def initialize(message, request, error_type: :internal_error, original_error: nil)
81+
super(message)
82+
@request = request
83+
@error_type = error_type
84+
@original_error = original_error
85+
end
86+
end
87+
end
88+
end

lib/mcp/client/http.rb

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
class HTTP
6+
attr_reader :url
7+
8+
def initialize(url:, headers: {})
9+
@url = url
10+
@headers = headers
11+
end
12+
13+
def send_request(request:)
14+
method = request[:method] || request["method"]
15+
params = request[:params] || request["params"]
16+
17+
client.post("", request).body
18+
rescue Faraday::BadRequestError => e
19+
raise RequestHandlerError.new(
20+
"The #{method} request is invalid",
21+
{ method:, params: },
22+
error_type: :bad_request,
23+
original_error: e,
24+
)
25+
rescue Faraday::UnauthorizedError => e
26+
raise RequestHandlerError.new(
27+
"You are unauthorized to make #{method} requests",
28+
{ method:, params: },
29+
error_type: :unauthorized,
30+
original_error: e,
31+
)
32+
rescue Faraday::ForbiddenError => e
33+
raise RequestHandlerError.new(
34+
"You are forbidden to make #{method} requests",
35+
{ method:, params: },
36+
error_type: :forbidden,
37+
original_error: e,
38+
)
39+
rescue Faraday::ResourceNotFound => e
40+
raise RequestHandlerError.new(
41+
"The #{method} request is not found",
42+
{ method:, params: },
43+
error_type: :not_found,
44+
original_error: e,
45+
)
46+
rescue Faraday::UnprocessableEntityError => e
47+
raise RequestHandlerError.new(
48+
"The #{method} request is unprocessable",
49+
{ method:, params: },
50+
error_type: :unprocessable_entity,
51+
original_error: e,
52+
)
53+
rescue Faraday::Error => e # Catch-all
54+
raise RequestHandlerError.new(
55+
"Internal error handling #{method} request",
56+
{ method:, params: },
57+
error_type: :internal_error,
58+
original_error: e,
59+
)
60+
end
61+
62+
private
63+
64+
attr_reader :headers
65+
66+
def client
67+
require_faraday!
68+
@client ||= Faraday.new(url) do |faraday|
69+
faraday.request(:json)
70+
faraday.response(:json)
71+
faraday.response(:raise_error)
72+
73+
headers.each do |key, value|
74+
faraday.headers[key] = value
75+
end
76+
end
77+
end
78+
79+
def require_faraday!
80+
require "faraday"
81+
rescue LoadError
82+
raise LoadError, "The 'faraday' gem is required to use the MCP client HTTP transport. " \
83+
"Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
84+
"See https://rubygems.org/gems/faraday for more details."
85+
end
86+
end
87+
end
88+
end

lib/mcp/client/tool.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
class Tool
6+
attr_reader :name, :description, :input_schema
7+
8+
def initialize(name:, description:, input_schema:)
9+
@name = name
10+
@description = description
11+
@input_schema = input_schema
12+
end
13+
end
14+
end
15+
end

0 commit comments

Comments
 (0)