## 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.
5.6 KiB
| layout | title | nav_order |
|---|---|---|
| default | Building Clients | 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 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):
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:
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:
gem 'mcp'
gem 'faraday', '>= 2.0'
gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
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:
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.
http_transport.close
Authorization
Provide custom headers for authentication:
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:
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:
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.