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 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ gem "rubocop-shopify", require: false
gem "minitest-reporters"
gem "mocha"
gem "debug"

group :test do
gem "webmock"
end
63 changes: 61 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ If you want to build a local command-line application, you can use the stdio tra
```ruby
#!/usr/bin/env ruby
require "model_context_protocol"
require "model_context_protocol/transports/stdio"
require "model_context_protocol/server/transports/stdio"

# Create a simple tool
class ExampleTool < ModelContextProtocol::Tool
Expand Down Expand Up @@ -97,7 +97,7 @@ server = ModelContextProtocol::Server.new(
)

# Create and start the transport
transport = ModelContextProtocol::Transports::StdioTransport.new(server)
transport = ModelContextProtocol::Server::Transports::StdioTransport.new(server)
transport.open
```

Expand All @@ -110,6 +110,65 @@ $ ./stdio_server.rb
{"jsonrpc":"2.0","id":"3","result":["ExampleTool"]}
```

## MCP Client

The `ModelContextProtocol::Client` module provides client implementations for interacting with MCP servers. Currently, it supports HTTP transport for making JSON-RPC requests to MCP servers.

### HTTP Client

The `ModelContextProtocol::Client::Http` class provides a simple HTTP client for interacting with MCP servers:

```ruby
client = ModelContextProtocol::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 = ModelContextProtocol::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 wrapper objects for tools returned by the server:

- `ModelContextProtocol::Client::Tool` - Represents a single tool with its metadata
- `ModelContextProtocol::Client::Tools` - Collection of tools with enumerable functionality

These objects provide easy access to tool properties like name, description, and input schema.

## Configuration

The gem can be configured using the `ModelContextProtocol.configure` block:
Expand Down
4 changes: 2 additions & 2 deletions examples/stdio_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "model_context_protocol"
require "model_context_protocol/transports/stdio"
require "model_context_protocol/server/transports/stdio"

# Create a simple tool
class ExampleTool < MCP::Tool
Expand Down Expand Up @@ -91,5 +91,5 @@ def template(args, server_context:)
end

# Create and start the transport
transport = MCP::Transports::StdioTransport.new(server)
transport = ModelContextProtocol::Server::Transports::StdioTransport.new(server)
transport.open
45 changes: 29 additions & 16 deletions lib/model_context_protocol.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
# typed: strict
# frozen_string_literal: true

require_relative "model_context_protocol/shared/version"
require_relative "model_context_protocol/shared/configuration"
require_relative "model_context_protocol/shared/instrumentation"
require_relative "model_context_protocol/shared/methods"
require_relative "model_context_protocol/shared/transport"
require_relative "model_context_protocol/shared/content"
require_relative "model_context_protocol/shared/string_utils"

require_relative "model_context_protocol/shared/resource"
require_relative "model_context_protocol/shared/resource/contents"
require_relative "model_context_protocol/shared/resource/embedded"
require_relative "model_context_protocol/shared/resource_template"

require_relative "model_context_protocol/shared/tool"
require_relative "model_context_protocol/shared/tool/input_schema"
require_relative "model_context_protocol/shared/tool/response"
require_relative "model_context_protocol/shared/tool/annotations"

require_relative "model_context_protocol/shared/prompt"
require_relative "model_context_protocol/shared/prompt/argument"
require_relative "model_context_protocol/shared/prompt/message"
require_relative "model_context_protocol/shared/prompt/result"

require_relative "model_context_protocol/server"
require_relative "model_context_protocol/string_utils"
require_relative "model_context_protocol/tool"
require_relative "model_context_protocol/tool/input_schema"
require_relative "model_context_protocol/tool/annotations"
require_relative "model_context_protocol/tool/response"
require_relative "model_context_protocol/content"
require_relative "model_context_protocol/resource"
require_relative "model_context_protocol/resource/contents"
require_relative "model_context_protocol/resource/embedded"
require_relative "model_context_protocol/resource_template"
require_relative "model_context_protocol/prompt"
require_relative "model_context_protocol/prompt/argument"
require_relative "model_context_protocol/prompt/message"
require_relative "model_context_protocol/prompt/result"
require_relative "model_context_protocol/version"
require_relative "model_context_protocol/configuration"
require_relative "model_context_protocol/server/transports/stdio"

require_relative "model_context_protocol/client"
require_relative "model_context_protocol/client/http"
require_relative "model_context_protocol/client/tools"
require_relative "model_context_protocol/client/tool"

module ModelContextProtocol
class << self
Expand Down
22 changes: 22 additions & 0 deletions lib/model_context_protocol/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# require "json_rpc_handler"
# require_relative "shared/instrumentation"
# require_relative "shared/methods"

module ModelContextProtocol
module Client
# Can be made an abstract class if we need shared behavior

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
107 changes: 107 additions & 0 deletions lib/model_context_protocol/client/http.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

module ModelContextProtocol
module Client
class Http
DEFAULT_VERSION = "0.1.0"

attr_reader :url, :version

def initialize(url:, version: DEFAULT_VERSION, headers: {})
@url = url
@version = version
@headers = headers
end

def tools
response = make_request(method: "tools/list").body

::ModelContextProtocol::Client::Tools.new(response)
end

def call_tool(tool:, input:)
response = make_request(
method: "tools/call",
params: { name: tool.name, arguments: input },
).body

response.dig("result", "content", 0, "text")
end

private

attr_reader :headers

def client
@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 make_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
26 changes: 26 additions & 0 deletions lib/model_context_protocol/client/tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# typed: false
# frozen_string_literal: true

module ModelContextProtocol
module Client
class Tool
attr_reader :payload

def initialize(payload)
@payload = payload
end

def name
payload["name"]
end

def description
payload["description"]
end

def input_schema
payload["inputSchema"]
end
end
end
end
30 changes: 30 additions & 0 deletions lib/model_context_protocol/client/tools.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# typed: false
# frozen_string_literal: true

module ModelContextProtocol
module Client
class Tools
include Enumerable

attr_reader :response

def initialize(response)
@response = response
end

def each(&block)
tools.each(&block)
end

def all
tools
end

private

def tools
@tools ||= @response.dig("result", "tools")&.map { |tool| Tool.new(tool) } || []
end
end
end
end
4 changes: 2 additions & 2 deletions lib/model_context_protocol/server.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true

require "json_rpc_handler"
require_relative "instrumentation"
require_relative "methods"
require_relative "shared/instrumentation"
require_relative "shared/methods"

module ModelContextProtocol
class Server
Expand Down
Loading