Skip to content

Commit 91d3327

Browse files
committed
Add basic HTTP client support with pluggable transports
1 parent 9486c63 commit 91d3327

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.
@@ -216,7 +218,7 @@ $ ruby examples/stdio_server.rb
216218
{"jsonrpc":"2.0","id":"2","method":"tools/list"}
217219
```
218220

219-
## Configuration
221+
### Configuration
220222

221223
The gem can be configured using the `MCP.configure` block:
222224

@@ -363,7 +365,7 @@ When an exception occurs:
363365

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

366-
## Tools
368+
### Tools
367369

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

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

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

431-
## Prompts
433+
### Prompts
432434

433435
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.
434436

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

557-
## Resources
559+
### Resources
558560

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

@@ -590,6 +592,101 @@ end
590592

591593
otherwise `resources/read` requests will be a no-op.
592594

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

595692
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)