Skip to content

Commit 774a3af

Browse files
committed
Add basic HTTP client support with pluggable transports
1 parent eb0d9c0 commit 774a3af

File tree

9 files changed

+614
-8
lines changed

9 files changed

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

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