-
Notifications
You must be signed in to change notification settings - Fork 53
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
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,73 @@ end | |
|
||
otherwise `resources/read` requests will be a no-op. | ||
|
||
## Building an MCP Client | ||
|
||
The `MCP::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers. | ||
|
||
**Note:** The client HTTP transport requires the `faraday` gem. Add `gem 'faraday', '>= 2.0'` to your Gemfile if you plan to use the client HTTP transport functionality. | ||
|
||
### HTTP Transport Layer | ||
|
||
You'll need to add `faraday` as a dependency to use the HTTP transport layer. | ||
|
||
```ruby | ||
gem 'mcp' | ||
gem 'faraday', '>= 2.0' | ||
``` | ||
|
||
The `MCP::Client::Http` class provides a simple HTTP client for interacting with MCP servers: | ||
|
||
```ruby | ||
client = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") | ||
|
||
# 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!" } | ||
) | ||
``` | ||
|
||
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 v7 request ID generation | ||
- Setting headers for things like authorization | ||
|
||
#### HTTP Authorization | ||
|
||
By default, the HTTP client has no authentication, but it supports custom headers for authentication. For example, to use Bearer token authentication: | ||
|
||
```ruby | ||
client = MCP::Client::HTTP.new( | ||
url: "https://api.example.com/mcp", | ||
headers: { | ||
"Authorization" => "Bearer my_token" | ||
} | ||
) | ||
|
||
client.tools # will make the call using Bearer auth | ||
``` | ||
|
||
You can add any custom headers needed for your authentication scheme. The client will include these headers in all requests. | ||
|
||
### 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) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
# frozen_string_literal: true | ||
|
||
module MCP | ||
class Client | ||
# 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
+5
to
+18
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. | ||
# | ||
# @return [Array<MCP::Client::Tool>] An array of available tools. | ||
# | ||
# @example | ||
# tools = client.tools | ||
# tools.each do |tool| | ||
# puts tool.name | ||
# end | ||
def tools | ||
@tools ||= transport.tools | ||
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
Comment on lines
+36
to
+53
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. Was torn on this while building. This class is really just a simple wrapper that delegates everything to the transport layer. Maybe that's just coincidental, and eventually these methods will do more that will make the abstraction worth it |
||
transport.call_tool(tool: tool, input: input) | ||
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,118 @@ | ||
# frozen_string_literal: true | ||
|
||
module MCP | ||
class Client | ||
class HTTP | ||
attr_reader :url | ||
|
||
def initialize(url:, headers: {}) | ||
@url = url | ||
@headers = headers | ||
end | ||
|
||
def tools | ||
response = send_request(method: "tools/list").body | ||
|
||
response.dig("result", "tools")&.map do |tool| | ||
Tool.new( | ||
name: tool["name"], | ||
description: tool["description"], | ||
input_schema: tool["inputSchema"], | ||
) | ||
end || [] | ||
end | ||
|
||
def call_tool(tool:, input:) | ||
response = send_request( | ||
method: "tools/call", | ||
params: { name: tool.name, arguments: input }, | ||
).body | ||
|
||
response.dig("result", "content") | ||
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 | ||
|
||
def send_request(method:, params: nil) | ||
client.post( | ||
"", | ||
{ | ||
jsonrpc: "2.0", | ||
id: request_id, | ||
method:, | ||
params:, | ||
mcp: { jsonrpc: "2.0", id: request_id, method:, params: }.compact, | ||
}.compact, | ||
) | ||
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 | ||
|
||
def request_id | ||
SecureRandom.uuid_v7 | ||
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.
❤️