-
Notifications
You must be signed in to change notification settings - Fork 56
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
base: main
Are you sure you want to change the base?
Changes from all commits
e08c659
bcd59c3
db08725
53a1156
5eacee6
9ad0d99
309aba5
d0f6b4c
3aa961c
3a0b9b8
efac287
f43c340
72e07aa
d571830
35a17c2
ce5ad24
5168003
50ad8c8
9ed51ff
0f9cb08
15bcc6a
289e462
d097729
687c2ae
dccb29e
281cb4c
e92e4ec
e05e502
f3f13e4
f83e561
f3bc852
644cf1c
3a87856
c2bf904
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,3 +8,6 @@ | |
/spec/reports/ | ||
/tmp/ | ||
Gemfile.lock | ||
|
||
# Mac stuff | ||
.DS_Store | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
|
@@ -216,7 +218,7 @@ $ ruby examples/stdio_server.rb | |
{"jsonrpc":"2.0","id":"2","method":"tools/list"} | ||
``` | ||
|
||
## Configuration | ||
### Configuration | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
||
|
@@ -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. | ||
|
||
|
@@ -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. | ||
|
||
|
@@ -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) | ||
|
||
|
@@ -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) | ||
|
@@ -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. | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could |
||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
# frozen_string_literal: true | ||
|
||
module MCP | ||
class Client | ||
class HTTP | ||
jcat4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 |
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️