Skip to content

Add basic http client support #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e08c659
refactor cdoe in prep for client support
jcat4 May 28, 2025
bcd59c3
remove gem
jcat4 May 28, 2025
db08725
remove DS_Store
jcat4 May 28, 2025
53a1156
add original spec files code back
jcat4 May 28, 2025
5eacee6
move and fix some tests
jcat4 May 28, 2025
9ad0d99
Add basic HTTP client support
jcat4 May 28, 2025
309aba5
add some client docs
jcat4 May 29, 2025
d0f6b4c
add more robust error handling
jcat4 May 29, 2025
3aa961c
Add basic HTTP client support
jcat4 May 29, 2025
3a0b9b8
fix gemspec
jcat4 Jul 30, 2025
efac287
patch up old reference
jcat4 Jul 30, 2025
f43c340
fix tests
jcat4 Jul 30, 2025
72e07aa
make faraday optional
jcat4 Jul 30, 2025
d571830
patch up lingering ModelContextProtocol references
jcat4 Jul 30, 2025
35a17c2
ew, stop calling private method in tests
jcat4 Jul 30, 2025
ce5ad24
I need a spellcheck extension
jcat4 Jul 30, 2025
5168003
rename private method
jcat4 Jul 30, 2025
50ad8c8
return all responses, not just text property of first one
jcat4 Jul 30, 2025
9ed51ff
attempt to break up readme between server and client
jcat4 Jul 31, 2025
0f9cb08
patch up sizing
jcat4 Jul 31, 2025
15bcc6a
add install instructions back
jcat4 Jul 31, 2025
289e462
add comment ackknowledging empty test
jcat4 Jul 31, 2025
d097729
remove version and Tools, make client a wrapper class
jcat4 Aug 5, 2025
687c2ae
simple client test
jcat4 Aug 5, 2025
dccb29e
stop memoizing tools
jcat4 Aug 14, 2025
281cb4c
fix up README with new interface and examples
jcat4 Aug 14, 2025
e92e4ec
make transports implement template methods instead
jcat4 Aug 14, 2025
e05e502
fix imports
jcat4 Aug 14, 2025
f3f13e4
just use uuid, that's what we use everywhere else anyway
jcat4 Aug 14, 2025
f83e561
remove duplicate mcp property
jcat4 Aug 14, 2025
f3bc852
indent json spec docs
jcat4 Aug 14, 2025
644cf1c
not using uuid v7 anymore 🙈
jcat4 Aug 14, 2025
3a87856
typo
jcat4 Aug 14, 2025
c2bf904
eh, no need for tempvar rn
jcat4 Aug 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@
/spec/reports/
/tmp/
Gemfile.lock

# Mac stuff
.DS_Store
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
105 changes: 100 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -216,7 +218,7 @@ $ ruby examples/stdio_server.rb
{"jsonrpc":"2.0","id":"2","method":"tools/list"}
```

## Configuration
### Configuration
Copy link
Contributor Author

@jcat4 jcat4 Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment, this is server-specific. If we have this patch a client config too (or stop having the config scoped to just the server), we can move this somewhere else in the README


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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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.

3 changes: 3 additions & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
@@ -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:)
Comment on lines +7 to +20
Copy link
Contributor Author

@jcat4 jcat4 Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see other YARD docs in this gem, but felt it might be useful to document ducktypes and whatnot. I want this to be easy to understand

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: 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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could **kwargs this is we want to let folks pass their own special stuff for custom transport layers, too

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
87 changes: 87 additions & 0 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions lib/mcp/client/tool.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions mcp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading