A community-driven registry for Claude, Cursor, Windsurf, Cline & more. Not affiliated with Anthropic.
Are you the author? Sign in to claim
The official Ruby SDK for the Model Context Protocol.
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 install
Or install it yourself as:
$ gem install mcp
You 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 capabilitiesping - 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.open
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 same
Mcp-Session-Idheader are always routed to the same instance.Stateless mode (
stateless: true) does not use sessions and works with any server configuration.
StreamableHTTPTransport is a Rack app that can be mounted directly in Rails routes:
# config/routes.rb
server = MCP::Server.new(
name: "my_server",
title: "Example Server Display Name",
version: "1.0.0",
instructions: "Use the tools of this server as a last resort",
tools: [SomeTool, AnotherTool],
prompts: [MyPrompt],
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
Rails.application.routes.draw do
mount transport => "/mcp"
end
mount directs all HTTP methods on /mcp to the transport. StreamableHTTPTransport internally dispatches
POST (client-to-server JSON-RPC messages, with responses optionally streamed via SSE),
GET (optional standalone SSE stream for server-to-client messages), and DELETE (session termination) per
the MCP Streamable HTTP transport spec,
so no additional route configuration is needed.
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
end
The 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]}")
}
end
or by creating an explicit configuration and passing it into the server. This is useful for systems where an application hosts more than one MCP server but they might require different configurations.
configuration = MCP::Configuration.new
configuration.exception_reporter = ->(exception, server_context) {
# Your exception reporting logic here
# For example with Bugsnag:
Bugsnag.notify(exception) do |report|
report.add_metadata(:model_context_protocol, server_context)
end
}
configuration.around_request = ->(data, &request_handler) {
logger.info("Start: #{data[:method]}")
request_handler.call
logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}")
}
server = MCP::Server.new(
# ... all other options
configuration:,
)
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" => {} } })
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 exception and instrumentation callbacks do not receive this user-defined hash.
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
end
Client 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
end
On 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> }
for request handling, { notification: "tools_list_changed" } for notification delivery).
This is not the user-defined server_context passed to Server.new.Signature:
exception_reporter = ->(exception, server_context) { ... }
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: methodrequest_handler.call: tool_name, tool_arguments, prompt_name, resource_uri, error, clientaround_request: duration (added after around_request returns)[!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:hljs language-ruby# 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 requestSignature:
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 stable version will be applied by default. The latest stable version includes new features from the draft version.
This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to nil:
MCP::Configuration.new(protocol_version: nil)
If an invalid protocol_version value is set, an ArgumentError is raised.
Be sure to check the MCP spec 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 = MyTool
MCP::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" }])
end
MCP::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" }])
end
The 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.
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 from
protocol_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
end
tool = 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" }])
end
class DataTool < MCP::Tool
output_schema MCP::Tool::OutputSchema.new(
properties: {
success: { type: "boolean" },
data: { type: "object" }
},
required: ["success"]
)
end
Output 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"]
}
)
end
Please note: in this case, you must provide type: "array". The default type
for output schemas is object.
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.
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
end
Tools 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
end
MCP 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 = MyPrompt
MCP::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"])
)
]
)
end
MCP::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"])
)
]
)
end
The 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 argumentsMCP spec includes Resources.
The MCP::Resource class provides a way to register resources with the server.
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],
)
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]}"
}]
end
otherwise resources/read requests will be a no-op.
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 }]
end
The MCP::ResourceTemplate class provides a way to register resource templates with the server.
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],
)
The Model Context Protocol allows servers to request filesystem roots from clients through the roots/list method.
Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available.
Key Concepts:
roots capability during initializationroots.listChanged send notifications/roots/list_changed when roots changeUsing 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
end
Result 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."
end
Error 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])
end
The 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.
No extra configuration is needed.notify_tools_list_changed, notify_prompts_list_changed, and notify_resources_list_changed are always broadcast to all connected clients,
as they represent server-wide state changes. These should be called on the 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.
[!NOTE] Client-initiated cancellation (
Client#cancelequivalent that would also abort the calling thread's wait) is not yet implemented. Sendingnotifications/cancelledfrom the client side can be done by constructing the notification payload and writing it directly through the transport, but the calling thread does not yet unwind automatically. This is tracked as a follow-up.
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 raise
MCP::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
end
Alternatively, 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" }])
end
When 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
end
Handlers 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, or
create_url_elicitation), cancelling the parent tool call automatically raises
MCP::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
end
Nested cancellation propagation is supported on StreamableHTTPTransport only.
StdioTransport is single-threaded and blocks on $stdin.gets, so a nested
server_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.
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
end
The 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
end
The 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]}" }])
end
Form 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 }])
end
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" }])
end
When 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.",
},
])
end
The 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
You 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)
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, sessions do not expire. To mitigate session hijacking risks, you can set a session_idle_timeout (in seconds).
When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
# Session timeout of 30 minutes
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
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
end
The same pattern applies to list_prompts (page.prompts), list_resources (page.resources), and
list_resource_templates (page.resource_templates). next_cursor is nil on the final page.
Because a single call returns a single page, how many items come back depends on the server's page_size configuration:
Server page_size | client.list_tools(cursor: nil) |
|---|---|
| Not set (default) | Returns every item in one response. next_cursor is nil. |
Set to N | Returns the first N items. next_cursor is set for continuation. |
If your application needs the complete collection regardless of how the server is configured, either loop on
next_cursor as shown above, or use the whole-collection methods described below.
client.tools, client.resources, client.resource_templates, and client.prompts auto-iterate
through all pages and return a plain array of items, guaranteeing the full collection regardless
of the server's page_size setting. When a server paginates, they issue multiple JSON-RPC round
trips per call and break out of the pagination loop if the server returns the same nextCursor
twice in a row as a safety measure.
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
Use these when you want the complete list; use list_tools(cursor:) etc. when you need
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
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
end
Key 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.
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
end
Use the MCP::Client::Stdio transport to interact with MCP servers running as subprocesses over standard input/output.
MCP::Client::Stdio.new accepts the following keyword arguments:
| Parameter | Required | Description |
|---|---|---|
command: | Yes | The command to spawn the server process (e.g., "ruby", "bundle", "npx"). |
args: | No | An array of arguments passed to the command. Defaults to []. |
env: | No | A hash of environment variables to set for the server process. Defaults to nil. |
read_timeout: | No | Timeout in seconds for waiting for a server response. Defaults to nil (no timeout). |
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.close
The 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 responses
Example 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.
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:
http_transport = MCP::Client::HTTP.new(
url: "https://api.example.com/mcp",
headers: {
"Authorization" => "Bearer my_token"
}
)
client = MCP::Client.new(transport: http_transport)
client.tools # will make the call using Bearer auth
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
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),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.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),
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A 403 without that challenge is surfaced unchanged.offline_access scope when client_metadata[:grant_types] includes refresh_token and the authorization server advertises offline_access in its metadata
scopes_supported (SEP-2207). This is what lets the server issue the refresh_token used above. As an SDK-level safeguard, when the authorization server does not advertise
offline_access the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.require "mcp"
provider = MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "My MCP App",
redirect_uris: ["http://localhost:3030/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:3030/callback",
redirect_handler: ->(authorization_url) {
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
# or a manual `puts authorization_url` in CLI tools.
},
callback_handler: -> {
# Capture the redirect (for example, by running a small HTTP listener on
# `redirect_uri`) and return [code, state] from the query string.
},
)
transport = MCP::Client::HTTP.new(
url: "https://api.example.com/mcp",
oauth: provider,
)
client = MCP::Client.new(transport: transport)
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
client.tools
Required keyword arguments to Provider.new:
client_metadata: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include redirect_uris, grant_types, response_types,
token_endpoint_auth_method. redirect_uri (below) must appear in this list, otherwise the constructor raises Provider::UnregisteredRedirectURIError.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] after the user is redirected back to redirect_uri.Optional keyword arguments:
scope: Space-separated scopes to request when the server's WWW-Authenticate does not specify one.storage: Object responding to tokens, save_tokens(t), client_information, save_client_information(info). Defaults to MCP::Client::OAuth::InMemoryStorage,
which keeps credentials in process memory only.client_id_metadata_document_url: URL where you publish a Client ID Metadata Document
(draft-ietf-oauth-client-id-metadata-document and the MCP authorization specification).
When the authorization server advertises client_id_metadata_document_supported: true,
the SDK uses this URL as the OAuth client_id and skips Dynamic Client Registration.
Spec-required: the URL MUST be https:// with a non-root path and MUST NOT include a fragment,
userinfo, or ./.. segments. The SDK additionally rejects query strings (the draft only marks
them SHOULD NOT include, but the SDK refuses to send any) for client_id stability.
Any of these failures raise Provider::InvalidClientIDMetadataDocumentURLError. The CIMD document
served at the URL is a separate JSON artifact from the client_metadata keyword above:
the DCR client_metadata MUST NOT include client_id, while the CIMD document MUST include
client_id set to the document URL, client_name, and redirect_uris covering redirect_uri.To persist credentials across restarts, supply your own storage:
class FileTokenStorage
def initialize(path)
@path = path
end
def tokens
read["tokens"]
end
def save_tokens(value)
write("tokens" => value)
end
def client_information
read["client"]
end
def save_client_information(value)
write("client" => value)
end
private
def read
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
end
def write(updates)
File.write(@path, JSON.dump(read.merge(updates)))
end
end
provider = MCP::Client::OAuth::Provider.new(
# ... required keywords ...
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
)
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.When oauth: is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, authorization_endpoint, token_endpoint, registration_endpoint,
redirect_uri) must use HTTPS or a loopback host. Non-loopback http:// URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at initialize time and re-checks them on every outgoing request through
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises MCP::Client::HTTP::InsecureURLError before the request reaches the adapter,
whether the swap was triggered by
instance_variable_set(:@url, ...), by a Faraday customizer rewriting url_prefix, or by a custom middleware rewriting env.url (including just env.url.query) at request time,
and whether the new URL is http:// or https:// to a different host or tenant.
You can pass a block to MCP::Client::HTTP.new to customize the underlying Faraday connection.
The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter:
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
faraday.use MyApp::Middleware::HttpRecorder
faraday.adapter :typhoeus
end
The 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 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.
1000+ skills curated from Anthropic, Vercel, Stripe, and other engineering teams
Claude Code skill for YouTube creators — channel audits, video SEO, retention scripts, thumbnails, content strategy, Sho
Design enforcement with memory — keeps your UI consistent across a project
AI image generation skill for Claude Code -- Creative Director powered by Gemini