The official Ruby SDK for Model Context Protocol servers and clients.
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.
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.
initialize - Initializes the protocol and returns server capabilitiesserver/discover - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the modern supportedVersions,capabilities, instructions, the required ttlMs/cacheScope cache hints, and the server identity as the optionalio.modelcontextprotocol/serverInfo stamp in the result _meta, and responds before initializeMcp-Session-Id. The server also serves the full stateless modern lifecycle: requests carrying the SEP-2575 _meta envelopeio.modelcontextprotocol/protocolVersion, clientInfo, and clientCapabilities) are validated per request,MCP::Client#connect negotiatesserver/discover, fall back to the initialize handshake), connect(mode: :modern) skipsconnect(mode: :legacy) forces the classic handshake, and MCP::Client#discover exposes the raw discovery resultsubscriptions/listen - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET listening stream:notifications filter (toolsListChanged / promptsListChanged / resourcesListChanged / resourceSubscriptions),notifications/subscriptions/acknowledged as the first stream message,io.modelcontextprotocol/subscriptionId in _meta. Served on the Streamable HTTP modern path;-32601. Concurrent streams are capped by max_listen_subscriptions: (default 1000), and each stream receives an SSE keepalivelisten_keepalive_interval: seconds (default 15) so a dropped connection frees its slot; pass listen_keepalive_interval: nilinput_required results (MCP 2026-07-28, SEP-2322): a tools/call, prompts/get, or resources/read handler thatserver_context: may return MCP::Server::InputRequiredResult.new(input_requests:, request_state:) to ask the client forelicitation/create, sampling/createMessage, or roots/list shapes) instead of performing a server-initiated request,server_context.input_responses / server_context.input_response(key) and the echoed opaque server_context.request_state-32021requestState arrives asMCP::Server::RequestStateSecurity.new(key:) (a 32-byte key) via Server.new(request_state_security:) torequest_state_security: the state crosses the wire exactly ason_elicitation / on_sampling / on_roots - the same registrations that answer a real server-to-client request - andconnect (a server embeds only the request kinds the client declared);call_tool / get_prompt / read_resource then resume input_required results automatically: each embedded request is fulfilled byinputResponses plus the echoed requestStaterequestState-only load-shedding legs). Without a matching handler they raise MCP::Client::InputRequiredError,input_responses: / request_state: keyword arguments support manual drivingping - 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)If you want to build a local command-line application, you can use the stdio transport:
require "mcp"
# Create a simple tool
class ExampleTool < MCP::Tool
description "A simple example tool that echoes back its arguments"
input_schema(
properties: {
message: { type: "string" },
},
required: ["message"]
)
class << self
def call(message:, server_context:)
MCP::Tool::Response.new([{
type: "text",
text: "Hello from example tool! Message: #{message}",
}])
end
end
end
# Set up the server
server = MCP::Server.new(
name: "example_server",
tools: [ExampleTool],
)
# Create and start the transport
transport = MCP::Server::Transports::StdioTransport.new(server)
transport.openStdioTransport.new accepts an optional max_line_bytes: keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to 4 * 1024 * 1024 (4 MiB).
You can run this script and then type in requests to the server at the command line.
$ ruby examples/stdio_server.rb
{"jsonrpc":"2.0","id":"1","method":"ping"}
{"jsonrpc":"2.0","id":"2","method":"tools/list"}
{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}}MCP::Server::Transports::StreamableHTTPTransport is a standard Rack app, so it can be mounted in any Rack-compatible framework.
The following examples show two common integration styles in Rails.
[!IMPORTANT]
MCP::Server::Transports::StreamableHTTPTransportstores session and SSE stream state in memory,
so it must run in a single process. Use a single-process server (e.g., Puma withworkers 0).
Multi-process configurations (Unicorn, or Puma withworkers > 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 sameMcp-Session-Idheader 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,StreamableHTTPTransportvalidates theHostandOriginheaders by default to
prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.Hostis allowed for the loopback defaults (127.0.0.1,::1,localhost), and anOriginheader,
when present, must be same-origin or explicitly allow-listed. Non-browser clients that send noOrigin
header are unaffected.Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
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 fullhost:portvalue,
so both"mcp.example.com"and"mcp.example.com:8443"work. Passdns_rebinding_protection: false
to disable the check entirely (e.g., when an upstream proxy or middleware already validatesHost/Origin).
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"
endmount directs all HTTP methods on /mcp to the transport. StreamableHTTPTransport internally dispatchesPOST (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,
so no additional route configuration is needed.
A complete runnable application using this approach is available in examples/rails.
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 < ActionController::API
def create
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],
server_context: { user_id: current_user.id },
)
# Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set.
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
status, headers, body = transport.handle_request(request)
render(json: body.first, status: status, headers: headers)
end
endThe gem can be configured using the MCP.configure block:
MCP.configure do |config|
config.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
}
config.around_request = ->(data, &request_handler) {
logger.info("Start: #{data[:method]}")
request_handler.call
logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}")
}
endor 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:,
)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 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: "<html>...</html>" }]
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" }])
endEverything 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.
server_contextThe 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 the exception reporter does not receive this user-defined hash, and instrumentation
callbacks omit it unless you opt in with instrument_server_context.
See the relevant sections below for the arguments they receive.
_meta ParameterThe MCP protocol supports a special _meta parameter 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]
_metais only merged whenserver_contextis aHash(ornil, in which case a new{ _meta: ... }hash is synthesized).
If you assign a non-Hashvalue toserver_context,_metais not merged and tools will not see it
underserver_context[:_meta]. Keepserver_contextas aHashif 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 < MCP::Tool
def self.call(message:, server_context:)
# Access provider-specific metadata
session_id = server_context.dig(:_meta, :session_id)
request_id = server_context.dig(:_meta, :request_id)
# Access server's original context
user_id = server_context.dig(:user_id)
MCP::Tool::Response.new([{
type: "text",
text: "Processing for user #{user_id} in session #{session_id}"
}])
end
endClient Request Example:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "my_tool",
"arguments": { "message": "Hello" },
"_meta": {
"session_id": "abc123",
"request_id": "req_456"
}
}
}Distributed Tracing (W3C Trace Context):
Per SEP-414, the keys traceparent, tracestate, and baggage are reserved un-prefixed _meta keys for propagating
W3C Trace Context across MCP requests. The SDK guarantees these keys pass through
incoming request _meta untouched, and exposes their names as constants on MCP::TraceContext (TRACEPARENT_META_KEY,TRACESTATE_META_KEY, BAGGAGE_META_KEY, and META_KEYS). The SDK does not depend on OpenTelemetry; bridge the values
to your tracing system yourself:
class TracedTool < MCP::Tool
def self.call(message:, server_context:)
traceparent = server_context.dig(:_meta, :traceparent)
# Hand traceparent/tracestate/baggage to your tracing library
# (e.g. the opentelemetry-ruby gems) to continue the caller's trace.
MCP::Tool::Response.new([{ type: "text", text: "ok" }])
end
endOn the client side, every request method (call_tool, read_resource, get_prompt, complete, ping, and the list_* methods)
accepts a meta: keyword to inject these keys into the outgoing request, so trace context can flow on every request:
meta = { MCP::TraceContext::TRACEPARENT_META_KEY => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" }
client.call_tool(tool: tool, arguments: { message: "Hello" }, meta: meta)
client.read_resource(uri: "file:///report.txt", meta: meta)The exception reporter receives:
exception: The Ruby exception object that was raisedserver_context: A hash describing where the failure occurred (e.g., { request: <raw JSON-RPC request> }{ notification: "tools_list_changed" } for notification delivery).server_context passed to Server.new.Signature:
exception_reporter = ->(exception, server_context) { ... }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:
request_handler.call: method, and server_context when instrument_server_context is enabledrequest_handler.call: tool_name, tool_arguments, prompt_name, resource_uri, error, clientaround_request: duration (added after around_request returns)Exposing the user-defined server_context (opt in):
data omits the user-defined server_context by default, because that hash is
application-supplied and may hold values a tracing backend should not receive.
Enable it when you need to tag spans with the request's subject:
MCP.configure do |config|
config.instrument_server_context = true
config.around_request = ->(data, &request_handler) {
Sentry.set_user(id: data.dig(:server_context, :user_id))
request_handler.call
}
enddata[:server_context] is the hash passed to Server.new — nil when the host
set none. It is not the exception reporter's context argument, which describes
where a failure occurred rather than who made the request.
[!NOTE]
tool_name,prompt_nameandresource_urimay 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_nameis not recorded when the prompt is not found).durationis added afteraround_requestreturns, 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[!NOTE]
instrumentation_callbackis soft-deprecated. Usearound_requestinstead.To migrate, wrap the call in
begin/ensureso the callback still runs when the request fails:# Before config.instrumentation_callback = ->(data) { log(data) } # After config.around_request = ->(data, &request_handler) do request_handler.call ensure log(data) endNote that
data[:duration]is not available insidearound_request.
If you need it, measure elapsed time yourself within the hook, or keep usinginstrumentation_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 calledtool_arguments: (Hash, optional) The arguments passed to the toolprompt_name: (String, optional) The name of the prompt calledresource_uri: (String, optional) The URI of the resource callederror: (String, optional) Error code if a lookup failedduration: (Float) Duration of the call in secondsclient: (Hash, optional) Client information with name and version keys, from the initialize requestserver_context: (Any, optional) The user-defined hash passed to Server.new, present only wheninstrument_server_context is enabledSignature:
instrumentation_callback = ->(data) { ... }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 handshake version (2025-11-25) is applied by default.
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.
The pin scopes the initialize handshake, so it accepts handshake versions (2025-11-25 and earlier) only. Per the SEP-2575 era model,2026-07-28 carries its version on every request and has no handshake at all, so there is nothing for a pin to configure there and setting it raises ArgumentError;
a client asking initialize for a modern version is counter-offered the pinned version (or the latest handshake version), matching the TypeScript and Python SDKs.
Clients reach 2026-07-28 through server/discover and the per-request _meta envelope, which the bundled transports serve alongside the handshake with no configuration needed.
Be sure to check the MCP spec for the protocol version to understand the supported features for the version being set.
The exception reporter receives two arguments:
exception: The Ruby exception object that was raisedserver_context: A hash containing contextual information about where the error occurredThe server_context hash includes:
{ request: { ... } } (the raw JSON-RPC request hash){ notification: "tools_list_changed" } (or the relevant notification name)When an exception occurs:
{ error: "Internal error occurred", isError: true }If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions.
MCP spec includes Tools which provide functionality to LLM apps.
This gem provides a MCP::Tool class that can be used to create tools in three ways:
class MyTool < MCP::Tool
title "My Tool"
description "This tool performs specific functionality..."
input_schema(
properties: {
message: { type: "string" },
},
required: ["message"]
)
output_schema(
properties: {
result: { type: "string" },
success: { type: "boolean" },
timestamp: { type: "string", format: "date-time" }
},
required: ["result", "success", "timestamp"]
)
annotations(
read_only_hint: true,
destructive_hint: false,
idempotent_hint: true,
open_world_hint: false,
title: "My Tool"
)
def self.call(message:, server_context:)
MCP::Tool::Response.new([{ type: "text", text: "OK" }])
end
end
tool = MyToolMCP::Tool.define method with a block:tool = MCP::Tool.define(
name: "my_tool",
title: "My Tool",
description: "This tool performs specific functionality...",
annotations: {
read_only_hint: true,
title: "My Tool"
}
) do |args, server_context:|
MCP::Tool::Response.new([{ type: "text", text: "OK" }])
endMCP::Server#define_tool method with a block:server = MCP::Server.new
server.define_tool(
name: "my_tool",
description: "This tool performs specific functionality...",
annotations: {
title: "My Tool",
read_only_hint: true
}
) do |args, server_context:|
Tool::Response.new([{ type: "text", text: "OK" }])
endThe server_context parameter is the server_context passed into the server and can be used to pass per request information,
e.g. around authentication state.
Tool arguments arrive as a Hash with symbol keys at every nesting level, because the transports parse JSON with symbolize_names: true.
Read nested objects with symbol keys (payload[:subject], not payload["subject"]).
See Tool argument keys for details and a testing tip.
Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:
destructive_hint: Indicates if the tool performs destructive operations. Defaults to trueidempotent_hint: Indicates if the tool's operations are idempotent. Defaults to falseopen_world_hint: Indicates if the tool operates in an open world context. Defaults to trueread_only_hint: Indicates if the tool only reads data (doesn't modify state). Defaults to falsetitle: A human-readable title for the toolAnnotations can be set either through the class definition using the annotations class method or when defining a tool using the define method.
[!NOTE]
This Tool Annotations feature is supported starting fromprotocol_version: '2025-03-26'.
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:
class WeatherTool < MCP::Tool
tool_name "get_weather"
description "Get current weather for a location"
input_schema(
properties: {
location: { type: "string" },
units: { type: "string", enum: ["celsius", "fahrenheit"] }
},
required: ["location"]
)
output_schema(
properties: {
temperature: { type: "number" },
condition: { type: "string" },
humidity: { type: "integer" }
},
required: ["temperature", "condition", "humidity"]
)
def self.call(location:, units: "celsius", server_context:)
# Call weather API and structure the response
api_response = WeatherAPI.fetch(location, units)
weather_data = {
temperature: api_response.temp,
condition: api_response.description,
humidity: api_response.humidity_percent
}
output_schema.validate_result(weather_data)
MCP::Tool::Response.new([{
type: "text",
text: weather_data.to_json
}])
end
endtool = MCP::Tool.define(
name: "calculate_stats",
description: "Calculate statistics for a dataset",
input_schema: {
properties: {
numbers: { type: "array", items: { type: "number" } }
},
required: ["numbers"]
},
output_schema: {
properties: {
mean: { type: "number" },
median: { type: "number" },
count: { type: "integer" }
},
required: ["mean", "median", "count"]
}
) do |args, server_context:|
# Calculate statistics and validate against schema
MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }])
endclass DataTool < MCP::Tool
output_schema MCP::Tool::OutputSchema.new(
properties: {
success: { type: "boolean" },
data: { type: "object" }
},
required: ["success"]
)
endOutput schema may also describe an array of objects:
class WeatherTool < MCP::Tool
output_schema(
type: "array",
items: {
properties: {
temperature: { type: "number" },
condition: { type: "string" },
humidity: { type: "integer" }
},
required: ["temperature", "condition", "humidity"]
}
)
endPlease note: in this case, you must provide type: "array". The default type for output schemas is object,
applied only when the schema declares no root keyword (type, $ref, oneOf, anyOf, allOf, not, if, const, enum).
Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root
({ type: "string" }) or a root-level composition:
class FlexibleTool < MCP::Tool
output_schema(
oneOf: [
{ type: "string" },
{ type: "array", items: { type: "number" } }
]
)
endInput schemas keep type: "object" at the root but accept the full 2020-12 vocabulary below it
($defs/$ref, oneOf/anyOf/allOf/not, if/then/else). Two resource bounds apply to
all tool schemas: only same-document $refs (starting with #) are accepted, and documents are
capped at MCP::Tool::Schema::MAX_SCHEMA_DEPTH nesting levels and MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT subschema objects;
violations raise ArgumentError at construction time.
MCP spec for the Output Schema specifies that:
The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.
By default, server-side validation of tool results against output_schema is disabled for backwards compatibility. To validate successful tool responses, enable validate_tool_call_results:
configuration = MCP::Configuration.new(validate_tool_call_results: true)
server = MCP::Server.new(
name: "example_server",
tools: [WeatherTool],
configuration: configuration
)When enabled, successful tool responses for tools with an output_schema must include structured_content that conforms to the schema. Error responses are not validated against the output schema.
Tools can return structured data alongside text content using the structured_content parameter.
The structured content will be included in the JSON-RPC response as the structuredContent field.
Per SEP-2106, structured_content may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array)
without providing any content blocks, the server automatically mirrors it into content as serialized JSON text so older clients
that only read content still receive the data.
class WeatherTool < MCP::Tool
description "Get current weather and return structured data"
def self.call(location:, units: "celsius", server_context:)
# Call weather API and structure the response
api_response = WeatherAPI.fetch(location, units)
weather_data = {
temperature: api_response.temp,
condition: api_response.description,
humidity: api_response.humidity_percent
}
output_schema.validate_result(weather_data)
MCP::Tool::Response.new(
[{
type: "text",
text: weather_data.to_json
}],
structured_content: weather_data
)
end
endTools can return error information alongside text content using the error parameter.
The error will be included in the JSON-RPC response as the isError field.
class WeatherTool < MCP::Tool
description "Get current weather and return structured data"
def self.call(server_context:)
# Do something here
content = {}
MCP::Tool::Response.new(
[{
type: "text",
text: content.to_json
}],
structured_content: content,
error: true
)
end
endTool responses are not limited to text. The MCP::Content module provides Image, Audio, and EmbeddedResource content types,
which serialize to the image, audio, and resource content blocks defined by the MCP spec. Image and audio data is passed as
a base64-encoded string together with its MIME type:
class ChartTool < MCP::Tool
description "Render a chart as a PNG image"
def self.call(server_context:)
MCP::Tool::Response.new([
MCP::Content::Text.new("Here is the rendered chart:").to_h,
MCP::Content::Image.new(Base64.strict_encode64(render_chart_png), "image/png").to_h,
])
end
end
class SpeechTool < MCP::Tool
description "Synthesize speech audio"
def self.call(server_context:)
MCP::Tool::Response.new([
MCP::Content::Audio.new(Base64.strict_encode64(synthesize_wav), "audio/wav").to_h,
])
end
endAn embedded resource wraps MCP::Resource::TextContents or MCP::Resource::BlobContents, allowing a tool to return resource contents inline:
class ReportTool < MCP::Tool
description "Return a report as an embedded resource"
def self.call(server_context:)
contents = MCP::Resource::TextContents.new(
uri: "report://monthly",
mime_type: "application/json",
text: { total: 42 }.to_json,
)
MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(contents).to_h])
end
endMCP spec includes Prompts, which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs.
The MCP::Prompt class provides three ways to create prompts:
class MyPrompt < MCP::Prompt
prompt_name "my_prompt" # Optional - defaults to underscored class name
title "My Prompt"
description "This prompt performs specific functionality..."
arguments [
MCP::Prompt::Argument.new(
name: "message",
title: "Message Title",
description: "Input message",
required: true
)
]
meta({ version: "1.0", category: "example" })
class << self
def template(args, server_context:)
MCP::Prompt::Result.new(
description: "Response description",
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("User message")
),
MCP::Prompt::Message.new(
role: "assistant",
content: MCP::Content::Text.new(args["message"])
)
]
)
end
end
end
prompt = MyPromptMCP::Prompt.define method:prompt = MCP::Prompt.define(
name: "my_prompt",
title: "My Prompt",
description: "This prompt performs specific functionality...",
arguments: [
MCP::Prompt::Argument.new(
name: "message",
title: "Message Title",
description: "Input message",
required: true
)
],
meta: { version: "1.0", category: "example" }
) do |args, server_context:|
MCP::Prompt::Result.new(
description: "Response description",
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("User message")
),
MCP::Prompt::Message.new(
role: "assistant",
content: MCP::Content::Text.new(args["message"])
)
]
)
endMCP::Server#define_prompt method:server = MCP::Server.new
server.define_prompt(
name: "my_prompt",
description: "This prompt performs specific functionality...",
arguments: [
Prompt::Argument.new(
name: "message",
title: "Message Title",
description: "Input message",
required: true
)
],
meta: { version: "1.0", category: "example" }
) do |args, server_context:|
Prompt::Result.new(
description: "Response description",
messages: [
Prompt::Message.new(
role: "user",
content: Content::Text.new("User message")
),
Prompt::Message.new(
role: "assistant",
content: Content::Text.new(args["message"])
)
]
)
endThe server_context parameter is the server_context passed into the server and can be used to pass per request information,
e.g. around authentication state or user preferences.
MCP::Prompt::Argument - Defines input parameters for the prompt template with name, title, description, and required flagMCP::Prompt::Message - Represents a message in the conversation with a role and contentMCP::Prompt::Result - The output of a prompt template containing description and messagesMCP::Content::Text - Text content for messagesRegister prompts with the MCP server:
server = MCP::Server.new(
name: "my_server",
prompts: [MyPrompt],
server_context: { user_id: current_user.id },
)The server will handle prompt listing and execution through the MCP protocol methods:
prompts/list - Lists all registered prompts and their schemasprompts/get - Retrieves and executes a specific prompt with argumentsPrompt messages are not limited to text. The same MCP::Content types used in tool responses can be used as message content,
letting a prompt template include images or inline resource contents. Unlike tool responses, the content object is passed directly rather than as a hash;MCP::Prompt::Message serializes it when the prompt result is returned:
class CodeReviewPrompt < MCP::Prompt
prompt_name "code_review"
description "Review a source file with an accompanying diagram"
arguments [
MCP::Prompt::Argument.new(name: "file_uri", description: "URI of the file to review", required: true),
]
class << self
def template(args, server_context:)
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::EmbeddedResource.new(
MCP::Resource::TextContents.new(
uri: args["file_uri"],
mime_type: "text/x-ruby",
text: read_source(args["file_uri"]),
),
),
),
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Image.new(architecture_diagram_base64, "image/png"),
),
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("Please review the code above, using the diagram for context."),
),
],
)
end
end
endMCP spec includes Resources.
Like tools and prompts, resources can be defined in three ways.
MCP::Resource, implementing contents to serve the resource body:class MyResource < MCP::Resource
uri "https://example.com/my_resource"
resource_name "my-resource"
title "My Resource"
description "Lorem ipsum dolor sit amet"
mime_type "text/html"
class << self
def contents
[MCP::Resource::TextContents.new(
uri: uri,
mime_type: mime_type,
text: "Hello from example resource!"
)]
end
end
end
server = MCP::Server.new(
name: "my_server",
resources: [MyResource],
)resources/read requests are routed automatically: when the requested URI matches a registered
class-based resource, its contents method is called. contents may return an array ofMCP::Resource::TextContents / MCP::Resource::BlobContents objects (or plain hashes), or a single one.
Like tools, contents can opt in to a server_context: keyword argument to receive per-request context.
When class-based resources or resource templates are registered and a resources/read request
does not match any of them, the server responds with the standard JSON-RPC Invalid Params error
(-32602) carrying the requested URI in the error data member, per SEP-2164.
MCP::Resource.define method, whose block implements contents:resource = MCP::Resource.define(
uri: "https://example.com/my_resource",
name: "my-resource",
mime_type: "text/html",
) do
[MCP::Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "Hello!")]
endMCP::Server#define_resource method:server = MCP::Server.new(name: "my_server")
server.define_resource(
uri: "https://example.com/my_resource",
name: "my-resource",
mime_type: "text/html",
) do
[MCP::Resource::TextContents.new(uri: "https://example.com/my_resource", mime_type: "text/html", text: "Hello!")]
endAlternatively, resources can be registered as plain data objects with MCP::Resource.new,
in which case the server only lists them:
resource = MCP::Resource.new(
uri: "https://example.com/my_resource",
name: "my-resource",
title: "My Resource",
description: "Lorem ipsum dolor sit amet",
mime_type: "text/html",
)
server = MCP::Server.new(
name: "my_server",
resources: [resource],
)With plain data resources, the server must register a handler for the resources/read method to
retrieve a resource dynamically.
server.resources_read_handler do |params|
[{
uri: params[:uri],
mimeType: "text/plain",
text: "Hello from example resource! URI: #{params[:uri]}"
}]
endotherwise resources/read requests will be a no-op. Note that a resources_read_handler fully replaces
the default resources/read handling, including the automatic routing to class-based resources described above.
For unknown URIs, raise MCP::Server::ResourceNotFoundError from the handler.
Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (-32602)
carrying the requested URI in the error data member:
server.resources_read_handler do |params|
resource = lookup(params[:uri])
raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource
[{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }]
endFor binary resources, respond with a base64-encoded blob field instead of text.
The MCP::Resource::TextContents and MCP::Resource::BlobContents classes build the two contents shapes defined by the spec:
server.resources_read_handler do |params|
case params[:uri]
when "file:///logo.png"
[
MCP::Resource::BlobContents.new(
uri: params[:uri],
mime_type: "image/png",
data: Base64.strict_encode64(File.binread("logo.png")),
).to_h,
]
else
[
MCP::Resource::TextContents.new(
uri: params[:uri],
mime_type: "text/plain",
text: "Hello from example resource!",
).to_h,
]
end
endResource templates follow the same pattern. Class-based templates declare a uri_template and
receive the variables extracted from the requested URI as keyword arguments to contents:
class UserProfileTemplate < MCP::ResourceTemplate
uri_template "users://{user_id}/profile"
resource_template_name "user-profile"
title "User Profile"
description "Profile data for a user"
mime_type "application/json"
class << self
def contents(user_id:)
[MCP::Resource::TextContents.new(
uri: "users://#{user_id}/profile",
mime_type: mime_type,
text: { id: user_id }.to_json
)]
end
end
end
server = MCP::Server.new(
name: "my_server",
resource_templates: [UserProfileTemplate],
)A resources/read request for users://42/profile calls UserProfileTemplate.contents(user_id: "42").
An exact match against a registered resource takes precedence over template matching.contents can also opt in to a server_context: keyword argument.
URI template matching supports simple RFC 6570 level 1 {variable} expressions only:
{+path}, {#fragment}, or {?query} are treated as literal text and never match an expanded URI./.The MCP::ResourceTemplate.define and MCP::Server#define_resource_template methods are also available,
mirroring the resource variants:
server.define_resource_template(
uri_template: "users://{user_id}/profile",
name: "user-profile",
mime_type: "application/json",
) do |user_id:|
[MCP::Resource::TextContents.new(
uri: "users://#{user_id}/profile",
mime_type: "application/json",
text: { id: user_id }.to_json
)]
endResource templates can also be registered as plain data objects with MCP::ResourceTemplate.new,
in which case reads must be served by a resources_read_handler:
resource_template = MCP::ResourceTemplate.new(
uri_template: "https://example.com/my_resource_template",
name: "my-resource-template",
title: "My Resource Template",
description: "Lorem ipsum dolor sit amet",
mime_type: "text/html",
)
server = MCP::Server.new(
name: "my_server",
resource_templates: [resource_template],
)Registered templates are listed through the resources/templates/list protocol method.
To serve reads for URIs that match a template, extract the variable parts of the URI in your resources_read_handler:
resource_template = MCP::ResourceTemplate.new(
uri_template: "file:///items/{item_id}",
name: "item",
mime_type: "application/json",
)
server = MCP::Server.new(name: "my_server", resource_templates: [resource_template])
server.resources_read_handler do |params|
if (match = params[:uri].match(%r{\Afile:///items/(?<item_id>[^/]+)\z}))
[{
uri: params[:uri],
mimeType: "application/json",
text: { id: match[:item_id] }.to_json,
}]
else
raise MCP::Server::ResourceNotFoundError.new(params[:uri], params)
end
endThe 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:
roots capability during initializationroots.listChanged send notifications/roots/list_changed when roots change[!NOTE]
Per SEP-2260, server-to-client requests (roots/list,sampling/createMessage,elicitation/create) must be associated with
an originating client request (pingis exempt). Use theserver_contextpassed to your handler, which stamps the association
automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the correspondingServerSessionmethods withoutrelated_request_id:still works but emits a deprecation warning.
Timeouts: every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely.MCP::Server::Transports::StreamableHTTPTransport waits server_to_client_request_timeout: seconds (600 by default), then tells
the client the request was abandoned and raises MCP::Server::RequestTimeoutError. Individual calls override the deadline with timeout:,
which is the knob to reach for when a prompt legitimately waits on a person:
server_context.create_form_elicitation(
message: "Approve this deployment?",
requested_schema: { type: "object", properties: { approved: { type: "boolean" } } },
timeout: 3600, # This one waits up to an hour.
)StdioTransport is not bounded and ignores timeout:: it owns the client process, so a client that stops answering
surfaces as end-of-file rather than as a wait that never ends.
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 < MCP::Tool
description "Search files within the client's project roots"
input_schema(
properties: {
query: { type: "string" }
},
required: ["query"]
)
def self.call(query:, server_context:)
roots = server_context.list_roots
root_uris = roots[:roots].map { |root| root[:uri] }
MCP::Tool::Response.new([{
type: "text",
text: "Searching in roots: #{root_uris.join(", ")}"
}])
end
endResult contains an array of root objects:
{
roots: [
{ uri: "file:///home/user/projects/myproject", name: "My Project" },
{ uri: "file:///home/user/repos/backend", name: "Backend Repository" }
]
}Handling Root Changes:
Register a callback to be notified when the client's roots change:
server.roots_list_changed_handler do
puts "Client's roots have changed, tools will see updated roots on next call."
endError Handling:
RuntimeError if client does not support roots capabilityStandardError if client returns an error responseResource subscriptions allow clients to monitor specific resources for changes.
When a subscribed resource is updated, the server sends a notification to the client.
The SDK does not track subscription state internally.
Server developers register handlers and manage their own subscription state.
Three methods are provided:
Server#resources_subscribe_handler - registers a handler for resources/subscribe requestsServer#resources_unsubscribe_handler - registers a handler for resources/unsubscribe requestsServerContext#notify_resources_updated - sends a notifications/resources/updated notification to the subscribing clientsubscribed_uris = Set.new
server = MCP::Server.new(
name: "my_server",
resources: [my_resource],
capabilities: { resources: { subscribe: true } },
)
server.resources_subscribe_handler do |params|
subscribed_uris.add(params[:uri].to_s)
end
server.resources_unsubscribe_handler do |params|
subscribed_uris.delete(params[:uri].to_s)
end
server.define_tool(name: "update_resource") do |server_context:, **args|
if subscribed_uris.include?("test://my-resource")
server_context.notify_resources_updated(uri: "test://my-resource")
end
MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h])
endThe Model Context Protocol allows servers to request LLM completions from clients through the sampling/createMessage method.
This enables servers to leverage the client's LLM capabilities without needing direct access to AI models.
Key Concepts:
sampling capability during initializationsampling.tools capabilityUsing Sampling in Tools:
Tools that accept a server_context: parameter can call create_sampling_message on it.
The request is automatically routed to the correct client session:
class SummarizeTool < MCP::Tool
description "Summarize text using LLM"
input_schema(
properties: {
text: { type: "string" }
},
required: ["text"]
)
def self.call(text:, server_context:)
result = server_context.create_sampling_message(
messages: [
{ role: "user", content: { type: "text", text: "Please summarize: #{text}" } }
],
max_tokens: 500
)
MCP::Tool::Response.new([{
type: "text",
text: result[:content][:text]
}])
end
end
server = MCP::Server.new(name: "my_server", tools: [SummarizeTool])Parameters:
Required:
messages: (Array) - Array of message objects with role and contentmax_tokens: (Integer) - Maximum tokens in the responseOptional:
system_prompt: (String) - System prompt for the LLMmodel_preferences: (Hash) - Model selection preferences (e.g., { intelligencePriority: 0.8 })include_context: (String) - Context inclusion: "none", "thisServer", or "allServers" (soft-deprecated)temperature: (Float) - Sampling temperaturestop_sequences: (Array) - Sequences that stop generationmetadata: (Hash) - Additional metadatatools: (Array) - Tools available to the LLM (requires sampling.tools capability)tool_choice: (Hash) - Tool selection mode (e.g., { mode: "auto" })Error Handling:
RuntimeError if client does not support sampling capabilityRuntimeError if tools are used but client lacks sampling.tools capabilityStandardError if client returns an error responseThe server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling.
The server provides the following notification methods:
notify_tools_list_changed - Send a notification when the tools list changesnotify_prompts_list_changed - Send a notification when the prompts list changesnotify_resources_list_changed - Send a notification when the resources list changesnotify_log_message - Send a structured logging notification messageWhen using Streamable HTTP transport with multiple clients, each client connection gets its own session. Notifications are scoped as follows:
report_progress and notify_log_message called via server_context inside a tool handler are automatically sent only to the requesting client.notify_tools_list_changed, notify_prompts_list_changed, and notify_resources_list_changed are always broadcast to all connected clients,server instance directly.Notifications follow the JSON-RPC 2.0 specification and use these method names:
notifications/tools/list_changednotifications/prompts/list_changednotifications/resources/list_changednotifications/cancellednotifications/progressnotifications/messageThe MCP Ruby SDK supports server-side handling of the
MCP notifications/cancelled utility.
When a client sends notifications/cancelled for an in-flight request, the server stops
processing cooperatively and suppresses the JSON-RPC response for that request.
Cancellation is cooperative: the SDK does not forcibly terminate tool code. Instead,
a MCP::Cancellation token is threaded through server_context, and long-running tools
poll it to exit early. When a tool returns after cancellation has been observed,
the server suppresses the JSON-RPC response, matching the spec. The initialize request
is never cancellable per the spec.
Client-initiated cancellation is also supported: see Client-Side: Cancelling an In-Flight Request below.
Any handler that opts in to server_context: - tools (Tool.call), prompt templates,resources_read_handler, completion_handler, resources_subscribe_handler,resources_unsubscribe_handler, and define_custom_method blocks - receives
an MCP::ServerContext wired to the in-flight request's cancellation token.
Handlers check cancelled? in their work loop, or call raise_if_cancelled! to raiseMCP::CancelledError at a safe point:
class LongRunningTool < MCP::Tool
description "A tool that supports cancellation"
input_schema(properties: { count: { type: "integer" } }, required: ["count"])
def self.call(count:, server_context:)
count.times do |i|
# Exit early if the client has sent `notifications/cancelled`.
break if server_context.cancelled?
do_work(i)
end
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
end
endAlternatively, raise at the next safe point with raise_if_cancelled!:
def self.call(count:, server_context:)
count.times do |i|
server_context.raise_if_cancelled!
do_work(i)
end
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
endWhen a handler observes cancellation (either by returning early with cancelled? or
by raising MCP::CancelledError via raise_if_cancelled!), the server drops the response and
no JSON-RPC result is sent to the client.
The same pattern works for other handler types:
# resources/read
server.resources_read_handler do |params, server_context:|
server_context.raise_if_cancelled!
# read the resource
end
# completion/complete
server.completion_handler do |params, server_context:|
server_context.raise_if_cancelled!
# compute completions
end
# custom method
server.define_custom_method(method_name: "custom/slow") do |params, server_context:|
server_context.raise_if_cancelled!
# do work
end
# prompts (via Prompt subclass)
class SlowPrompt < MCP::Prompt
prompt_name "slow_prompt"
def self.template(args, server_context:)
server_context.raise_if_cancelled!
MCP::Prompt::Result.new(messages: [])
end
endHandlers that do not declare a server_context: keyword continue to work unchanged -
the opt-in detection only wraps the context when the block signature asks for it.
When a tool handler is waiting on a nested server-to-client request
(server_context.create_sampling_message, create_form_elicitation, orcreate_url_elicitation), cancelling the parent tool call automatically raisesMCP::CancelledError from the nested call, so the tool does not need to wrap it
in its own cancelled? checks:
def self.call(server_context:)
result = server_context.create_sampling_message(messages: messages, max_tokens: 100)
# If the parent tools/call is cancelled while waiting above, MCP::CancelledError
# is raised here and the tool can let it propagate or clean up as needed.
MCP::Tool::Response.new([{ type: "text", text: result[:content][:text] }])
rescue MCP::CancelledError
# Optional: run cleanup. Re-raising (or letting it propagate) is fine; the server
# will still suppress the JSON-RPC response per the MCP spec.
raise
endNested cancellation propagation is supported on StreamableHTTPTransport only.StdioTransport is single-threaded and blocks on $stdin.gets, so a nestedserver_context.create_sampling_message inside a tool runs to completion even if
the parent tools/call is cancelled. The parent tool itself still observes cancellation
via server_context.cancelled? between nested calls.
MCP::Client lets the caller cancel a request it has already issued. The recommended pattern is to pass
an MCP::Cancellation token into the request method, run the request on a worker thread, and callcancellation.cancel(reason:) from another thread. The cancelling thread sends notifications/cancelled to
the server, and the calling thread is woken up with MCP::CancelledError:
client = MCP::Client.new(transport: transport)
cancellation = MCP::Cancellation.new
Thread.new do
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
rescue MCP::CancelledError
# cleanup
end
# Later, from another thread:
cancellation.cancel(reason: "user pressed cancel")All request methods (tools, list_tools, resources, list_resources, resource_templates, list_resource_templates,prompts, list_prompts, call_tool, read_resource, get_prompt, complete, ping) accept the cancellation: keyword.
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
[!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-sideStreamableHTTPTransport#send_requesttrade-off. ForStreamableHTTPTransport#send_requesttrade-off. ForClient::HTTP
the leak resolves as soon as the server sends any response; forClient::Stdioyou may need to callclient.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_sentfromsend_request) before issuingnotifications/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.
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),
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 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.
The MCP Ruby SDK supports the
MCP ping utility,
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.
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 < MCP::Tool
description "Verifies the client is still responsive"
def self.call(server_context:)
server_context.ping # => {} 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.
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.
The MCP Ruby SDK supports progress tracking for long-running tool operations,
following the MCP Progress specification.
progressToken in the _meta field when calling a toolnotifications/progress messages back to the client during tool executionserver_context.report_progress to report incremental progressTools 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 < MCP::Tool
description "A tool that reports progress during execution"
input_schema(
properties: {
count: { type: "integer" },
},
required: ["count"]
)
def self.call(count:, server_context:)
count.times do |i|
# Do work here.
server_context.report_progress(i + 1, total: count, message: "Processing item #{i + 1}")
end
MCP::Tool::Response.new([{ type: "text", text: "Done" }])
end
endThe server_context.report_progress method accepts:
progress (required) — current progress value (numeric)total: (optional) — total expected value, so clients can display a percentagemessage: (optional) — human-readable status messageKey Features:
server_context.report_progressreport_progress is a no-op when no progressToken was provided by the clientMCP spec includes Completions,
which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs.
To enable completions, declare the completions capability and register a handler:
server = MCP::Server.new(
name: "my_server",
prompts: [CodeReviewPrompt],
resource_templates: [FileTemplate],
capabilities: { completions: {} },
)
server.completion_handler do |params|
ref = params[:ref]
argument = params[:argument]
value = argument[:value]
case ref[:type]
when "ref/prompt"
values = case argument[:name]
when "language"
["python", "pytorch", "pyside"].select { |v| v.start_with?(value) }
else
[]
end
{ completion: { values: values, hasMore: false } }
when "ref/resource"
{ completion: { values: [], hasMore: false } }
end
endThe handler receives a params hash with:
ref - The reference ({ type: "ref/prompt", name: "..." } or { type: "ref/resource", uri: "..." })argument - The argument being completed ({ name: "...", value: "..." })context (optional) - Previously resolved arguments ({ arguments: { ... } })The handler must return a hash with a completion key containing values (array of strings), and optionally total and hasMore.
The SDK automatically enforces the 100-item limit per the MCP specification.
The server validates that the referenced prompt, resource, or resource template is registered before calling the handler.
Requests for unknown references return an error.
The MCP Ruby SDK supports elicitation,
which allows servers to request additional information from users through the client during tool execution.
Elicitation is a server-to-client request. The server sends a request and blocks until the user responds via the client.
Clients must declare the elicitation capability during initialization. The server checks this before sending any elicitation request
and raises a RuntimeError if the client does not support it.
For URL mode support, the client must also declare elicitation.url capability.
Tools that accept a server_context: parameter can call create_form_elicitation on it:
server.define_tool(name: "collect_info", description: "Collect user info") do |server_context:|
result = server_context.create_form_elicitation(
message: "Please provide your name",
requested_schema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
)
MCP::Tool::Response.new([{ type: "text", text: "Hello, #{result[:content][:name]}" }])
endForm mode collects structured data from the user directly through the MCP client:
server.define_tool(name: "collect_contact", description: "Collect contact info") do |server_context:|
result = server_context.create_form_elicitation(
message: "Please provide your contact information",
requested_schema: {
type: "object",
properties: {
name: { type: "string", description: "Your full name" },
email: { type: "string", format: "email", description: "Your email address" },
},
required: ["name", "email"],
},
)
text = case result[:action]
when "accept"
"Hello, #{result[:content][:name]} (#{result[:content][:email]})"
when "decline"
"User declined"
when "cancel"
"User cancelled"
end
MCP::Tool::Response.new([{ type: "text", text: text }])
endThe requested_schema must be a flat object schema: a top-level type: "object" whose properties are limited to
primitive types (string, number, integer, boolean). Nested objects and arrays are not allowed, which keeps
the schema simple enough for clients to render as a form. Per the MCP specification, the client validates
the user's input against this schema before returning it, so the content of an accept response matches the requested shape.
Properties may declare a default value (SEP-1034), which clients use to pre-fill the form.
String properties may declare enum values, optionally with human-readable enumNames (SEP-1330), which clients render as a choice list:
server.define_tool(name: "configure_deploy", description: "Configure a deployment") do |server_context:|
result = server_context.create_form_elicitation(
message: "Configure the deployment",
requested_schema: {
type: "object",
properties: {
replicas: { type: "integer", default: 3 },
verbose: { type: "boolean", default: false },
environment: {
type: "string",
enum: ["dev", "staging", "prod"],
enumNames: ["Development", "Staging", "Production"],
default: "dev",
},
},
required: ["environment"],
},
)
MCP::Tool::Response.new([{ type: "text", text: "Deploying to #{result[:content][:environment]}" }])
endFor enumerated choices, use MCP::Elicitation::EnumSchema to construct the canonical schema shapes per
SEP-1330 instead of building
the underlying Hash by hand. The five class methods cover titled and untitled, single-select and multi-select,
plus the legacy enumNames form retained for backward compatibility:
size_schema = MCP::Elicitation::EnumSchema.titled_single_select(
options: [
{ value: "s", title: "Small" },
{ value: "m", title: "Medium" },
{ value: "l", title: "Large" },
],
default: "m",
)
tags_schema = MCP::Elicitation::EnumSchema.untitled_multi_select(
values: ["urgent", "billing", "feedback"],
)
result = server_context.create_form_elicitation(
message: "Tell us about your order",
requested_schema: {
type: "object",
properties: {
size: size_schema.to_h,
tags: tags_schema.to_h,
},
required: ["size"],
},
)The available builders are untitled_single_select, titled_single_select, untitled_multi_select, titled_multi_select,
and legacy_titled. Each accepts optional default:, title:, and description:.
The same builders produce the requestedSchema of an elicitation/create request embedded in a SEP-2322 input_required result,
which is how elicitation reaches clients on the stateless 2026-07-28 lifecycle:
MCP::Server::InputRequiredResult.new(
input_requests: {
"size" => {
method: "elicitation/create",
params: {
message: "Pick a size",
requestedSchema: {
type: "object",
properties: { size: size_schema.to_h },
required: ["size"],
},
},
},
},
)URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows:
server.define_tool(name: "authorize_github", description: "Authorize GitHub") do |server_context:|
elicitation_id = SecureRandom.uuid
result = server_context.create_url_elicitation(
message: "Please authorize access to your GitHub account",
url: "https://example.com/oauth/authorize?elicitation_id=#{elicitation_id}",
elicitation_id: elicitation_id,
)
server_context.notify_elicitation_complete(elicitation_id: elicitation_id)
MCP::Tool::Response.new([{ type: "text", text: "Authorization complete" }])
endWhen a tool cannot proceed until an out-of-band elicitation is completed, raise MCP::Server::URLElicitationRequiredError.
This returns a JSON-RPC error with code -32042 to the client:
server.define_tool(name: "access_github", description: "Access GitHub") do |server_context:|
raise MCP::Server::URLElicitationRequiredError.new([
{
mode: "url",
elicitationId: SecureRandom.uuid,
url: "https://example.com/oauth/authorize",
message: "GitHub authorization is required.",
},
])
endThe MCP Ruby SDK supports structured logging through the notify_log_message method, following the MCP Logging specification.
The notifications/message notification is used for structured logging between client and server.
The SDK supports 8 log levels with increasing severity:
debug - Detailed debugging informationinfo - General informational messagesnotice - Normal but significant eventswarning - Warning conditionserror - Error conditionscritical - Critical conditionsalert - Action must be taken immediatelyemergency - System is unusablelogging/setLevel request to configure the minimum log levelnotifications/message to the clientFor example, if the client sets the level to "error" (severity 4), the server will send messages with levels: error, critical, alert, and emergency.
For more details, see the MCP Logging specification.
Usage Example:
server = MCP::Server.new(name: "my_server")
transport = MCP::Server::Transports::StdioTransport.new(server)
# The client first configures the logging level (on the client side):
transport.send_request(
request: {
jsonrpc: "2.0",
method: "logging/setLevel",
params: { level: "info" },
id: session_id # Unique request ID within the session
}
)
# Send log messages at different severity levels
server.notify_log_message(
data: { message: "Application started successfully" },
level: "info"
)
server.notify_log_message(
data: { message: "Configuration file not found, using defaults" },
level: "warning"
)
server.notify_log_message(
data: {
error: "Database connection failed",
details: { host: "localhost", port: 5432 }
},
level: "error",
logger: "DatabaseLogger" # Optional logger name
)Key Features:
logging to send log messagesserver = MCP::Server.new(name: "my_server")
# Default Streamable HTTP - session oriented
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
# When tools change, notify clients
server.define_tool(name: "new_tool") { |**args| { result: "ok" } }
server.notify_tools_list_changed
# When prompts change, notify clients
server.define_prompt(name: "new_prompt") do |args, server_context:|
MCP::Prompt::Result.new(messages: [])
end
server.notify_prompts_list_changed
# When resources change, notify clients
server.define_resource(uri: "resource://new", name: "new_resource", mime_type: "text/plain") do
[MCP::Resource::TextContents.new(uri: "resource://new", mime_type: "text/plain", text: "contents")]
end
server.notify_resources_list_changedYou can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions.
This mode allows for easy multi-node deployment.
Set stateless: true in MCP::Server::Transports::StreamableHTTPTransport.new (stateless defaults to false):
# Stateless Streamable HTTP - session-less
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)In stateless mode, each POST is fully self-contained per SEP-2567: no Mcp-Session-Id is issued or required,
handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
and repeated initialize requests are permitted. Request-scoped notifications such as progress and log messages are skipped
(there is no stream to deliver them), while server-to-client requests (sampling/createMessage, roots/list, elicitation/create) raise an error.
You can enable JSON response mode, where the server returns application/json instead of text/event-stream.
Set enable_json_response: true in MCP::Server::Transports::StreamableHTTPTransport.new:
# JSON response mode
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true)In JSON response mode, the POST response is a single JSON object, so server-to-client messages
that need to arrive during request processing are not supported:
request-scoped notifications (progress, log) are silently dropped, and all server-to-client requests
(sampling/createMessage, roots/list, elicitation/create) raise an error.
Session-scoped standalone notifications (resources/updated, elicitation/complete) and
broadcast notifications (tools/list_changed, etc.) still flow to clients connected to the GET SSE stream.
This mode is suitable for simple tool servers that do not need server-initiated requests.
By default, stateful sessions are bounded so an initialize flood cannot retain sessions until memory is exhausted:
they expire after session_idle_timeout seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent
session count is capped at max_sessions (default 10000). A session's idle timer is reset by activity that touches it
(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute,
so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots
and then, if still full, rejects a new initialize with HTTP 503 (it does not evict an existing session).
# Tune the limits
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000)
# Opt out of expiry and/or the cap (not recommended on internet-facing deployments)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil)Stateless mode (stateless: true) retains no sessions, so neither limit applies to it.
StreamableHTTPTransport issues a random SecureRandom.uuid session ID and validates incoming requests by session
existence and idle timeout only. It does not bind a session to a user, because the transport never receives
an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD).
The primary control is the session_request_validator. It is called as ->(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.
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)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.
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.
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), andlist_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 onnext_cursor as shown above, or use the whole-collection methods described below.
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. Two guards keep that loop finite: it stops when the server returns a nextCursor
it has already sent, and it stops after max_pages pages.
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.MCP::Client.new accepts an optional max_pages: keyword that caps how many pages these methods
will walk. It defaults to 1_000; a server that keeps offering a fresh nextCursor past that
point raises MCP::Client::PaginationLimitError rather than being followed indefinitely. Raise it
if you legitimately expect more pages than that.
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).
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: "private",
the scope that keeps a potentially user-dependent result out of shared caches).
When neither is set, responses are serialized exactly as before.
The 2026-07-28 revision makes both hints required on these results, so on requests carrying the modern _meta envelope
the server always emits them, filling unset values with the same defaults; stable protocol versions keep the opt-in behavior.
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"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:
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:
MCP::Server::MethodAlreadyDefinedError if trying to override an existing methodThe MCP::Client class provides an interface for interacting with MCP servers.
This class supports:
ping method (MCP::Client#ping)tools/list method (MCP::Client#tools)tools/call method (MCP::Client#call_tool)resources/list method (MCP::Client#resources)resources/templates/list method (MCP::Client#resource_templates)resources/read method (MCP::Client#read_resource)prompts/list method (MCP::Client#prompts)prompts/get method (MCP::Client#get_prompt)completion/complete method (MCP::Client#complete)Clients are initialized with a transport layer instance that handles the low-level communication mechanics.
Authorization is handled by the transport layer.
MCP::Client#connect selects the protocol lifecycle automatically by default: on the bundledMCP::Client::HTTP and MCP::Client::Stdio transports it probes server/discover first and adopts
the stateless modern lifecycle (MCP 2026-07-28) when the server serves it, falling back to
the classic initialize handshake otherwise. Custom transports whose connect does not declare
a mode: keyword always receive the classic call shape, unchanged.
client.connect # negotiate automatically (default)
client.connect(mode: :legacy) # force the classic initialize handshake
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probePrefer mode: :legacy for spawn-per-invocation CLI tools (the probe adds a round trip per process)
and when using server-initiated requests (on_elicitation / on_sampling), which exist only on
the legacy lifecycle.
Because the raw connect return value and MCP::Client#server_info mirror the wire result,
their shape depends on the negotiated lifecycle: InitializeResult (protocolVersion,
top-level serverInfo) on legacy, DiscoverResult (supportedVersions, ttlMs/cacheScope)
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
client.protocol_version # negotiated or adopted version, either lifecycle
client.server_capabilities # capabilities Hash, either lifecycle
client.instructions # instructions text, either lifecycle
client.server_implementation # server name/version; nil when a modern server does not identify itselfTroubleshooting: if server_info["protocolVersion"] starts returning nil after a server you connect to was upgraded,
the server now serves the modern lifecycle and the automatic negotiation adopted it.
Pass mode: :legacy for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
On a modern MCP::Client::HTTP connection, tools/call mirrors arguments whose inputSchema property carries
an x-mcp-header annotation into Mcp-Param-{Name} request headers, so intermediaries can route
on the values without parsing bodies. The declarations are learned from tools/list responses:
list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values
(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as =?base64?...?=,
and a null or absent argument omits its header.
Per the specification, a tool definition whose x-mcp-header annotations are invalid (empty or non-token names,
duplicate names, non-primitive properties, annotations outside a chain of properties keys) is excluded fromtools/list results on modern connections, with a warning naming the tool.
Legacy connections are unaffected: nothing is learned, mirrored, or excluded.
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
endUse 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:
Open3.popen3initialize request + notifications/initialized)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 <<~TOOL_INFORMATION
Tool: #{tool.name}
Description: #{tool.description}
Input Schema: #{tool.input_schema}
TOOL_INFORMATION
end
# Call a specific tool
response = client.call_tool(
tool: tools.first,
arguments: { message: "Hello, world!" }
)
# Call a tool with progress tracking.
response = client.call_tool(
tool: tools.first,
arguments: { count: 10 },
progress_token: "my-progress-token"
)The server will send notifications/progress back to the client during execution.
MCP::Client::HTTP.new accepts an optional max_message_bytes: keyword that caps the bytes buffered in memory for a single message from the server -
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
a server that never terminates an SSE event. It defaults to 4 * 1024 * 1024 (4 MiB); raise it if your server returns larger responses.
MCP::Client::HTTP.new also accepts max_reconnection_wait:, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts,
and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE retry: field, and resuming happens on the calling thread,
so without a budget a server answering with a large retry: parks a thread of your application for as long as it likes. It defaults to 300 (5 minutes).
The server's retry: is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead,
the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a retry: 0 cannot spin
the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at.
Servers can send requests back to the client while one of the client's own requests is in flight - for example,elicitation/create to ask the user for additional input during a tool call.
Register a handler and advertise the capability on connect to respond to them:
client.connect(capabilities: { elicitation: {} })
client.on_elicitation do |params|
{
action: "accept",
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
}
endRegistering a handler opens a standalone HTTP GET SSE stream on a background thread
(listening for messages from the server),
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
a JSON-RPC -32601 (method not found) error. To handle methods other than elicitation/create, register directly on the transport withhttp_transport.on_server_request("method/name") { |params| ... }.
Servers can also request an LLM completion from the client with sampling/createMessage,
letting a server leverage the client's model access without its own API keys.
MCP Sampling is deprecated as of protocol version
2026-07-28(SEP-2577), while remaining fully supported under2025-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",
}
endFor 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)
endUse capabilities: { sampling: { tools: {} } } to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
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 authYou can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
When an MCP server enforces the MCP Authorization spec,
pass an MCP::Client::OAuth::Provider to the transport instead of a static Authorization header. The transport will:
Authorization: Bearer <access_token> on every request when a token is available.401 Unauthorized, parse the WWW-Authenticate header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),<origin>/.well-known/oauth-authorization-server without the RFC 8414 issuer byte-match (which the legacy spec predates),/authorize, /token, and /register at the origin are used with PKCE S256 assumed.refresh_token, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).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),403 without that challenge is surfaced unchanged.offline_access scope when client_metadata[:grant_types] includes refresh_token and the authorization server advertises offline_access in its metadatascopes_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 advertiseoffline_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.toolsRequired 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.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);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 formiss set to the RFC 9207 iss parameter from the redirect, or nil when absent) opts into SEP-2468 issuer validation: a present iss must matchauthorization_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,client_information is stamped with an "issuer" member binding it to the authorization server thatclient_ids 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 Documentdraft-ietf-oauth-client-id-metadata-document and the MCP authorization specification).client_id_metadata_document_supported: true,client_id and skips Dynamic Client Registration.https:// with a non-root path and MUST NOT include a fragment,./.. segments. The SDK additionally rejects query strings (the draft only marksclient_id stability.Provider::InvalidClientIDMetadataDocumentURLError. The CIMD documentclient_metadata keyword above:client_metadata MUST NOT include client_id, while the CIMD document MUST includeclient_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")),
)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.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).IDJAGTokenExchange.request covers the common case.scope, storage: Optional, same meaning as on Provider.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 byinstance_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.
The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations.
Both checks run before the request is sent, and neither is configurable.
resource_metadata URL in a WWW-Authenticate challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server,401 cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728,authorization_servers entry and the authorization_endpoint, token_endpoint, and registration_endpoint from Authorization Server metadata must not be0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, ::/96, fc00::/7,fe80::/10, along with the IPv4-mapped IPv6 spellings of each and the localhost name.http://localhost development and deployments that never leave a corporate network keep working.The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed,
such as https://vault.corp.internal/. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
the HTTP client connects to a moment later. The same-origin rule is what protects the resource_metadata URL, which is the only one of these a server supplies directly.
If you replace the OAuth HTTP client through MCP::Client::OAuth::Flow.new(http_client_factory:), do not add redirect-following middleware. Every check above runs against
the URL as written, so a connection that follows a 3xx on its own would reach hosts these rules just refused.
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
endThe client provides a wrapper class for tools returned by the server:
MCP::Client::Tool - Represents a single tool with its metadataThis class provides easy access to tool properties like name, description, input schema, and output schema.
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.
SEP-2322 also makes resultType a required member of every result a 2026-07-28 server returns. The server stamps resultType: "complete" on all results of requests carrying
the modern _meta envelope (and on server/discover results), while results that already carry a discriminator ("input_required", the tasks extension's "task") keep it.
Legacy results stay unstamped, and clients treat an absent resultType as "complete" per the spec.
Handlers written in the 2026 style serve pre-2026 clients too: when a tools/call, prompts/get, or resources/read handler returns an InputRequiredResult on the legacy wire,
the server fulfills it in place of the client's driver. Each inputRequests entry is sent as the equivalent real server-to-client request
(elicitation/create, sampling/createMessage, roots/list), associated with the originating request per SEP-2260; the answers are collected under the same keys,
and the handler re-runs with server_context.input_responses populated and the raw requestState echoed, the same deterministic replay contract the modern client driver follows.
The shim is on by default (matching the TypeScript SDK) and capped at 8 rounds; MCP::Server.new(input_required_legacy_shim: false) restores the strict rejection of input_required results on legacy requests.
The conformance/ directory contains a test server and runner that validate the SDK against the MCP specification using @modelcontextprotocol/conformance.
See conformance/README.md for usage instructions.
modelcontextprotocol/ruby-sdk
February 27, 2025
August 17, 2026
Ruby