## Motivation and Context CRuby's `IO#gets` without a limit reads until the next newline with no upper bound. A peer that never emits a newline can therefore grow a single String until the process is killed by the OS OOM killer. This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs. It is most relevant when a host application spawns a sandboxed or untrusted MCP server and pipes its stdout into an unconstrained host process, where a memory-capped peer can exhaust the host. Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB, large enough for any realistic frame including base64-embedded images, and the value recommended in the advisory). A frame that reaches the limit without a newline is treated as a transport error: the client raises, and the server reports the error and closes the connection. A short final frame that ends at EOF without a trailing newline is still accepted, so legitimate traffic is unaffected. Both constructors validate `max_line_bytes:` as a positive Integer, since `nil` or a non-positive value makes `IO#gets` read without an upper bound and would silently disable the protection. ## How Has This Been Tested? Server tests cover the `open` read loop stopping and reporting on an over-limit frame, the `send_request` read loop raising on an over-limit response, and a valid final frame without a trailing newline still being processed. Client tests cover `read_response` raising on an over-limit response frame and a normal response still round-tripping within a custom `max_line_bytes`. Both transports also have tests rejecting invalid `max_line_bytes:` values (`nil`, zero, negative, non-Integer) with an `ArgumentError`. The full test suite and RuboCop pass. ## Breaking Changes None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame, so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword on both transport constructors is purely additive and optional.
153 lines
5.6 KiB
Markdown
153 lines
5.6 KiB
Markdown
---
|
|
layout: default
|
|
title: Building Clients
|
|
nav_order: 4
|
|
---
|
|
|
|
# Building an MCP Client
|
|
|
|
The `MCP::Client` class provides an interface for interacting with MCP servers.
|
|
|
|
**Supported operations:**
|
|
|
|
- Tool listing (`MCP::Client#tools`) and invocation (`MCP::Client#call_tool`)
|
|
- Resource listing (`MCP::Client#resources`) and reading (`MCP::Client#read_resource`)
|
|
- Resource template listing (`MCP::Client#resource_templates`)
|
|
- Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`)
|
|
- Completion requests (`MCP::Client#complete`)
|
|
|
|
## Handshake
|
|
|
|
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
|
|
|
```ruby
|
|
client.connect
|
|
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
|
|
|
client.connected? # => true
|
|
client.server_info # => cached InitializeResult
|
|
```
|
|
|
|
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
|
|
|
This applies to both the Stdio and HTTP transports below.
|
|
|
|
## Stdio Transport
|
|
|
|
Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses:
|
|
|
|
```ruby
|
|
stdio_transport = MCP::Client::Stdio.new(
|
|
command: "bundle",
|
|
args: ["exec", "ruby", "path/to/server.rb"],
|
|
env: { "API_KEY" => "my_secret_key" },
|
|
read_timeout: 30
|
|
)
|
|
client = MCP::Client.new(transport: stdio_transport)
|
|
client.connect
|
|
|
|
tools = client.tools
|
|
tools.each do |tool|
|
|
puts "Tool: #{tool.name} - #{tool.description}"
|
|
end
|
|
|
|
response = client.call_tool(
|
|
tool: tools.first,
|
|
arguments: { message: "Hello, world!" }
|
|
)
|
|
|
|
stdio_transport.close
|
|
```
|
|
|
|
| Parameter | Required | Description |
|
|
|---|---|---|
|
|
| `command:` | Yes | The command to spawn the server process. |
|
|
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
|
| `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. |
|
|
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. |
|
|
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
|
|
|
|
## HTTP Transport
|
|
|
|
Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem, plus `event_stream_parser` if the server uses SSE (`text/event-stream`) responses:
|
|
|
|
```ruby
|
|
gem 'mcp'
|
|
gem 'faraday', '>= 2.0'
|
|
gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
|
|
```
|
|
|
|
```ruby
|
|
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
|
|
client = MCP::Client.new(transport: http_transport)
|
|
client.connect
|
|
|
|
tools = client.tools
|
|
tools.each do |tool|
|
|
puts "Tool: #{tool.name} - #{tool.description}"
|
|
end
|
|
|
|
response = client.call_tool(
|
|
tool: tools.first,
|
|
arguments: { message: "Hello, world!" }
|
|
)
|
|
```
|
|
|
|
### Sessions
|
|
|
|
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
|
|
|
|
```ruby
|
|
http_transport.session_id # => "abc123..."
|
|
http_transport.protocol_version # => "2025-11-25"
|
|
```
|
|
|
|
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
|
|
|
|
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
|
|
|
|
```ruby
|
|
http_transport.close
|
|
```
|
|
|
|
### Authorization
|
|
|
|
Provide custom headers for 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)
|
|
```
|
|
|
|
### Customizing the Faraday Connection
|
|
|
|
Pass a block to customize the underlying Faraday connection:
|
|
|
|
```ruby
|
|
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
|
|
faraday.use MyApp::Middleware::HttpRecorder
|
|
faraday.adapter :typhoeus
|
|
end
|
|
```
|
|
|
|
## Custom Transport
|
|
|
|
If the built-in transports do not fit your needs, you can implement your own:
|
|
|
|
```ruby
|
|
class CustomTransport
|
|
def send_request(request:)
|
|
# Your transport-specific logic here.
|
|
# Returns a Hash modeling a JSON-RPC response object.
|
|
end
|
|
end
|
|
|
|
client = MCP::Client.new(transport: CustomTransport.new)
|
|
```
|
|
|
|
For more details, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-client).
|