The official Ruby SDK for the Model Context Protocol. Maintained in collaboration with Shopify. Trusted by 600+ developers.
Documentation
MCP Ruby SDK

The official Ruby SDK for Model Context Protocol servers and clients.
Installation
Add this line to your application's Gemfile:
gem 'mcp'And then execute:
$ bundle installOr install it yourself as:
$ gem install mcpYou 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.
Key Features
- Implements JSON-RPC 2.0 message handling
- Supports protocol initialization and capability negotiation
- Manages tool registration and invocation
- Supports prompt registration and execution
- Supports resource registration and retrieval
- Supports stdio & Streamable HTTP (including SSE) transports
- Supports notifications for list changes (tools, prompts, resources)
- Supports roots (server-to-client filesystem boundary queries)
- Supports sampling (server-to-client LLM completion requests)
- Supports cursor-based pagination for list operations
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
Supported Methods
initialize- Initializes the protocol and returns server capabilitiesserver/discover- Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returnssupportedVersions,capabilities,serverInfo,
and instructions, and responds before initialize and without an Mcp-Session-Id
ping- Simple health checklogging/setLevel- Configures the minimum log level for the servertools/list- Lists all registered tools and their schemastools/call- Invokes a specific tool with provided argumentsprompts/list- Lists all registered prompts and their schemasprompts/get- Retrieves a specific prompt by nameresources/list- Lists all registered resources and their schemasresources/read- Retrieves a specific resource by nameresources/templates/list- Lists all registered resource templates and their schemasresources/subscribe- Subscribes to updates for a specific resourceresources/unsubscribe- Unsubscribes from updates for a specific resourcecompletion/complete- Returns autocompletion suggestions for prompt arguments and resource URIsroots/list- Requests filesystem roots from the client (server-to-client)sampling/createMessage- Requests LLM completion from the client (server-to-client)elicitation/create- Requests user input from the client (server-to-client)
Usage
Stdio Transport
If you want to build a local command-line application, you can use the stdio transport:
require "mcp"
# Create a simple tool
class ExampleTool [!IMPORTANT]
> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory,
> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`).
> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that
> do not share memory, which breaks session management and SSE connections.
>
> When running multiple server instances behind a load balancer, configure your load balancer to use
> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always
> routed to the same instance.
>
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
> [!IMPORTANT]
> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to
> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.
> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header,
> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin`
> header are unaffected.
>
> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
>
> ```ruby
> transport = MCP::Server::Transports::StreamableHTTPTransport.new(
> server,
> allowed_hosts: ["mcp.example.com"],
> allowed_origins: ["https://app.example.com"],
> )
> ```
>
> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value,
> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false`
> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`).
##### Rails (mount)
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:config/routes.rb
server = MCP::Server.new(
name: "my_server",
title: "Example Server Display Name",
version: "1.0.0",
instructions: "Use the tools of this server as a last resort",
tools: [SomeTool, AnotherTool],
prompts: [MyPrompt],
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
Rails.application.routes.draw do
mount transport => "/mcp"
end
`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches
`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE),
`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per
the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http),
so no additional route configuration is needed.
A complete runnable application using this approach is available in [`examples/rails`](examples/rails).
##### Rails (controller)
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.
This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route).
`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications):class McpController (exception, server_context) {
# Your exception reporting logic here
# For example with Bugsnag:
Bugsnag.notify(exception) do |report|
report.add_metadata(:model_context_protocol, server_context)
end
}
config.around_request = ->(data, &request_handler) {
logger.info("Start: #{data[:method]}")
request_handler.call
logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}")
}
end
or by creating an explicit configuration and passing it into the server.
This is useful for systems where an application hosts more than one MCP server but
they might require different configurations.configuration = MCP::Configuration.new
configuration.exception_reporter = ->(exception, server_context) {
# Your exception reporting logic here
# For example with Bugsnag:
Bugsnag.notify(exception) do |report|
report.add_metadata(:model_context_protocol, server_context)
end
}
configuration.around_request = ->(data, &request_handler) {
logger.info("Start: #{data[:method]}")
request_handler.call
logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}")
}
server = MCP::Server.new(
# ... all other options
configuration:,
)
### Capability Extensions
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:capabilities = MCP::Server::Capabilities.new
capabilities.support_tools
capabilities.support_extensions("com.example/feature" => { enabled: true })
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
On the client, pass extensions through `connect`:client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
### MCP Apps (SEP-1865)
MCP Apps is a Final extension (negotiated via the Capability Extensions mechanism above) that lets a server ship interactive
HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention,
and `MCP::Apps` provides the vocabulary and helpers:capabilities = MCP::Server::Capabilities.new
capabilities.support_tools
capabilities.support_resources
capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } }
server = MCP::Server.new(
name: "weather_server",
capabilities: capabilities,
# UI templates are ordinary resources with a ui:// URI and the text/html;profile=mcp-app MIME type.
resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")],
)
server.resources_read_handler do |params|
[{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }]
end
Link the tool to its template via _meta.ui.resourceUri (pass legacy: true to also
emit the older flat "ui/resourceUri" alias for hosts that predate the Final spec).
server.define_tool(
name: "get_weather",
meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"),
) do |server_context:|
# The extension is optional: always return a meaningful text result, and use
# MCP::Apps.client_supports? when UI-capable clients should get richer structured content.
MCP::Apps.client_supports?(server.client_capabilities) # => true when the host declared the extension
MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }])
end
Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge, consent for UI-initiated actions)
is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests.
See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx).
### Server Context and Configuration Block Data
#### `server_context`
The `server_context` is a user-defined hash that is passed into the server instance and made available to tool and prompt calls.
It can be used to provide contextual information such as authentication state, user IDs, or request-specific data.
**Type:**server_context: { [String, Symbol] => Any }
**Example:**server = MCP::Server.new(
name: "my_server",
server_context: { user_id: current_user.id, request_id: request.uuid }
)
This hash is then passed as the `server_context` keyword argument to tool and prompt calls.
Note that exception and instrumentation callbacks do not receive this user-defined hash.
See the relevant sections below for the arguments they receive.
#### Request-specific `_meta` Parameter
The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`.
> [!NOTE]
> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized).
> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it
> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`.
**Access Pattern:**
When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`:class MyTool "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }
client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta)
client.read_resource(uri: "file:///report.txt", meta: meta)
#### Configuration Block Data
##### Exception Reporter
The exception reporter receives:
- `exception`: The Ruby exception object that was raised
- `server_context`: A hash describing where the failure occurred (e.g., `{ request: }`
for request handling, `{ notification: "tools_list_changed" }` for notification delivery).
This is not the user-defined `server_context` passed to `Server.new`.
**Signature:**exception_reporter = ->(exception, server_context) { ... }
##### Around Request
The `around_request` hook wraps request handling, allowing you to execute code before and after each request.
This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs.
The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request:
**Signature:**around_request = ->(data, &request_handler) { request_handler.call }
**`data` availability by timing:**
- Before `request_handler.call`: `method`
- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client`
- Not available inside `around_request`: `duration` (added after `around_request` returns)
> [!NOTE]
> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods
> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled
> (for example, `prompt_name` is not recorded when the prompt is not found).
> `duration` is added after `around_request` returns, so it is not visible from within the hook.
**Example:**MCP.configure do |config|
config.around_request = ->(data, &request_handler) {
logger.info("Start: #{data[:method]}")
request_handler.call
logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}")
}
end
##### Instrumentation Callback (soft-deprecated)
> [!NOTE]
> `instrumentation_callback` is soft-deprecated. Use `around_request` instead.
>
> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails:
>
> ```ruby
> # Before
> config.instrumentation_callback = ->(data) { log(data) }
>
> # After
> config.around_request = ->(data, &request_handler) do
> request_handler.call
> ensure
> log(data)
> end
> ```
>
> Note that `data[:duration]` is not available inside `around_request`.
> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`.
The instrumentation callback is called after each request finishes, whether successfully or with an error.
It receives a hash with the following possible keys:
- `method`: (String) The protocol method called (e.g., "ping", "tools/list")
- `tool_name`: (String, optional) The name of the tool called
- `tool_arguments`: (Hash, optional) The arguments passed to the tool
- `prompt_name`: (String, optional) The name of the prompt called
- `resource_uri`: (String, optional) The URI of the resource called
- `error`: (String, optional) Error code if a lookup failed
- `duration`: (Float) Duration of the call in seconds
- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request
**Signature:**instrumentation_callback = ->(data) { ... }
### Server Protocol Version
The server's protocol version can be overridden using the `protocol_version` keyword argument:configuration = MCP::Configuration.new(protocol_version: "2024-11-05")
MCP::Server.new(name: "test_server", configuration: configuration)
If no protocol version is specified, the latest stable version will be applied by default.
The latest stable version includes new features from the [draft version](https://modelcontextprotocol.io/specification/draft).
This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`:MCP::Configuration.new(protocol_version: nil)
If an invalid `protocol_version` value is set, an `ArgumentError` is raised.
Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set.
### Exception Reporting
The exception reporter receives two arguments:
- `exception`: The Ruby exception object that was raised
- `server_context`: A hash containing contextual information about where the error occurred
The `server_context` hash includes:
- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash)
- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name)
When an exception occurs:
1. The exception is reported via the configured reporter
2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", isError: true }`
3. For other requests, the exception is re-raised after reporting
If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions.
### Tools
MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps.
This gem provides a `MCP::Tool` class that can be used to create tools in three ways:
1. As a class definition:class MyTool [!NOTE]
This Tool Annotations feature is supported starting from
protocol_version: '2025-03-26'.
Tool Output Schemas
Tools can optionally define an output_schema to specify the expected structure of their results. This works similarly to how input_schema is defined and can be used in three ways:
1. Class definition with output_schema:
class WeatherTool [^/]+)\z}))
[{
uri: params[:uri],
mimeType: "application/json",
text: { id: match[:item_id] }.to_json,
}]
else
raise MCP::Server::ResourceNotFoundError.new(params[:uri], params)
end
endRoots
The Model Context Protocol allows servers to request filesystem roots from clients through the roots/list method.
Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available.
Key Concepts:
- Server-to-Client Request: Like sampling, roots listing is initiated by the server
- Client Capability: Clients must declare
rootscapability during initialization - Change Notifications: Clients that support
roots.listChangedsendnotifications/roots/list_changedwhen roots change
[!NOTE]
Per SEP-2260, server-to-client requests (
roots/list,sampling/createMessage,elicitation/create) must be associated withan originating client request (
pingis exempt). Use theserver_contextpassed to your handler, which stamps the associationautomatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding
ServerSessionmethods withoutrelated_request_id:still works but emits a deprecation warning.
Using Roots in Tools:
Tools that accept a server_context: parameter can call list_roots on it.
The request is automatically routed to the correct client session:
class FileSearchTool [!NOTE]
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
##### Wire-order guarantees
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
so the server is guaranteed to read the request line before the cancel line.
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
##### Custom transports
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
### Ping
The MCP Ruby SDK supports the
[MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping),
which allows either side of the connection to verify that the peer is still responsive.
A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result.
#### Server-Side
Servers respond to incoming `ping` requests automatically - no setup is required.
Any `MCP::Server` instance replies with an empty result.
Servers can also send `ping` requests to the client via `ServerSession#ping`.
Inside a tool handler that receives `server_context:`, call `ping` on it:class HealthCheckTool {} on success
MCP::Tool::Response.new([{ type: "text", text: "client is alive" }])
end
end
`#ping` raises `MCP::Server::ValidationError` when the client returns a `result`
that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error)
propagate as exceptions raised by the transport layer.
#### Client-Side
`MCP::Client` exposes `ping` to send a ping to the server:client = MCP::Client.new(transport: transport)
client.ping # => {} on success
`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error.
It raises `MCP::Client::ValidationError` when the response `result` is missing or
is not a Hash (matching the spec requirement that `result` be an object).
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
propagate as exceptions raised by the transport layer.
### Progress
The MCP Ruby SDK supports progress tracking for long-running tool operations,
following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress).
#### How Progress Works
1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool
2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution
3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress
#### Server-Side: Tool with Progress
Tools that accept a `server_context:` parameter can call `report_progress` on it.
The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method:class LongRunningTool (request, session_id) { true | false }`
on every non-initialize POST, GET, and DELETE against an existing session (including notification and response POSTs,
so a stolen session ID cannot, for example, POST notifications/cancelled against a victim's request). A falsy return
rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded
when the session was created:
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
session_request_validator: ->(request, session_id) { owns_session?(request, session_id) },
)Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication),
it also records the Origin header at initialize and rejects a later request whose Origin differs, but only
when both are present - a non-browser client that omits Origin (e.g. curl or a script) is not stopped by this check.
Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
Request Size Limits
StreamableHTTPTransport bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory
with one oversized message. A body larger than max_request_bytes (default 4 MiB) is rejected with HTTP 413,
and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON
string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only
if you exchange unusually large payloads:
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024)Pagination
The MCP Ruby SDK supports pagination
for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset,
treated as opaque by clients: the server decides page size, and the client follows nextCursor until the server omits it.
Pagination applies to tools/list, prompts/list, resources/list, and resources/templates/list.
Server-Side: Enabling Pagination
Pass page_size: to MCP::Server.new to split list responses into pages. When page_size is omitted (the default),
list responses contain all items in a single response, preserving the pre-pagination behavior.
server = MCP::Server.new(
name: "my_server",
tools: tools,
page_size: 50,
)When page_size is set, list responses include a nextCursor field whenever more pages are available:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{ "name": "example_tool" }
],
"nextCursor": "50"
}
}Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code -32602 (Invalid params) per the MCP specification.
Client-Side: Iterating Pages
MCP::Client exposes list_tools, list_prompts, list_resources, and list_resource_templates.
**Each call issues exactly one */list JSON-RPC request and returns exactly one page** — not the full collection.
The returned result object (MCP::Client::ListToolsResult etc.) exposes the page items and the next cursor as method accessors:
client = MCP::Client.new(transport: transport)
cursor = nil
loop do
page = client.list_tools(cursor: cursor)
page.tools.each { |tool| process(tool) }
cursor = page.next_cursor
break unless cursor
endThe same pattern applies to list_prompts (page.prompts), list_resources (page.resources), and
list_resource_templates (page.resource_templates). next_cursor is nil on the final page.
Because a single call returns a single page, how many items come back depends on the server's page_size configuration:
Server page_size | client.list_tools(cursor: nil) |
|---|---|
| Not set (default) | Returns every item in one response. next_cursor is nil. |
Set to N | Returns the first N items. next_cursor is set for continuation. |
If your application needs the complete collection regardless of how the server is configured, either loop on
next_cursor as shown above, or use the whole-collection methods described below.
Fetching the Complete Collection
client.tools, client.resources, client.resource_templates, and client.prompts auto-iterate
through all pages and return a plain array of items, guaranteeing the full collection regardless
of the server's page_size setting. When a server paginates, they issue multiple JSON-RPC round
trips per call and break out of the pagination loop if the server returns the same nextCursor
twice in a row as a safety measure.
tools = client.tools # => Array of every tool on the server.Use these when you want the complete list; use list_tools(cursor:) etc. when you need
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
List Result Caching (ttlMs / cacheScope)
Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (ttlMs, max-age semantics in milliseconds;
0 means do not cache) and whether shared intermediaries may cache it (cacheScope: "public" or "private").
Emission is opt-in: pass ttl_ms: and/or cache_scope: to MCP::Server.new and both fields are added to tools/list, prompts/list, resources/list,
resources/templates/list, and resources/read results (a missing field is filled with the defaults ttlMs: 0 / cacheScope: "public").
When neither is set, responses are serialized exactly as before.
server = MCP::Server.new(
name: "my_server",
tools: tools,
ttl_ms: 60_000, # results stay fresh for one minute
cache_scope: "private", # only the requesting client may cache them
)A resources_read_handler can override the hints per result by returning a full result hash instead of bare contents:
server.resources_read_handler do |params|
{ contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 }
endOn the client, the values are surfaced on the paginated result structs as ttl_ms and cache_scope:
page = client.list_tools
page.ttl_ms # => 60000 (nil when the server sent no hint)
page.cache_scope # => "private"Advanced
Custom Methods
The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the define_custom_method method:
server = MCP::Server.new(name: "my_server")
# Define a custom method that returns a result
server.define_custom_method(method_name: "add") do |params|
params[:a] + params[:b]
end
# Define a custom notification method (returns nil)
server.define_custom_method(method_name: "notify") do |params|
# Process notification
nil
endKey Features:
- Accepts any method name as a string
- Block receives the request parameters as a hash
- Can handle both regular methods (with responses) and notifications
- Prevents overriding existing MCP protocol methods
- Supports instrumentation callbacks for monitoring
Usage Example:
# Client request
{
"jsonrpc": "2.0",
"id": 1,
"method": "add",
"params": { "a": 5, "b": 3 }
}
# Server response
{
"jsonrpc": "2.0",
"id": 1,
"result": 8
}Error Handling:
- Raises
MCP::Server::MethodAlreadyDefinedErrorif trying to override an existing method - Supports the same exception reporting and instrumentation as standard methods
Building an MCP Client
The MCP::Client class provides an interface for interacting with MCP servers.
This class supports:
- Liveness check via the
pingmethod (MCP::Client#ping) - Tool listing via the
tools/listmethod (MCP::Client#tools) - Tool invocation via the
tools/callmethod (MCP::Client#call_tool) - Resource listing via the
resources/listmethod (MCP::Client#resources) - Resource template listing via the
resources/templates/listmethod (MCP::Client#resource_templates) - Resource reading via the
resources/readmethod (MCP::Client#read_resource) - Prompt listing via the
prompts/listmethod (MCP::Client#prompts) - Prompt retrieval via the
prompts/getmethod (MCP::Client#get_prompt) - Completion requests via the
completion/completemethod (MCP::Client#complete) - Automatic JSON-RPC 2.0 message formatting
- UUID request ID generation
Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
Authorization is handled by the transport layer.
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:
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
endStdio Transport Layer
Use the MCP::Client::Stdio transport to interact with MCP servers running as subprocesses over standard input/output.
MCP::Client::Stdio.new accepts the following keyword arguments:
| Parameter | Required | Description |
|---|---|---|
command: | Yes | The command to spawn the server process (e.g., "ruby", "bundle", "npx"). |
args: | No | An array of arguments passed to the command. Defaults to []. |
env: | No | A hash of environment variables to set for the server process. Defaults to nil. |
read_timeout: | No | Timeout in seconds for waiting for a server response. Defaults to nil (no timeout). |
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). |
Example usage:
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)
# Perform the MCP initialization handshake before sending any requests.
client.connect
# List available tools.
tools = client.tools
tools.each do |tool|
puts "Tool: #{tool.name} - #{tool.description}"
end
# Call a specific tool.
response = client.call_tool(
tool: tools.first,
arguments: { message: "Hello, world!" }
)
# Close the transport when done.
stdio_transport.closeThe stdio transport automatically handles:
- Spawning the server process with
Open3.popen3 - MCP protocol initialization handshake (
initializerequest +notifications/initialized) - JSON-RPC 2.0 message framing over newline-delimited JSON
HTTP Transport Layer
Use the MCP::Client::HTTP transport to interact with MCP servers using simple HTTP requests.
You'll need to add faraday as a dependency in order to use the HTTP transport layer. Add event_stream_parser as well 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 responsesExample usage:
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
client = MCP::Client.new(transport: http_transport)
# Perform the MCP initialization handshake before sending any requests.
client.connect
# List available tools
tools = client.tools
tools.each do |tool|
puts MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
> new servers should call LLM provider APIs directly.
Register a handler and advertise the capability on `connect`:client.connect(capabilities: { sampling: {} })
client.on_sampling do |params|
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
{
role: "assistant",
content: { type: "text", text: completion.text },
model: completion.model,
stopReason: "endTurn",
}
end
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:client.on_sampling do |params|
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
generate_completion(params)
end
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
#### 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: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.
#### OAuth 2.1 Authorization
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization),
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
- Send `Authorization: Bearer ` on every request when a token is available.
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates),
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.require "mcp"
provider = MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "My MCP App",
redirect_uris: ["http://localhost:3030/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:3030/callback",
redirect_handler: ->(authorization_url) {
# Send the user to the authorization URL - typically Launchy.open(authorization_url)
# or a manual puts authorization_url in CLI tools.
},
callback_handler: -> {
# Capture the redirect (for example, by running a small HTTP listener on
# redirect_uri) and return [code, state] from the query string.
},
)
transport = MCP::Client::HTTP.new(
url: "https://api.example.com/mcp",
oauth: provider,
)
client = MCP::Client.new(transport: transport)
client.connect # initialize is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
client.tools
Required keyword arguments to `Provider.new`:
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
an explicit value always wins.
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form
(with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
When the authorization server advertises `client_id_metadata_document_supported: true`,
the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
To persist credentials across restarts, supply your own storage:class FileTokenStorage
def initialize(path)
@path = path
end
def tokens
read["tokens"]
end
def save_tokens(value)
write("tokens" => value)
end
def client_information
read["client"]
end
def save_client_information(value)
write("client" => value)
end
private
def read
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
end
def write(updates)
File.write(@path, JSON.dump(read.merge(updates)))
end
end
provider = MCP::Client::OAuth::Provider.new(
# ... required keywords ...
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
)
##### Client Credentials Grant
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post"
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
Keyword arguments:
- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
- `scope`, `storage`: Optional, same meaning as on `Provider`.
##### Cross-App Access (JWT Bearer) Grant
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
an enterprise secret store or a test double without changing the transport wiring.provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
client_id: "my-mcp-client",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
assertion_provider: ->(audience:, resource:) {
MCP::Client::OAuth::IDJAGTokenExchange.request(
token_endpoint: "https://idp.example.com/token",
id_token: ENV.fetch("IDP_ID_TOKEN"),
client_id: "my-idp-client",
audience: audience,
resource: resource,
)
},
# scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
Keyword arguments:
- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server.
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`: Optional, same meaning as on `Provider`.
##### Communication Security
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
whether the swap was triggered by
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
#### Customizing the Faraday Connection
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.
The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter:http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
faraday.use MyApp::Middleware::HttpRecorder
faraday.adapter :typhoeus
end
### 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 provides easy access to tool properties like name, description, input schema, and output schema.
### Multi-Round-Trip Results (Experimental, SEP-2322)
The MCP 2026-07-28 draft replaces in-flight server-to-client requests with Multi Round-Trip Requests: instead of issuing `sampling/createMessage`, `roots/list`,
or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
The Ruby client recognizes such results and raises `MCP::Client::InputRequiredError` instead of returning them as if they were final. The error exposes `input_requests`, `request_state`,
and the raw `result`; automatic resumption is not implemented yet, so callers respond manually if they opt into the draft flow. `MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED`
are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged.
## Conformance Testing
The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance).
See [conformance/README.md](conformance/README.md) for usage instructions.
## Documentation
- [SDK API documentation](https://rubydoc.info/gems/mcp)
- [Model Context Protocol documentation](https://modelcontextprotocol.io)Similar MCP
Based on tags & features
Trending MCP
Most active this week