From 88831b46f62254fccc1ea62d8b5e0ad1aa55ab55 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Fri, 21 Aug 2026 03:03:27 +0900 Subject: [PATCH] Move the Documentation From README.md to the Documentation Site Move the documentation that had grown to 3000+ lines in README.md onto https://ruby.sdk.modelcontextprotocol.io and slim the README down to a quick start and feature overview, following the Python SDK layout: - Add 21 server pages as the docs/_server/ collection (overview plus transports, discovery, tools, prompts, resources, roots, sampling, elicitation, multi round-trip results, notifications, notification subscriptions, cancellation, progress, ping, completions, logging, pagination, server context, configuration, and custom methods), 8 client pages as the docs/_client/ collection (overview, transports, lifecycle, multi round-trip results, cancellation, ping, pagination, and authorization), and 3 extension pages as the docs/_extensions/ collection (overview, capability extensions, and MCP Apps, following the Extensions Overview recommendation that SDK documentation list the supported extensions) - one page per topic, ordered to match the sidebar of the 2026-07-28 specification, with the client-side APIs (pinging, cancelling, and paginating from MCP::Client) documented under Building Clients - Add top-level Examples and Protocol Versions pages after Installation, linking the runnable examples in examples/ and summarizing the supported protocol versions, the era model, and client negotiation - Render the three sections as just-the-docs collections, which list every page beneath a plain category heading in the sidebar with no folding, and serve every page at an extensionless URL under /server/, /client/, and /extensions/, matching the sibling SDK documentation sites - Replace docs/building-servers.md and docs/building-clients.md with redirects to the new section overview pages via jekyll-redirect-from, redirect the previously published /installation.html to its extensionless URL, fold their content that was missing from README.md into the new pages, and update the Tool argument keys reference comment in lib/mcp/server.rb to the relocated Tools page - Reduce README.md to the badges, a compact feature overview in the Python SDK style, installation instructions, a stdio server and client quick start, and a License section, using absolute URLs for the remaining repository links so they resolve on rubygems.org - Adapt formatting where GitHub rendering habits break on the site: convert the numbered "three ways to define" lists into headings, since kramdown restarts numbering at 1 when code blocks split list items, convert GitHub-style alerts into just-the-docs callouts with the SEP-2260 server-to-client association note raised to a red warning, merge the duplicated Exception Reporting and Configuration Block Data sections into the Configuration page, and relocate the Streamable HTTP settings that were nested under the Logging section into the Transports page - Refresh the migrated content against the current implementation: correct stale claims and broken samples the README carried, note on each session-era feature how it relates to the modern lifecycle of MCP 2026-07-28, and point spec links at the latest revision, keeping deliberate 2025-11-25 pins for pages the modern revision removed - Style the site after the Rails API documentation palette (red links and accents on neutral surfaces) with matching light and dark color schemes, center the sidebar and content as one block, add a Previous/Next footer pager following the sidebar order, open external links in a new tab, and serve the MCP logo as the favicon Every code block and heading from the previous README was verified to have a home in the new docs pages or the slimmed README before the reduction; a few samples were corrected rather than copied, as noted above. --- AGENTS.md | 9 + README.md | 3050 +------------------- docs/_client/authorization.md | 260 ++ docs/_client/cancellation.md | 71 + docs/_client/index.md | 46 + docs/_client/lifecycle.md | 78 + docs/_client/multi-round-trip-results.md | 60 + docs/_client/pagination.md | 80 + docs/_client/ping.md | 35 + docs/_client/transports.md | 261 ++ docs/_config.yml | 46 +- docs/_extensions/capability-extensions.md | 35 + docs/_extensions/index.md | 17 + docs/_extensions/mcp-apps.md | 48 + docs/_includes/footer_custom.html | 48 + docs/_includes/head_custom.html | 23 +- docs/_sass/color_schemes/ruby-dark.scss | 17 + docs/_sass/color_schemes/ruby-light.scss | 8 + docs/_sass/custom/custom.scss | 76 +- docs/_sass/custom/setup.scss | 2 + docs/_server/cancellation.md | 137 + docs/_server/completions.md | 52 + docs/_server/configuration.md | 206 ++ docs/_server/custom-methods.md | 62 + docs/_server/discovery.md | 61 + docs/_server/elicitation.md | 231 ++ docs/_server/index.md | 53 + docs/_server/logging.md | 90 + docs/_server/multi-round-trip-results.md | 85 + docs/_server/notification-subscriptions.md | 71 + docs/_server/notifications.md | 68 + docs/_server/pagination.md | 78 + docs/_server/ping.md | 50 + docs/_server/progress.md | 64 + docs/_server/prompts.md | 194 ++ docs/_server/resources.md | 319 ++ docs/_server/roots.md | 82 + docs/_server/sampling.md | 87 + docs/_server/server-context.md | 111 + docs/_server/tools.md | 419 +++ docs/_server/transports.md | 274 ++ docs/assets/css/just-the-docs-dark.scss | 2 +- docs/assets/css/just-the-docs-light.scss | 2 +- docs/building-clients.md | 153 - docs/building-servers.md | 332 --- docs/examples.md | 48 + docs/favicon.svg | 11 + docs/index.md | 76 +- docs/installation.md | 17 +- docs/protocol-versions.md | 62 + lib/mcp/server.rb | 2 +- 51 files changed, 4253 insertions(+), 3516 deletions(-) create mode 100644 docs/_client/authorization.md create mode 100644 docs/_client/cancellation.md create mode 100644 docs/_client/index.md create mode 100644 docs/_client/lifecycle.md create mode 100644 docs/_client/multi-round-trip-results.md create mode 100644 docs/_client/pagination.md create mode 100644 docs/_client/ping.md create mode 100644 docs/_client/transports.md create mode 100644 docs/_extensions/capability-extensions.md create mode 100644 docs/_extensions/index.md create mode 100644 docs/_extensions/mcp-apps.md create mode 100644 docs/_includes/footer_custom.html create mode 100644 docs/_sass/color_schemes/ruby-dark.scss create mode 100644 docs/_sass/color_schemes/ruby-light.scss create mode 100644 docs/_sass/custom/setup.scss create mode 100644 docs/_server/cancellation.md create mode 100644 docs/_server/completions.md create mode 100644 docs/_server/configuration.md create mode 100644 docs/_server/custom-methods.md create mode 100644 docs/_server/discovery.md create mode 100644 docs/_server/elicitation.md create mode 100644 docs/_server/index.md create mode 100644 docs/_server/logging.md create mode 100644 docs/_server/multi-round-trip-results.md create mode 100644 docs/_server/notification-subscriptions.md create mode 100644 docs/_server/notifications.md create mode 100644 docs/_server/pagination.md create mode 100644 docs/_server/ping.md create mode 100644 docs/_server/progress.md create mode 100644 docs/_server/prompts.md create mode 100644 docs/_server/resources.md create mode 100644 docs/_server/roots.md create mode 100644 docs/_server/sampling.md create mode 100644 docs/_server/server-context.md create mode 100644 docs/_server/tools.md create mode 100644 docs/_server/transports.md delete mode 100644 docs/building-clients.md delete mode 100644 docs/building-servers.md create mode 100644 docs/examples.md create mode 100644 docs/favicon.svg create mode 100644 docs/protocol-versions.md diff --git a/AGENTS.md b/AGENTS.md index 5f0ee622..8af6c4d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - `rake test` - Run all tests - `rake rubocop` - Run linter - `rake` - Run tests and linting (default task) +- `bundle exec rake conformance` - Run the MCP conformance suite (see conformance/README.md) - `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file - `gem build mcp.gemspec` - Build the gem @@ -34,6 +35,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - Keep dependencies minimal - Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only +## Documentation + +- User-facing documentation lives in `docs/`, one page per topic, published at https://ruby.sdk.modelcontextprotocol.io (deploys only when a release is published) +- Pages live in the `docs/_server/`, `docs/_client/`, and `docs/_extensions/` collections +- Keep README.md slim: quick start and pointers only; document features on the relevant docs page +- Internal links are absolute and extensionless (e.g. `/server/tools/`); front matter is followed by a blank line before the h1 +- Callout tiers: `.note` (supplementary), `.important` (spec constraints), `.warning` (deprecated features) + ## Commit message conventions - Use conventional commit format when possible diff --git a/README.md b/README.md index 964c26f2..45823ab0 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,21 @@ The official Ruby SDK for Model Context Protocol servers and clients. +Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io. + +## Features + +- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization +- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration +- Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination + ## Installation Add this line to your application's Gemfile: ```ruby -gem 'mcp' +gem "mcp" ``` And then execute: @@ -24,81 +33,14 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. -## Building an MCP Server - -The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. -It implements the Model Context Protocol specification, handling model context requests and responses. - -### Key Features - -- Implements JSON-RPC 2.0 message handling -- Supports protocol initialization and capability negotiation -- Manages tool registration and invocation -- Supports prompt registration and execution -- Supports resource registration and retrieval -- Supports stdio & Streamable HTTP (including SSE) transports -- Supports notifications for list changes (tools, prompts, resources) -- Supports roots (server-to-client filesystem boundary queries) -- Supports sampling (server-to-client LLM completion requests) -- Supports cursor-based pagination for list operations -- Supports cancellation of in-flight requests on both server and client (notifications/cancelled) - -### Supported Methods - -- `initialize` - Initializes the protocol and returns server capabilities -- `server/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 optional - `io.modelcontextprotocol/serverInfo` stamp in the result `_meta`, and responds before `initialize` - and without an `Mcp-Session-Id`. The server also serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope - (`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request, - and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates - the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips - the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result -- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET listening stream: - the client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`), - the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message, - and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. Served on the Streamable HTTP modern path; - stdio answers `-32601`. Concurrent streams are capped by `max_listen_subscriptions:` (default 1000), and each stream receives an SSE keepalive - comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil` - when an upstream proxy already keeps the stream alive -- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): a `tools/call`, `prompts/get`, or `resources/read` handler that - opts in to `server_context:` may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for - additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes) instead of performing a server-initiated request, - which the modern lifecycle forbids. On the retried request the handler re-runs from the start and reads the answers via - `server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` - (deterministic replay; the server holds no memory between rounds). The SDK rejects issuance on legacy requests and returns `-32021` - when an embedded request needs a client capability the request did not declare. The echoed `requestState` arrives as - client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to - have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers. - Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as - the handler wrote it and protecting it is the handler author's responsibility. On the client, register handlers with - `on_elicitation` / `on_sampling` / `on_roots` - the same registrations that answer a real server-to-client request - and - declare the matching capabilities on `connect` (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 by - the matching handler and the original request is re-issued with `inputResponses` plus the echoed `requestState` - (with exponential backoff for `requestState`-only load-shedding legs). Without a matching handler they raise `MCP::Client::InputRequiredError`, - and the `input_responses:` / `request_state:` keyword arguments support manual driving -- `ping` - Simple health check -- `logging/setLevel` - Configures the minimum log level for the server -- `tools/list` - Lists all registered tools and their schemas -- `tools/call` - Invokes a specific tool with provided arguments -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves a specific prompt by name -- `resources/list` - Lists all registered resources and their schemas -- `resources/read` - Retrieves a specific resource by name -- `resources/templates/list` - Lists all registered resource templates and their schemas -- `resources/subscribe` - Subscribes to updates for a specific resource -- `resources/unsubscribe` - Unsubscribes from updates for a specific resource -- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs -- `roots/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) +## Quick Start -### Usage +The following minimal programs show both sides of the protocol: a server that exposes a single tool, +and a client that spawns such a server and drives it over stdio. -#### Stdio Transport +### MCP Server -If you want to build a local command-line application, you can use the stdio transport: +A minimal server defines a tool and serves it over the stdio transport: ```ruby require "mcp" @@ -134,2956 +76,64 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` -`StdioTransport.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. +Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: ```console -$ ruby examples/stdio_server.rb +$ ruby 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"}}} ``` -#### Streamable HTTP Transport - -`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::StreamableHTTPTransport` stores session and SSE stream state in memory, -> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). -> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that -> do not share memory, which breaks session management and SSE connections. -> -> When running multiple server instances behind a load balancer, configure your load balancer to use -> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always -> routed to the same instance. -> -> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. - -> [!IMPORTANT] -> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to -> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. -> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, -> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` -> header are unaffected. -> -> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: -> -> ```ruby -> transport = MCP::Server::Transports::StreamableHTTPTransport.new( -> server, -> allowed_hosts: ["mcp.example.com"], -> allowed_origins: ["https://app.example.com"], -> ) -> ``` -> -> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, -> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` -> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). - -##### Rails (mount) - -`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: - -```ruby -# config/routes.rb -server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], -) -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -Rails.application.routes.draw do - mount transport => "/mcp" -end -``` - -`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches -`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), -`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per -the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), -so no additional route configuration is needed. - -A complete runnable application using this approach is available in [`examples/rails`](examples/rails). - -##### Rails (controller) - -While the mount approach creates a single server at boot time, the controller approach creates a new server per request. -This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). - -`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): - -```ruby -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 -``` - -### Configuration - -The gem can be configured using the `MCP.configure` block: - -```ruby -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. - -```ruby -configuration = MCP::Configuration.new -configuration.exception_reporter = ->(exception, server_context) { - # Your exception reporting logic here - # For example with Bugsnag: - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end -} - -configuration.around_request = ->(data, &request_handler) { - logger.info("Start: #{data[:method]}") - request_handler.call - logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") -} - -server = MCP::Server.new( - # ... all other options - configuration:, -) -``` - -### Capability Extensions - -Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities. -Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); -values are extension-defined configuration objects, with `{}` meaning "supported with no settings". - -On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder: - -```ruby -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`: - -```ruby -client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) -``` +The same server can also run over Streamable HTTP, including mounted inside a Rails application; +see [Server Transports](https://ruby.sdk.modelcontextprotocol.io/server/transports/). -### MCP Apps (SEP-1865) +### MCP Client -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: +A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: ```ruby -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")], +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) -server.resources_read_handler do |params| - [{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }] -end +# Perform the MCP initialization handshake before sending any requests. +client.connect -# 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" }]) +# List available tools. +tools = client.tools +tools.each do |tool| + puts "Tool: #{tool.name} - #{tool.description}" end -``` - -Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge, consent for UI-initiated actions) -is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. -See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). - -### Server Context and Configuration Block Data - -#### `server_context` -The `server_context` is a user-defined hash that is passed into the server instance and made available to tool and prompt calls. -It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. - -**Type:** - -```ruby -server_context: { [String, Symbol] => Any } -``` - -**Example:** - -```ruby -server = MCP::Server.new( - name: "my_server", - server_context: { user_id: current_user.id, request_id: request.uuid } +# Call a specific tool. +response = client.call_tool( + tool: tools.first, + arguments: { message: "Hello, world!" } ) -``` - -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. - -#### Request-specific `_meta` Parameter - -The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. - -> [!NOTE] -> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). -> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it -> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. - -**Access Pattern:** - -When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: - -```ruby -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:** - -```json -{ - "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](https://www.w3.org/TR/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: - -```ruby -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: - -```ruby -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) -``` - -#### Configuration Block Data - -##### Exception Reporter - -The exception reporter receives: - -- `exception`: The Ruby exception object that was raised -- `server_context`: A hash describing where the failure occurred (e.g., `{ request: }` - for request handling, `{ notification: "tools_list_changed" }` for notification delivery). - This is not the user-defined `server_context` passed to `Server.new`. - -**Signature:** - -```ruby -exception_reporter = ->(exception, server_context) { ... } -``` - -##### Around Request - -The `around_request` hook wraps request handling, allowing you to execute code before and after each request. -This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs. - -The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request: - -**Signature:** - -```ruby -around_request = ->(data, &request_handler) { request_handler.call } -``` - -**`data` availability by timing:** - -- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled -- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` -- Not available inside `around_request`: `duration` (added after `around_request` returns) - -**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: - -```ruby -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 - } -end -``` - -`data[: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_name` and `resource_uri` may only be populated for the corresponding request methods -> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled -> (for example, `prompt_name` is not recorded when the prompt is not found). -> `duration` is added after `around_request` returns, so it is not visible from within the hook. - -**Example:** - -```ruby -MCP.configure do |config| - config.around_request = ->(data, &request_handler) { - logger.info("Start: #{data[:method]}") - request_handler.call - logger.info("Done: #{data[:method]}, tool: #{data[:tool_name]}") - } -end -``` - -##### Instrumentation Callback (soft-deprecated) - -> [!NOTE] -> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. -> -> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: -> -> ```ruby -> # Before -> config.instrumentation_callback = ->(data) { log(data) } -> -> # After -> config.around_request = ->(data, &request_handler) do -> request_handler.call -> ensure -> log(data) -> end -> ``` -> -> Note that `data[:duration]` is not available inside `around_request`. -> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. - -The instrumentation callback is called after each request finishes, whether successfully or with an error. -It receives a hash with the following possible keys: - -- `method`: (String) The protocol method called (e.g., "ping", "tools/list") -- `tool_name`: (String, optional) The name of the tool called -- `tool_arguments`: (Hash, optional) The arguments passed to the tool -- `prompt_name`: (String, optional) The name of the prompt called -- `resource_uri`: (String, optional) The URI of the resource called -- `error`: (String, optional) Error code if a lookup failed -- `duration`: (Float) Duration of the call in seconds -- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request -- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when - `instrument_server_context` is enabled - -**Signature:** - -```ruby -instrumentation_callback = ->(data) { ... } -``` - -### Server Protocol Version - -The server's protocol version can be overridden using the `protocol_version` keyword argument: - -```ruby -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`: - -```ruby -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](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. - -### Exception Reporting - -The exception reporter receives two arguments: - -- `exception`: The Ruby exception object that was raised -- `server_context`: A hash containing contextual information about where the error occurred - -The `server_context` hash includes: - -- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) -- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) - -When an exception occurs: - -1. The exception is reported via the configured reporter -2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", isError: true }` -3. For other requests, the exception is re-raised after reporting - -If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. - -### Tools - -MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps. - -This gem provides a `MCP::Tool` class that can be used to create tools in three ways: - -1. As a class definition: - -```ruby -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 -``` - -2. By using the `MCP::Tool.define` method with a block: - -```ruby -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 -``` -3. By using the `MCP::Server#define_tool` method with a block: - -```ruby -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 +# Close the transport when done. +stdio_transport.close ``` -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. - -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](docs/building-servers.md#tool-argument-keys) for details and a testing tip. - -### Tool Annotations - -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 true -- `idempotent_hint`: Indicates if the tool's operations are idempotent. Defaults to false -- `open_world_hint`: Indicates if the tool operates in an open world context. Defaults to true -- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state). Defaults to false -- `title`: A human-readable title for the tool - -Annotations 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'`. - -### Tool Output Schemas - -Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways: - -1. **Class definition with output_schema:** - -```ruby -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 -``` +The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; +see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transports/). -2. **Using Tool.define with output_schema:** +## Examples -```ruby -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 -``` +Runnable examples are available in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples), +including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails). -3. **Using OutputSchema objects:** +## Documentation -```ruby -class DataTool < MCP::Tool - output_schema MCP::Tool::OutputSchema.new( - properties: { - success: { type: "boolean" }, - data: { type: "object" } - }, - required: ["success"] - ) -end -``` +- [SDK guides](https://ruby.sdk.modelcontextprotocol.io) +- [SDK API documentation](https://rubydoc.info/gems/mcp) +- [Model Context Protocol documentation](https://modelcontextprotocol.io) -Output schema may also describe an array of objects: +## License -```ruby -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`, -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: - -```ruby -class FlexibleTool < MCP::Tool - output_schema( - oneOf: [ - { type: "string" }, - { type: "array", items: { type: "number" } } - ] - ) -end -``` - -Input 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 `$ref`s (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](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that: - -- **Server Validation**: Servers MUST provide structured results that conform to the output schema -- **Client Validation**: Clients SHOULD validate structured results against the output schema -- **Better Integration**: Enables strict schema validation, type information, and improved developer experience -- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block - -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`: - -```ruby -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. - -### Tool Responses with Structured Content - -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. - -```ruby -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 -``` - -### Tool Responses with Errors - -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. - -```ruby -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 -``` - -### Tool Responses with Image, Audio, and Embedded Resources - -Tool 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: - -```ruby -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 -end -``` - -An embedded resource wraps `MCP::Resource::TextContents` or `MCP::Resource::BlobContents`, allowing a tool to return resource contents inline: - -```ruby -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 -end -``` - -### Prompts - -MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/latest/server/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: - -1. As a class definition with metadata: - -```ruby -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 -``` - -2. Using the `MCP::Prompt.define` method: - -```ruby -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 -``` - -3. Using the `MCP::Server#define_prompt` method: - -```ruby -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. - -### Key Components - -- `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag -- `MCP::Prompt::Message` - Represents a message in the conversation with a role and content -- `MCP::Prompt::Result` - The output of a prompt template containing description and messages -- `MCP::Content::Text` - Text content for messages - -### Usage - -Register prompts with the MCP server: - -```ruby -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 schemas -- `prompts/get` - Retrieves and executes a specific prompt with arguments - -### Prompts with Image and Embedded Resource Content - -Prompt 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: - -```ruby -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 -end -``` - -### Resources - -MCP spec includes [Resources](https://modelcontextprotocol.io/specification/latest/server/resources). - -### Reading Resources - -Like tools and prompts, resources can be defined in three ways. - -1. As a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: - -```ruby -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 of -`MCP::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. - -2. With the `MCP::Resource.define` method, whose block implements `contents`: - -```ruby -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!")] -end -``` - -3. Using the `MCP::Server#define_resource` method: - -```ruby -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!")] -end -``` - -Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, -in which case the server only lists them: - -```ruby -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. - -```ruby -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. Note that a `resources_read_handler` fully replaces -the default `resources/read` handling, including the automatic routing to class-based resources described above. - -To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve, -so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array -and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array. -A block that declares `server_context:` receives it: - -```ruby -server.resources_list_handler do |params, server_context:| - server_context[:authenticated] ? real_resources : demo_resources -end -``` - -The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset -into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged. - -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: - -```ruby -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 -``` - -### Reading Binary Resources - -For 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: - -```ruby -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 -end -``` - -### Resource Templates - -Resource 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`: - -```ruby -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](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: - -- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. -- A variable matches one or more characters excluding `/`. -- Extracted values are not percent-decoded. - -The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, -mirroring the resource variants: - -```ruby -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 - )] -end -``` - -Resource 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`: - -```ruby -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`: - -```ruby -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/(?[^/]+)\z})) - [{ - uri: params[:uri], - mimeType: "application/json", - text: { id: match[:item_id] }.to_json, - }] - else - raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) - end -end -``` - -### Roots - -The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method. -Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available. - -**Key Concepts:** - -- **Server-to-Client Request**: Like sampling, roots listing is initiated by the server -- **Client Capability**: Clients must declare `roots` capability during initialization -- **Change Notifications**: Clients that support `roots.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 (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association -> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding -> `ServerSession` methods without `related_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: - -```ruby -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: - -```ruby -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: - -```ruby -{ - 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: - -```ruby -server.roots_list_changed_handler do - puts "Client's roots have changed, tools will see updated roots on next call." -end -``` - -**Error Handling:** - -- Raises `RuntimeError` if client does not support `roots` capability -- Raises `StandardError` if client returns an error response - -### Resource Subscriptions - -Resource 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` requests -- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests -- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client - -```ruby -subscribed_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 `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows -alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it -returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta` -rather than returning it at the top level, which interoperating clients reject: - -```ruby -server.resources_subscribe_handler do |params| - id = subscriptions.create(params[:uri].to_s) - { _meta: { "myapp.example/subscriptionId" => id } } -end -``` - -### Sampling - -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:** - -- **Server-to-Client Request**: Unlike typical MCP methods (client to server), sampling is initiated by the server -- **Client Capability**: Clients must declare `sampling` capability during initialization -- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability -- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs - -**Using 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: - -```ruby -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 `content` -- `max_tokens:` (Integer) - Maximum tokens in the response - -Optional: - -- `system_prompt:` (String) - System prompt for the LLM -- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) -- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) -- `temperature:` (Float) - Sampling temperature -- `stop_sequences:` (Array) - Sequences that stop generation -- `metadata:` (Hash) - Additional metadata -- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) -- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) - -**Error Handling:** - -- Raises `RuntimeError` if client does not support `sampling` capability -- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability -- Raises `StandardError` if client returns an error response - -### Notifications - -The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. - -#### Notification Methods - -The server provides the following notification methods: - -- `notify_tools_list_changed` - Send a notification when the tools list changes -- `notify_prompts_list_changed` - Send a notification when the prompts list changes -- `notify_resources_list_changed` - Send a notification when the resources list changes -- `notify_log_message` - Send a structured logging notification message - -#### Session Scoping - -When 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. - -#### Notification Format - -Notifications follow the JSON-RPC 2.0 specification and use these method names: - -- `notifications/tools/list_changed` -- `notifications/prompts/list_changed` -- `notifications/resources/list_changed` -- `notifications/cancelled` -- `notifications/progress` -- `notifications/message` - -### Cancellation - -The MCP Ruby SDK supports server-side handling of the -[MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation). -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](#client-side-cancelling-an-in-flight-request) below. - -#### Server-Side: Handlers that Check for Cancellation - -Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, -`resources_read_handler`, `resources_list_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: - -```ruby -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!`: - -```ruby -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: - -```ruby -# 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. - -#### Nested Server-to-Client Requests Are Cancelled Automatically - -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: - -```ruby -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. - -#### Client-Side: Cancelling an In-Flight Request - -`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 call -`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to -the server, and the calling thread is woken up with `MCP::CancelledError`: - -```ruby -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-side -> `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` -> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` -> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal -> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at -> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. - -##### Wire-order guarantees - -`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, -so the server is guaranteed to read the request line before the cancel line. - -`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, -so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST -on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and -still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)), -and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST -happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. - -##### Custom transports - -Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. -They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire -(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). -The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for -the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. - -### Ping - -The MCP Ruby SDK supports the -[MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), -which allows either side of the connection to verify that the peer is still responsive. -A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. - -#### Server-Side - -Servers respond to incoming `ping` requests automatically - no setup is required. -Any `MCP::Server` instance replies with an empty result. - -Servers can also send `ping` requests to the client via `ServerSession#ping`. -Inside a tool handler that receives `server_context:`, call `ping` on it: - -```ruby -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. - -#### Client-Side - -`MCP::Client` exposes `ping` to send a ping to the server: - -```ruby -client = MCP::Client.new(transport: transport) -client.ping # => {} on success -``` - -`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error. -It raises `MCP::Client::ValidationError` when the response `result` is missing or -is not a Hash (matching the spec requirement that `result` be an object). -Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) -propagate as exceptions raised by the transport layer. - -### Progress - -The MCP Ruby SDK supports progress tracking for long-running tool operations, -following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). - -#### How Progress Works - -1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool -2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution -3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress - -#### Server-Side: Tool with Progress - -Tools that accept a `server_context:` parameter can call `report_progress` on it. -The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: - -```ruby -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 percentage -- `message:` (optional) — human-readable status message - -**Key Features:** - -- Tools report progress via `server_context.report_progress` -- `report_progress` is a no-op when no `progressToken` was provided by the client -- Supports both numeric and string progress tokens - -### Completions - -MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), -which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. - -To enable completions, declare the `completions` capability and register a handler: - -```ruby -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. - -### Elicitation - -The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/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. - -#### Capabilities - -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. - -#### Using Elicitation in Tools - -Tools that accept a `server_context:` parameter can call `create_form_elicitation` on it: - -```ruby -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 - -Form mode collects structured data from the user directly through the MCP client: - -```ruby -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 -``` - -The `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. - -#### Default Values and Enums - -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: - -```ruby -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]}" }]) -end -``` - -#### Enum Schemas - -For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per -[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/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: - -```ruby -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: - -```ruby -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 - -URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: - -```ruby -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 -``` - -#### URLElicitationRequiredError - -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: - -```ruby -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 -``` - -### Logging - -The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). - -The `notifications/message` notification is used for structured logging between client and server. - -#### Log Levels - -The SDK supports 8 log levels with increasing severity: - -- `debug` - Detailed debugging information -- `info` - General informational messages -- `notice` - Normal but significant events -- `warning` - Warning conditions -- `error` - Error conditions -- `critical` - Critical conditions -- `alert` - Action must be taken immediately -- `emergency` - System is unusable - -#### How Logging Works - -1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level -2. **Server Filtering**: The server only sends log messages at the configured level or higher severity -3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client - -For 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](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). - -**Usage Example:** - -```ruby -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:** - -- Supports 8 log levels (debug, info, notice, warning, error, critical, alert, emergency) based on https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#log-levels -- Server has capability `logging` to send log messages -- Messages are only sent if a transport is configured -- Messages are filtered based on the client's configured log level -- If the log level hasn't been set by the client, no messages will be sent - -#### Transport Support - -- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout -- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) - -#### Usage Example - -```ruby -server = 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_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`): - -```ruby -# 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`: - -```ruby -# 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). - -```ruby -# 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. - -#### Session Ownership - -`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: - -```ruby -transport = MCP::Server::Transports::StreamableHTTPTransport.new( - server, - session_request_validator: ->(request, session_id) { owns_session?(request, session_id) }, -) -``` - -Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), -it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only -when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. -Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. - -#### Request Size Limits - -`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory -with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, -and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON -string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only -if you exchange unusually large payloads: - -```ruby -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) -``` - -### Pagination - -The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) -for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset, -treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it. - -Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`. - -#### Server-Side: Enabling Pagination - -Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), -list responses contain all items in a single response, preserving the pre-pagination behavior. - -```ruby -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: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "tools": [ - { "name": "example_tool" } - ], - "nextCursor": "50" - } -} -``` - -Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification. - -#### Client-Side: Iterating Pages - -`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. -**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** — not the full collection. -The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor as method accessors: - -```ruby -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. - -#### Fetching the Complete Collection - -`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate -through all pages and return a plain array of items, guaranteeing the full collection regardless -of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round -trips per call. 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. - -```ruby -tools = client.tools # => Array 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). - -#### List Result Caching (`ttlMs` / `cacheScope`) - -Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds; -`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). - -Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, -`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "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. - -```ruby -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: - -```ruby -server.resources_read_handler do |params| - { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 } -end -``` - -On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`: - -```ruby -page = client.list_tools -page.ttl_ms # => 60000 (nil when the server sent no hint) -page.cache_scope # => "private" -``` - -### Advanced - -#### Custom Methods - -The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: - -```ruby -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:** - -- Accepts any method name as a string -- Block receives the request parameters as a hash -- Can handle both regular methods (with responses) and notifications -- Prevents overriding existing MCP protocol methods -- Supports instrumentation callbacks for monitoring - -**Usage Example:** - -```ruby -# Client request -{ - "jsonrpc": "2.0", - "id": 1, - "method": "add", - "params": { "a": 5, "b": 3 } -} - -# Server response -{ - "jsonrpc": "2.0", - "id": 1, - "result": 8 -} -``` - -**Error Handling:** - -- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method -- Supports the same exception reporting and instrumentation as standard methods - -## Building an MCP Client - -The `MCP::Client` class provides an interface for interacting with MCP servers. - -This class supports: - -- Liveness check via the `ping` method (`MCP::Client#ping`) -- Tool listing via the `tools/list` method (`MCP::Client#tools`) -- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) -- Resource listing via the `resources/list` method (`MCP::Client#resources`) -- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) -- Resource reading via the `resources/read` method (`MCP::Client#read_resource`) -- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) -- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) -- Completion requests via the `completion/complete` method (`MCP::Client#complete`) -- Automatic JSON-RPC 2.0 message formatting -- UUID request ID generation - -Clients are initialized with a transport layer instance that handles the low-level communication mechanics. -Authorization is handled by the transport layer. - -### Lifecycle Negotiation (SEP-2575) - -`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled -`MCP::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. - -```ruby -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 probe -``` - -Prefer `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: - -```ruby -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 itself -``` - -Troubleshooting: 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. - -### Custom Headers from Tool Parameters (SEP-2243) - -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 from -`tools/list` results on modern connections, with a warning naming the tool. -Legacy connections are unaffected: nothing is learned, mirrored, or excluded. - -## Transport Layer Interface - -If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: - -```ruby -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 -``` - -### Stdio Transport Layer - -Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output. - -`MCP::Client::Stdio.new` accepts the following keyword arguments: - -| Parameter | Required | Description | -|---|---|---| -| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). | -| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | -| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | -| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | -| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | - -Example usage: - -```ruby -stdio_transport = MCP::Client::Stdio.new( - command: "bundle", - args: ["exec", "ruby", "path/to/server.rb"], - env: { "API_KEY" => "my_secret_key" }, - read_timeout: 30 -) -client = MCP::Client.new(transport: stdio_transport) - -# 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: - -- Spawning the server process with `Open3.popen3` -- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) -- JSON-RPC 2.0 message framing over newline-delimited JSON - -### HTTP Transport Layer - -Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. - -You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: - -```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' -gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses -``` - -Example usage: - -```ruby -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. - -#### Server-to-Client Requests (Elicitation) - -Servers can send requests back to the client while one of the client's own requests is in flight - for example, -[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call. -Register a handler and advertise the capability on `connect` to respond to them: - -```ruby -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"]), - } -end -``` - -Registering a handler opens a standalone HTTP GET SSE stream on a background thread -([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#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 with -`http_transport.on_server_request("method/name") { |params| ... }`. - -#### Server-to-Client Requests (Sampling) - -Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), -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 under `2025-11-25`. -> Register this handler to interoperate with servers that still send sampling requests during the deprecation window; -> new servers should call LLM provider APIs directly. - -Register a handler and advertise the capability on `connect`: - -```ruby -client.connect(capabilities: { sampling: {} }) - -client.on_sampling do |params| - completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) - { - role: "assistant", - content: { type: "text", text: completion.text }, - model: completion.model, - stopReason: "endTurn", - } -end -``` - -For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response. -To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`: - -```ruby -client.on_sampling do |params| - raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) - - generate_completion(params) -end -``` - -Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream. - -#### HTTP Authorization - -By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: - -```ruby -http_transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - headers: { - "Authorization" => "Bearer my_token" - } -) - -client = MCP::Client.new(transport: http_transport) -client.tools # will make the call using Bearer auth -``` - -You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. - -#### OAuth 2.1 Authorization - -When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), -pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will: - -- Send `Authorization: Bearer ` on every request when a token is available. -- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), - perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. -- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts - as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), - and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. -- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). -- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), - run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. - The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. -- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata - `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise - `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. - -```ruby -require "mcp" - -provider = MCP::Client::OAuth::Provider.new( - client_metadata: { - client_name: "My MCP App", - redirect_uris: ["http://localhost:3030/callback"], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:3030/callback", - redirect_handler: ->(authorization_url) { - # Send the user to the authorization URL - typically `Launchy.open(authorization_url)` - # or a manual `puts authorization_url` in CLI tools. - }, - callback_handler: -> { - # Capture the redirect (for example, by running a small HTTP listener on - # `redirect_uri`) and return [code, state] from the query string. - }, -) - -transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - oauth: provider, -) -client = MCP::Client.new(transport: transport) -client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token -client.tools -``` - -Required keyword arguments to `Provider.new`: - -- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, - `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. - When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native); - an explicit value always wins. -- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. -- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. -- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form - (with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match - the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`. - -Optional keyword arguments: - -- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. -- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, - which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that - issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically - (portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. -- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document - (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). - When the authorization server advertises `client_id_metadata_document_supported: true`, - the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. - Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment, - userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks - them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability. - Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document - served at the URL is a separate JSON artifact from the `client_metadata` keyword above: - the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include - `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. - -To persist credentials across restarts, supply your own storage: - -```ruby -class FileTokenStorage - def initialize(path) - @path = path - end - - def tokens - read["tokens"] - end - - def save_tokens(value) - write("tokens" => value) - end - - def client_information - read["client"] - end - - def save_client_information(value) - write("client" => value) - end - - private - - def read - File.exist?(@path) ? JSON.parse(File.read(@path)) : {} - end - - def write(updates) - File.write(@path, JSON.dump(read.merge(updates))) - end -end - -provider = MCP::Client::OAuth::Provider.new( - # ... required keywords ... - storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")), -) -``` - -##### Client Credentials Grant - -For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. -The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at -the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. - -```ruby -provider = MCP::Client::OAuth::ClientCredentialsProvider.new( - client_id: "my-service", - client_secret: ENV.fetch("MCP_CLIENT_SECRET"), - # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post" - # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) -) - -transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) -``` - -Keyword arguments: - -- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory. -- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`. -- `scope`, `storage`: Optional, same meaning as on `Provider`. - -##### Cross-App Access (JWT Bearer) Grant - -For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. -The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG -to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. -Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. - -`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into -an enterprise secret store or a test double without changing the transport wiring. - -```ruby -provider = MCP::Client::OAuth::CrossAppAccessProvider.new( - client_id: "my-mcp-client", - client_secret: ENV.fetch("MCP_CLIENT_SECRET"), - assertion_provider: ->(audience:, resource:) { - MCP::Client::OAuth::IDJAGTokenExchange.request( - token_endpoint: "https://idp.example.com/token", - id_token: ENV.fetch("IDP_ID_TOKEN"), - client_id: "my-idp-client", - audience: audience, - resource: resource, - ) - }, - # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one) -) - -transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) -``` - -Keyword arguments: - -- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server. -- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. - `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). - Passing both through to `IDJAGTokenExchange.request` covers the common case. -- `scope`, `storage`: Optional, same meaning as on `Provider`. - -##### Communication Security - -When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, -`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host. - -The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through -a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter, -whether the swap was triggered by -`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, -and whether the new URL is `http://` *or* `https://` to a different host or tenant. - -##### Discovery URL Destinations - -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. - -- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server, - so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728, - which does not require it. -- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be - IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7). - The blocked ranges are `0.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`, - and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name. -- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act, - and the authorization server for it usually lives on the same network, so `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. - -The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, -measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. -Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through -`http_client_factory:` is bounded as well, so there is no way to opt out of it. - -#### Customizing the Faraday Connection - -You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. -The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter: - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| - faraday.use MyApp::Middleware::HttpRecorder - faraday.adapter :typhoeus -end -``` - -### Tool Objects - -The client provides a wrapper class for tools returned by the server: - -- `MCP::Client::Tool` - Represents a single tool with its metadata - -This class provides easy access to tool properties like name, description, input schema, and output schema. - -### Multi-Round-Trip Results (Experimental, SEP-2322) - -The MCP 2026-07-28 draft replaces in-flight server-to-client requests with Multi Round-Trip Requests: instead of issuing `sampling/createMessage`, `roots/list`, -or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map -and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`. - -The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, -and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds` -(10 by default, matching the TypeScript and Python SDKs). Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final; -the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords. -`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. - -#### Dual-era authoring (legacy fulfilment shim) - -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. - -## Conformance Testing - -The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). - -See [conformance/README.md](conformance/README.md) for usage instructions. - -## Documentation - -- [SDK API documentation](https://rubydoc.info/gems/mcp) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) file for details. diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md new file mode 100644 index 00000000..1c73eebd --- /dev/null +++ b/docs/_client/authorization.md @@ -0,0 +1,260 @@ +--- +layout: default +title: Authorization +nav_order: 8 +--- + +# Authorization + +Authorization is handled by the transport layer. This page covers authenticating the HTTP transport, +from custom headers such as bearer tokens to the OAuth 2.1 flows the SDK implements +(PKCE with dynamic client registration, the client credentials grant, and cross-app access). + +## HTTP Authorization + +By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication: + +```ruby +http_transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + headers: { + "Authorization" => "Bearer my_token" + } +) + +client = MCP::Client.new(transport: http_transport) +client.tools # will make the call using Bearer auth +``` + +You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request. + +## OAuth 2.1 Authorization + +When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization), +pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will: + +- Send `Authorization: Bearer ` on every request when a token is available. +- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata), + perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token. +- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts + as the authorization base URL, its metadata is fetched from `/.well-known/oauth-authorization-server` without the RFC 8414 issuer byte-match (which the legacy spec predates), + and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. +- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6). +- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy), + run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once. + The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged. +- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata + `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise + `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it. + +```ruby +require "mcp" + +provider = MCP::Client::OAuth::Provider.new( + client_metadata: { + client_name: "My MCP App", + redirect_uris: ["http://localhost:3030/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:3030/callback", + redirect_handler: ->(authorization_url) { + # Send the user to the authorization URL - typically `Launchy.open(authorization_url)` + # or a manual `puts authorization_url` in CLI tools. + }, + callback_handler: -> { + # Capture the redirect (for example, by running a small HTTP listener on + # `redirect_uri`) and return [code, state] from the query string. + }, +) + +transport = MCP::Client::HTTP.new( + url: "https://api.example.com/mcp", + oauth: provider, +) +client = MCP::Client.new(transport: transport) +client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token +client.tools +``` + +Required keyword arguments to `Provider.new`: + +- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`, + `token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`. + When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native); + an explicit value always wins. +- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`. +- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser. +- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form + (with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match + the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`. + +Optional keyword arguments: + +- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. +- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, + which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that + issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically + (portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. +- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document + (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). + When the authorization server advertises `client_id_metadata_document_supported: true`, + the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration. + Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment, + userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks + them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability. + Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document + served at the URL is a separate JSON artifact from the `client_metadata` keyword above: + the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include + `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`. + +{: .warning } +> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents, +> while remaining available for authorization servers that do not support them. Publish a CIMD document and set `client_id_metadata_document_url`; the SDK then prefers it +> automatically wherever the authorization server advertises support. + +To persist credentials across restarts, supply your own storage: + +```ruby +class FileTokenStorage + def initialize(path) + @path = path + end + + def tokens + read["tokens"] + end + + def save_tokens(value) + write("tokens" => value) + end + + def client_information + read["client"] + end + + def save_client_information(value) + write("client" => value) + end + + private + + def read + File.exist?(@path) ? JSON.parse(File.read(@path)) : {} + end + + def write(updates) + File.write(@path, JSON.dump(read.merge(updates))) + end +end + +provider = MCP::Client::OAuth::Provider.new( + # ... required keywords ... + storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")), +) +``` + +### Client Credentials Grant + +For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. +The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at +the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. + +```ruby +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), "client_secret_post", or "private_key_jwt" + # 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`: Required. `client_secret`: Required with the secret-based methods; the grant is + for confidential clients, so a credential is mandatory. +- `token_endpoint_auth_method`: `"client_secret_basic"` (default), `"client_secret_post"`, + or `"private_key_jwt"` (RFC 7523 JWT client assertion per SEP-1046). `"none"` is rejected + with `ClientCredentialsProvider::InvalidCredentialsError`. +- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string + or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` + or `"RS256"`; `client_secret` must not be set, because the private key is the credential. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + +### Cross-App Access (JWT Bearer) Grant + +For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`. +The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG +to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`. +Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK. + +`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into +an enterprise secret store or a test double without changing the transport wiring. + +```ruby +provider = MCP::Client::OAuth::CrossAppAccessProvider.new( + client_id: "my-mcp-client", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + assertion_provider: ->(audience:, resource:) { + MCP::Client::OAuth::IDJAGTokenExchange.request( + token_endpoint: "https://idp.example.com/token", + id_token: ENV.fetch("IDP_ID_TOKEN"), + client_id: "my-idp-client", + audience: audience, + resource: resource, + ) + }, + # scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server. +- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. + `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). + Passing both through to `IDJAGTokenExchange.request` covers the common case. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + +### Communication Security + +When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, +`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host. + +The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through +a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter, +whether the swap was triggered by +`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time, +and whether the new URL is `http://` *or* `https://` to a different host or tenant. + +### Discovery URL Destinations + +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. + +- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server, + so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728, + which does not require it. +- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be + IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7). + The blocked ranges are `0.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`, + and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name. +- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act, + and the authorization server for it usually lives on the same network, so `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. + +The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB, +measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion. +Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through +`http_client_factory:` is bounded as well, so there is no way to opt out of it. diff --git a/docs/_client/cancellation.md b/docs/_client/cancellation.md new file mode 100644 index 00000000..2a9409c8 --- /dev/null +++ b/docs/_client/cancellation.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Cancellation +nav_order: 5 +--- + +# Cancellation + +`MCP::Client` lets the caller cancel a request it has already issued, +per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). +The recommended pattern is to pass +an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call +`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to +the server, and the calling thread is woken up with `MCP::CancelledError`: + +```ruby +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`, `discover`, `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-side +> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP` +> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close` +> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal +> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at +> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it. + +{: .note } +> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight +> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests +> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still +> raises `MCP::CancelledError` - but the server runs the request to completion. + +## Wire-order guarantees + +`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`, +so the server is guaranteed to read the request line before the cancel line. + +`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook, +so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST +on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and +still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)), +and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST +happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering. + +## Custom transports + +Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered. +They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire +(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports). +The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for +the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed. + +## Server Side + +How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page. diff --git a/docs/_client/index.md b/docs/_client/index.md new file mode 100644 index 00000000..1f8e5411 --- /dev/null +++ b/docs/_client/index.md @@ -0,0 +1,46 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /client/ +redirect_from: + - /building-clients.html + - /building-clients/ +--- + +# Building an MCP Client + +The `MCP::Client` class provides an interface for interacting with MCP servers. + +This class supports: + +- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle + when the server serves it; see [Lifecycle](/client/lifecycle/) +- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery) +- Liveness check via the `ping` method (`MCP::Client#ping`) +- Tool listing via the `tools/list` method (`MCP::Client#tools`) +- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`) +- Resource listing via the `resources/list` method (`MCP::Client#resources`) +- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`) +- Resource reading via the `resources/read` method (`MCP::Client#read_resource`) +- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`) +- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`) +- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completions](/server/completions/) +- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`, + or `on_roots` handlers are registered; see [Multi-Round-Trip Results](/client/multi-round-trip-results/) +- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/) +- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with + the `max_pages` guard; see [Pagination](/client/pagination/) +- Automatic JSON-RPC 2.0 message formatting +- UUID request ID generation + +Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics. +Authorization is handled by the transport layer; see [Authorization](/client/authorization/). + +## Tool Objects + +The client provides a wrapper class for tools returned by the server: + +- `MCP::Client::Tool` - Represents a single tool with its metadata + +This class provides easy access to tool properties like name, description, input schema, and output schema. diff --git a/docs/_client/lifecycle.md b/docs/_client/lifecycle.md new file mode 100644 index 00000000..3486b97e --- /dev/null +++ b/docs/_client/lifecycle.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Lifecycle +nav_order: 3 +--- + +# Lifecycle + +Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake +on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28. +This page covers `MCP::Client#connect` and how it negotiates between the two. + +## Handshake + +Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): + +```ruby +client.connect +# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } + +client.connected? # => true +client.server_info # => cached InitializeResult +``` + +`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. + +This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page. + +By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake +only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it. + +## Lifecycle Negotiation + +`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled +`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discovery/) first and adopts +the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) 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. + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe +``` + +Prefer `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: + +```ruby +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 itself +``` + +Troubleshooting: 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. + +## Explicit Discovery + +`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery +that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct +exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and +the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discovery/) page +for the wire shapes. + +```ruby +result = client.discover +result.supported_versions # => ["2026-07-28"] +``` diff --git a/docs/_client/multi-round-trip-results.md b/docs/_client/multi-round-trip-results.md new file mode 100644 index 00000000..7fa994fb --- /dev/null +++ b/docs/_client/multi-round-trip-results.md @@ -0,0 +1,60 @@ +--- +layout: default +title: Multi-Round-Trip Results +nav_order: 4 +--- + +# Multi-Round-Trip Results + +MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): 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`. + +## Automatic Driving + +The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`, +and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds` +(10 by default, matching the TypeScript and Python SDKs). + +```ruby +client = MCP::Client.new(transport: transport) +client.connect(capabilities: { elicitation: { form: {} } }) + +client.on_elicitation do |params| + { action: "accept", content: { name: "Alice" } } +end + +# The input_required round trips are driven automatically; this returns the final result. +response = client.call_tool(name: "collect_name", arguments: {}) +``` + +Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds +the client declared. + +## Manual Driving + +Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final; +the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords: + +```ruby +begin + client.call_tool(name: "collect_name", arguments: {}) +rescue MCP::Client::InputRequiredError => error + answers = error.input_requests.transform_values { |request| answer_for(request) } + + client.call_tool( + name: "collect_name", + arguments: {}, + input_responses: answers, + request_state: error.request_state, + ) +end +``` + +`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility. +Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged. + +## Server Side + +Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment +shim that serves pre-2026 clients are documented on the server [Multi-Round-Trip Results](/server/multi-round-trip-results/) page. diff --git a/docs/_client/pagination.md b/docs/_client/pagination.md new file mode 100644 index 00000000..1f89c020 --- /dev/null +++ b/docs/_client/pagination.md @@ -0,0 +1,80 @@ +--- +layout: default +title: Pagination +nav_order: 7 +--- + +# Pagination + +Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses +per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination). +Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it. + +## Iterating Pages + +`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`. +**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection. +The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor +as method accessors; a `meta` accessor also mirrors the response's `_meta` field: + +```ruby +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. + +## Fetching the Complete Collection + +`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate +through all pages and return a plain array of items, guaranteeing the full collection regardless +of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round +trips per call. 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. + +```ruby +tools = client.tools # => Array 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). + +## Cache Hints + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`) +and whether shared intermediaries may cache it (`cacheScope`); see +[List Result Caching](/server/pagination/#list-result-caching) on the server page for how they are emitted. +On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`: + +```ruby +page = client.list_tools +page.ttl_ms # => 60000 (nil when the server sent no hint) +page.cache_scope # => "private" +``` + +## Server Side + +Enabling pagination with `page_size:` is documented on the server [Pagination](/server/pagination/) page. diff --git a/docs/_client/ping.md b/docs/_client/ping.md new file mode 100644 index 00000000..e01e21bb --- /dev/null +++ b/docs/_client/ping.md @@ -0,0 +1,35 @@ +--- +layout: default +title: Ping +nav_order: 6 +--- + +# Ping + +The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping) +allows either side of the connection to verify that the peer is still responsive. + +{: .note } +> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of +> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself +> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle, +> so on a modern connection a conforming server (the Ruby SDK server included) rejects it +> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility. + +`MCP::Client` exposes `ping` to send a ping to the server: + +```ruby +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. + +## Server Side + +How servers answer `ping` requests and ping the client themselves is documented on +the server [Ping](/server/ping/) page. diff --git a/docs/_client/transports.md b/docs/_client/transports.md new file mode 100644 index 00000000..47203d6e --- /dev/null +++ b/docs/_client/transports.md @@ -0,0 +1,261 @@ +--- +layout: default +title: Transports +nav_order: 2 +--- + +# Transports + +`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with. +This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports, +their sessions and server-to-client request handling, and the interface a custom transport must implement. + +## Stdio Transport Layer + +Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output. + +`MCP::Client::Stdio.new` accepts the following keyword arguments: + +| Parameter | Required | Description | +|---|---|---| +| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). | +| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | +| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. | +| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). | +| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | + +Example usage: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +# 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: + +- Spawning the server process with `Open3.popen3` +- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`) +- JSON-RPC 2.0 message framing over newline-delimited JSON + +## HTTP Transport Layer + +Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests. + +You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses: + +```ruby +gem "mcp" +gem "faraday", ">= 2.0" +gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses +``` + +Example usage: + +```ruby +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 sends `notifications/progress` during execution; the bundled transports do not currently +expose these notifications to application code, so the token's effect is visible on the server side. +See the [Progress](/server/progress/) page. + +`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. + +### Sessions + +After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: + +```ruby +http_transport.session_id # => "abc123..." +http_transport.protocol_version # => "2025-11-25" +``` + +If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. + +To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. + +```ruby +http_transport.close +``` + +These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is +no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead. + +### Server-to-Client Requests (Elicitation) + +Servers can send requests back to the client while one of the client's own requests is in flight - for example, +[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call. +Register a handler and advertise the capability on `connect` to respond to them: + +```ruby +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"]), + } +end +``` + +Registering a handler opens a standalone HTTP GET SSE stream on a background thread +([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#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 with +`http_transport.on_server_request("method/name") { |params| ... }`. + +On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same +registered handlers instead drive the requests embedded in `input_required` results, +as documented on [Multi-Round-Trip Results](/client/multi-round-trip-results/). + +### Server-to-Client Requests (Sampling) + +Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), +letting a server leverage the client's model access without its own API keys. + +{: .warning } +> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`. +> Register this handler to interoperate with servers that still send sampling requests during the deprecation window; +> new servers should call LLM provider APIs directly. + +Register a handler and advertise the capability on `connect`: + +```ruby +client.connect(capabilities: { sampling: {} }) + +client.on_sampling do |params| + completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"]) + { + role: "assistant", + content: { type: "text", text: completion.text }, + model: completion.model, + stopReason: "endTurn", + } +end +``` + +For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response. +To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`: + +```ruby +client.on_sampling do |params| + raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params) + + generate_completion(params) +end +``` + +Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream. + +### Custom Headers from Tool Parameters + +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 per SEP-2243, 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 from +`tools/list` results on modern connections, with a warning naming the tool. +Legacy connections are unaffected: nothing is learned, mirrored, or excluded. + +### Customizing the Faraday Connection + +You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection. +The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter: + +```ruby +http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| + faraday.use MyApp::Middleware::HttpRecorder + faraday.adapter :typhoeus +end +``` + +## Custom Transports + +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: + +```ruby +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 + +client = MCP::Client.new(transport: CustomTransport.new) +``` + +Custom transports that need to support client-side cancellation have additional requirements; +see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page. diff --git a/docs/_config.yml b/docs/_config.yml index b589e4ed..4f101a50 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,18 +2,44 @@ title: "MCP Ruby SDK" description: "The official Ruby SDK for Model Context Protocol servers and clients." remote_theme: just-the-docs/just-the-docs +color_scheme: ruby-light -# Include generated files and directories which may start with underscores -include: - - "_*" +plugins: + - jekyll-redirect-from + +# Sidebar sections: each collection renders as a plain category heading +# with its pages listed beneath it, instead of a clickable parent page. +collections: + server: + permalink: "/server/:name/" + output: true + client: + permalink: "/client/:name/" + output: true + extensions: + permalink: "/extensions/:name/" + output: true + +just_the_docs: + collections: + server: + name: Building Servers + client: + name: Building Clients + extensions: + name: Extensions # Search search_enabled: true -# Footer -gh_edit_link: true -gh_edit_link_text: "Edit this page on GitHub." -gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk" -gh_edit_branch: "main" -gh_edit_source: "docs" -gh_edit_view_mode: "edit" +# Callouts +callouts: + note: + title: Note + color: blue + important: + title: Important + color: yellow + warning: + title: Warning + color: red diff --git a/docs/_extensions/capability-extensions.md b/docs/_extensions/capability-extensions.md new file mode 100644 index 00000000..3415ae8b --- /dev/null +++ b/docs/_extensions/capability-extensions.md @@ -0,0 +1,35 @@ +--- +layout: default +title: Capability Extensions +nav_order: 2 +--- + +# Capability Extensions + +Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities. +Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`); +values are extension-defined configuration objects, with `{}` meaning "supported with no settings". + +On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder: + +```ruby +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 [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result, +and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake. +Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations +with envelope-first resolution, on either lifecycle. + +On the client, pass extensions through `connect`: + +```ruby +client.connect(capabilities: { extensions: { "com.example/feature" => {} } }) +``` diff --git a/docs/_extensions/index.md b/docs/_extensions/index.md new file mode 100644 index 00000000..5b52c0fd --- /dev/null +++ b/docs/_extensions/index.md @@ -0,0 +1,17 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /extensions/ +--- + +# Extensions + +Extensions add optional functionality on top of the core MCP protocol. +Support for an extension is negotiated per connection: +clients and servers declare the extensions they speak under the `extensions` member of their capabilities, +as described on [Capability Extensions](/extensions/capability-extensions/). + +The extensions this SDK ships support for: + +- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results diff --git a/docs/_extensions/mcp-apps.md b/docs/_extensions/mcp-apps.md new file mode 100644 index 00000000..0c9c6684 --- /dev/null +++ b/docs/_extensions/mcp-apps.md @@ -0,0 +1,48 @@ +--- +layout: default +title: MCP Apps +nav_order: 3 +--- + +# MCP Apps + +MCP Apps (SEP-1865) is a Final extension (negotiated via [Capability Extensions](/extensions/capability-extensions/)) 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: + +```ruby +capabilities = MCP::Server::Capabilities.new +capabilities.support_tools +capabilities.support_resources +capabilities.support_extensions(MCP::Apps.capability) # { "io.modelcontextprotocol/ui" => { mimeTypes: [...] } } + +server = MCP::Server.new( + name: "weather_server", + capabilities: capabilities, + # UI templates are ordinary resources with a `ui://` URI and the `text/html;profile=mcp-app` MIME type. + resources: [MCP::Apps.ui_resource(uri: "ui://weather-server/dashboard", name: "weather_dashboard")], +) + +server.resources_read_handler do |params| + [{ uri: params[:uri], mimeType: MCP::Apps::RESOURCE_MIME_TYPE, text: "..." }] +end + +# Link the tool to its template via `_meta.ui.resourceUri` (pass `legacy: true` to also +# emit the older flat `"ui/resourceUri"` alias for hosts that predate the Final spec). +server.define_tool( + name: "get_weather", + meta: MCP::Apps.tool_meta(resource_uri: "ui://weather-server/dashboard"), +) do |server_context:| + # The extension is optional: always return a meaningful text result, and use + # `MCP::Apps.client_supports?` when UI-capable clients should get richer structured content. + MCP::Apps.client_supports?(server_context.client_capabilities) # => true when the host declared the extension + MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }]) +end +``` + +`MCP::Apps.tool_meta` also accepts `visibility:` (an array of `"model"` / `"app"`) to restrict who sees the tool, +and merges non-destructively into caller-supplied `meta:`. + +Everything else the extension defines (the sandboxed iframe, the `ui/*` `postMessage` bridge, consent for UI-initiated actions) +is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. +See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). diff --git a/docs/_includes/footer_custom.html b/docs/_includes/footer_custom.html new file mode 100644 index 00000000..bfd1278b --- /dev/null +++ b/docs/_includes/footer_custom.html @@ -0,0 +1,48 @@ +{%- comment -%} + Previous/Next pager following the sidebar order: top-level pages by + nav_order, then the server, client, and extensions collections. +{%- endcomment -%} +{%- assign top_pages = site.html_pages | where_exp: "item", "item.nav_order" | sort: "nav_order" -%} +{%- assign server_docs = site.server | sort: "nav_order" -%} +{%- assign client_docs = site.client | sort: "nav_order" -%} +{%- assign extensions_docs = site.extensions | sort: "nav_order" -%} +{%- assign ordered_pages = top_pages | concat: server_docs | concat: client_docs | concat: extensions_docs -%} +{%- assign pager_state = "before" -%} +{%- assign prev_page = nil -%} +{%- assign next_page = nil -%} +{%- for item in ordered_pages -%} + {%- if pager_state == "current" -%} + {%- assign next_page = item -%} + {%- assign pager_state = "after" -%} + {%- elsif item.url == page.url -%} + {%- assign pager_state = "current" -%} + {%- elsif pager_state == "before" -%} + {%- assign prev_page = item -%} + {%- endif -%} +{%- endfor -%} +{%- if pager_state != "before" -%} + +{%- endif -%} diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html index 4a919ce1..34a88e62 100644 --- a/docs/_includes/head_custom.html +++ b/docs/_includes/head_custom.html @@ -1,3 +1,5 @@ + + + + diff --git a/docs/_sass/color_schemes/ruby-dark.scss b/docs/_sass/color_schemes/ruby-dark.scss new file mode 100644 index 00000000..5bd0ef1f --- /dev/null +++ b/docs/_sass/color_schemes/ruby-dark.scss @@ -0,0 +1,17 @@ +// Dark counterpart of ruby-light: the link color is lightened from +// the #b61d1d accent so it keeps WCAG AA contrast, and the theme's +// purple-tinted dark greys become neutral ones. +@import "./color_schemes/dark"; + +$link-color: #f0776c; +$btn-primary-color: #b61d1d; + +// The dark scheme derives $feedback-color before these overrides apply, +// so it is restated. +$body-background-color: #252525; +$sidebar-color: #252525; +$feedback-color: #1e1e1e; +$search-background-color: #202020; +$table-background-color: #202020; +$base-button-color: #202020; +$border-color: #3f3f3f; diff --git a/docs/_sass/color_schemes/ruby-light.scss b/docs/_sass/color_schemes/ruby-light.scss new file mode 100644 index 00000000..5da81c31 --- /dev/null +++ b/docs/_sass/color_schemes/ruby-light.scss @@ -0,0 +1,8 @@ +// The include imports the light scheme first, so only overrides live here. +$link-color: #990000; +$btn-primary-color: #b61d1d; +$sidebar-color: #fff; + +// The light scheme derives $feedback-color before these overrides apply, +// so it is restated (the hover shade on the white sidebar). +$feedback-color: #f2f2f2; diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss index 498de1b3..7f898476 100644 --- a/docs/_sass/custom/custom.scss +++ b/docs/_sass/custom/custom.scss @@ -65,13 +65,85 @@ /* * Applied before the dark stylesheet finishes loading, so browsers that * do not support blocking="render" avoid a light-colored first paint. - * Values match $grey-dk-300 and $grey-lt-300 from the dark color scheme. + * Values match $body-background-color from the ruby-dark color scheme + * and $grey-lt-300 for the text. */ :root[data-theme="dark"] body { - background-color: #27262b; + background-color: #252525; color: #e6e1e8; } :root[data-theme="light"] { color-scheme: light; } + +/* + * Center the sidebar-plus-content block as one unit, splitting the leftover viewport equally + * on both sides. The theme instead widens the sidebar to absorb the whole left overflow, + * and its margin rule lives on `.side-bar + .main`, which a bare `.main` selector cannot override. + */ +$docs-content-width: 64rem; + +@media (min-width: 66.5rem) { + .side-bar { + /* Capitalized Max/Min pass through the Sass compiler as plain CSS. */ + left: Max(0px, calc((100% - #{$nav-width + $docs-content-width}) / 2)); + width: $nav-width; + } + + .side-bar + .main { + max-width: $docs-content-width; + margin-left: Max(#{$nav-width}, calc((100% - #{$nav-width + $docs-content-width}) / 2 + #{$nav-width})); + } + + /* Breathing room between the sidebar and the content. */ + .main-content-wrap { + padding-left: 3rem; + } + + .main-header { + padding-left: 1rem; + } +} + +/* Previous/Next pager in the page footer. */ +.docs-pager { + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.docs-pager-link { + display: flex; + flex-direction: column; + max-width: 48%; + text-decoration: none; +} + +.docs-pager-next { + margin-left: auto; + text-align: right; +} + +.docs-pager-direction { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $body-text-color; + opacity: 0.6; +} + +.docs-pager-title { + font-weight: 600; +} + +/* + * Category headings read as section labels, not separators: no underline, + * spaced apart from the preceding group and letter-spaced instead. + */ +.nav-category { + margin-top: 1.5rem; + letter-spacing: 0.08em; + border-bottom: none; + opacity: 0.65; +} diff --git a/docs/_sass/custom/setup.scss b/docs/_sass/custom/setup.scss new file mode 100644 index 00000000..b34691c1 --- /dev/null +++ b/docs/_sass/custom/setup.scss @@ -0,0 +1,2 @@ +// Widen the sidebar from the theme's 16.5rem default. +$nav-width: 19rem; diff --git a/docs/_server/cancellation.md b/docs/_server/cancellation.md new file mode 100644 index 00000000..207dfcf3 --- /dev/null +++ b/docs/_server/cancellation.md @@ -0,0 +1,137 @@ +--- +layout: default +title: Cancellation +nav_order: 13 +--- + +# Cancellation + +The MCP Ruby SDK supports server-side handling of +the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation). +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`](/server/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 } +> Cancellation by notification belongs to the handshake lifecycle, where the session correlates +> `notifications/cancelled` with the in-flight request it targets. Requests of the +> [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are sessionless single POST exchanges, so a separately +> POSTed cancel notification cannot reach them; a modern client abandons a request by closing +> the connection instead. + +## Handlers that Check for Cancellation + +Any handler that opts in to `server_context:` - tools (`Tool.call`), prompt templates, +`resources_read_handler`, `resources_list_handler`, `completion_handler`, `resources_subscribe_handler`, +`resources_unsubscribe_handler`, and [`define_custom_method`](/server/custom-methods/) 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: + +```ruby +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!`: + +```ruby +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: + +```ruby +# 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. + +## Nested Server-to-Client Requests Are Cancelled Automatically + +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: + +```ruby +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. + +## Client Side + +Cancelling a request the client has issued (the `cancellation:` keyword, wire-order guarantees, +and custom transport requirements) is documented on the client [Cancellation](/client/cancellation/) page. diff --git a/docs/_server/completions.md b/docs/_server/completions.md new file mode 100644 index 00000000..170848e2 --- /dev/null +++ b/docs/_server/completions.md @@ -0,0 +1,52 @@ +--- +layout: default +title: Completions +nav_order: 16 +--- + +# Completions + +MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion), +which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs. + +To enable completions, declare the `completions` capability and register a handler: + +```ruby +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. diff --git a/docs/_server/configuration.md b/docs/_server/configuration.md new file mode 100644 index 00000000..0185cb5b --- /dev/null +++ b/docs/_server/configuration.md @@ -0,0 +1,206 @@ +--- +layout: default +title: Configuration +nav_order: 20 +--- + +# Configuration + +The gem can be configured using the `MCP.configure` block: + +```ruby +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. + +```ruby +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:, +) +``` + +## Exception Reporter + +The exception reporter receives two arguments: + +- `exception`: The Ruby exception object that was raised +- `server_context`: A hash containing contextual information about where the error occurred. + This is not the user-defined [`server_context`](/server/server-context/) passed to `Server.new`. + +The `server_context` hash includes: + +- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) +- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) + +**Signature:** + +```ruby +exception_reporter = ->(exception, server_context) { ... } +``` + +When an exception occurs: + +1. The exception is reported via the configured reporter +2. The client receives a generic JSON-RPC error response (for example, "Internal error calling tool " + for a tool call); the exception's own message is deliberately withheld from clients + +If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. + +## Around Request + +The `around_request` hook wraps request handling, allowing you to execute code before and after each request. +This is useful for Application Performance Monitoring (APM) tracing, logging, or other observability needs. + +The hook receives a `data` hash and a `request_handler` block. You must call `request_handler.call` to execute the request: + +**Signature:** + +```ruby +around_request = ->(data, &request_handler) { request_handler.call } +``` + +**`data` availability by timing:** + +- Before `request_handler.call`: `method`, and `server_context` when `instrument_server_context` is enabled +- After `request_handler.call`: `tool_name`, `tool_arguments`, `prompt_name`, `resource_uri`, `error`, `client` +- Not available inside `around_request`: `duration` (added after `around_request` returns) + +{: .note } +> `tool_name`, `prompt_name` and `resource_uri` may only be populated for the corresponding request methods +> (`tools/call`, `prompts/get`, `resources/read`), and may not be set depending on how the request is handled +> (for example, `prompt_name` is not recorded when the prompt is not found). +> `duration` is added after `around_request` returns, so it is not visible from within the hook. + +**Example:** + +```ruby +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 +``` + +### Exposing the User-Defined `server_context` + +`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: + +```ruby +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 + } +end +``` + +`data[: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. + +## Instrumentation Callback (soft-deprecated) + +{: .note } +> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. +> +> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: +> +> ```ruby +> # Before +> config.instrumentation_callback = ->(data) { log(data) } +> +> # After +> config.around_request = ->(data, &request_handler) do +> request_handler.call +> ensure +> log(data) +> end +> ``` +> +> Note that `data[:duration]` is not available inside `around_request`. +> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. + +The instrumentation callback is called after each request finishes, whether successfully or with an error. +It receives a hash with the following possible keys: + +- `method`: (String) The protocol method called (e.g., "ping", "tools/list") +- `tool_name`: (String, optional) The name of the tool called +- `tool_arguments`: (Hash, optional) The arguments passed to the tool +- `prompt_name`: (String, optional) The name of the prompt called +- `resource_uri`: (String, optional) The URI of the resource called +- `error`: (String, optional) Error code if a lookup failed +- `duration`: (Float) Duration of the call in seconds +- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request +- `server_context`: (Any, optional) The user-defined hash passed to `Server.new`, present only when + `instrument_server_context` is enabled + +**Signature:** + +```ruby +instrumentation_callback = ->(data) { ... } +``` + +## Server Protocol Version + +The server's protocol version can be overridden using the `protocol_version` keyword argument: + +```ruby +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`: + +```ruby +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`](/server/discovery/) 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](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. diff --git a/docs/_server/custom-methods.md b/docs/_server/custom-methods.md new file mode 100644 index 00000000..85dc87a3 --- /dev/null +++ b/docs/_server/custom-methods.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Custom Methods +nav_order: 21 +--- + +# Custom Methods + +The server allows you to define custom JSON-RPC methods beyond the standard MCP protocol methods using the `define_custom_method` method: + +```ruby +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:** + +- Accepts any method name as a string +- Block receives the request parameters as a hash +- Can handle both regular methods (with responses) and notifications +- Prevents overriding existing MCP protocol methods +- Supports instrumentation callbacks for monitoring +- Blocks may opt in to a [`server_context:`](/server/server-context/) keyword like the built-in handlers + (see [Cancellation](/server/cancellation/) for an example) + +**Usage Example:** + +The wire exchange for the custom `add` method defined above. The client sends: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "add", + "params": { "a": 5, "b": 3 } +} +``` + +The server responds: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": 8 +} +``` + +**Error Handling:** + +- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method +- Supports the same [exception reporting and instrumentation](/server/configuration/) as standard methods diff --git a/docs/_server/discovery.md b/docs/_server/discovery.md new file mode 100644 index 00000000..ec9fa185 --- /dev/null +++ b/docs/_server/discovery.md @@ -0,0 +1,61 @@ +--- +layout: default +title: Discovery +nav_order: 3 +--- + +# Discovery + +`server/discover` is the sessionless capability discovery method of MCP 2026-07-28 (SEP-2575). +It responds before `initialize` and without an `Mcp-Session-Id`, so a client learns what a server +offers in a single exchange, without creating a session. + +## The Discovery Result + +The result carries the modern `supportedVersions`, `capabilities`, and `instructions`, together with +the required `ttlMs`/`cacheScope` cache hints, and the server identity as the optional +`io.modelcontextprotocol/serverInfo` stamp in the result `_meta`. + +The request takes no parameters: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "server/discover" } +``` + +The server answers with the full discovery result: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "supportedVersions": ["2026-07-28"], + "capabilities": { "tools": { "listChanged": true } }, + "instructions": "Use the tools of this server as a last resort", + "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "my_server", "version": "1.0.0" } }, + "ttlMs": 0, + "cacheScope": "private", + "resultType": "complete" + } +} +``` + +## The Stateless Modern Lifecycle + +Beyond answering `server/discover`, the server serves the full stateless modern lifecycle: requests carrying the SEP-2575 `_meta` envelope +(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, and `clientCapabilities`) are validated per request, +and the Streamable HTTP transport serves them on a sessionless single-exchange path. +Calling `server/discover` first is not required: the envelope alone selects the modern lifecycle for a request, +while a client performing the classic `initialize` handshake is served on the handshake lifecycle - +the lifecycle is a per-request property, not a server-wide mode. + +The bundled transports serve `server/discover` and the per-request `_meta` envelope alongside the `initialize` handshake +with no configuration needed. The `protocol_version` pin scopes the handshake only +and does not affect the modern lifecycle; see [Configuration](/server/configuration/) for details. + +## Client Side + +On the client, `MCP::Client#connect` negotiates the lifecycle automatically by default +(probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips +the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` +exposes the raw discovery result. See the client-side [Lifecycle](/client/lifecycle/) page for details. diff --git a/docs/_server/elicitation.md b/docs/_server/elicitation.md new file mode 100644 index 00000000..f5591553 --- /dev/null +++ b/docs/_server/elicitation.md @@ -0,0 +1,231 @@ +--- +layout: default +title: Elicitation +nav_order: 9 +--- + +# Elicitation + +The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/latest/client/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. + +{: .note } +> Unlike [roots](/server/roots/) and [sampling](/server/sampling/), elicitation carries no SEP-2577 deprecation +> and remains fully available. On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28), which forbids server-initiated requests, +> an `elicitation/create` request is embedded in an `input_required` result instead; +> see [Multi-Round-Trip Results](/server/multi-round-trip-results/). + +{: .important } +> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with +> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association +> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding +> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning. + +## Capabilities + +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. + +## Using Elicitation in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `create_form_elicitation` on it: + +```ruby +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 + +Form mode collects structured data from the user directly through the MCP client: + +```ruby +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 +``` + +The `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. + +## Default Values and Enums + +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: + +```ruby +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]}" }]) +end +``` + +## Enum Schemas + +For enumerated choices, use `MCP::Elicitation::EnumSchema` to construct the canonical schema shapes per +[SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/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: + +```ruby +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: + +```ruby +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 + +URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows: + +```ruby +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 +``` + +## URLElicitationRequiredError + +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: + +```ruby +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 +``` + +## 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: + +```ruby +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. + +The same timeout applies to [roots](/server/roots/) and [sampling](/server/sampling/) requests. diff --git a/docs/_server/index.md b/docs/_server/index.md new file mode 100644 index 00000000..d0921a4b --- /dev/null +++ b/docs/_server/index.md @@ -0,0 +1,53 @@ +--- +layout: default +title: Overview +nav_order: 1 +permalink: /server/ +redirect_from: + - /building-servers.html + - /building-servers/ +--- + +# Building an MCP Server + +The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. +It implements the Model Context Protocol specification, handling model context requests and responses. + +## Key Features + +- Implements JSON-RPC 2.0 message handling +- Supports protocol initialization and capability negotiation +- Manages tool registration and invocation +- Supports prompt registration and execution +- Supports resource registration and retrieval +- Supports stdio and Streamable HTTP (including SSE) transports +- Supports notifications for list changes (tools, prompts, resources) +- Supports roots (server-to-client filesystem boundary queries) +- Supports sampling (server-to-client LLM completion requests) +- Supports cursor-based pagination for list operations +- Supports cancellation of in-flight requests on both server and client (notifications/cancelled) + +## Supported Methods + +- `initialize` - Initializes the protocol and returns server capabilities +- `server/discover` - Sessionless capability discovery (MCP 2026-07-28, SEP-2575): returns the server's capabilities + before `initialize` and without an `Mcp-Session-Id`, and anchors the stateless modern lifecycle; see [Discovery](/server/discovery/) +- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET + listening stream; see [Notification Subscriptions](/server/notification-subscriptions/) +- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask + the client for additional input instead of performing a server-initiated request; see [Multi-Round-Trip Results](/server/multi-round-trip-results/) +- `ping` - Simple health check +- `logging/setLevel` - Configures the minimum log level for the server +- `tools/list` - Lists all registered tools and their schemas +- `tools/call` - Invokes a specific tool with provided arguments +- `prompts/list` - Lists all registered prompts and their schemas +- `prompts/get` - Retrieves a specific prompt by name +- `resources/list` - Lists all registered resources and their schemas +- `resources/read` - Retrieves a specific resource by name +- `resources/templates/list` - Lists all registered resource templates and their schemas +- `resources/subscribe` - Subscribes to updates for a specific resource +- `resources/unsubscribe` - Unsubscribes from updates for a specific resource +- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs +- `roots/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) diff --git a/docs/_server/logging.md b/docs/_server/logging.md new file mode 100644 index 00000000..c2f02304 --- /dev/null +++ b/docs/_server/logging.md @@ -0,0 +1,90 @@ +--- +layout: default +title: Logging +nav_order: 17 +--- + +# Logging + +The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +The `notifications/message` notification is used for structured logging between client and server. + +{: .warning } +> MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. Use stderr or OpenTelemetry for new servers. + +{: .note } +> On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), where `logging/setLevel` does not exist, the level +> comes per request from the `io.modelcontextprotocol/logLevel` `_meta` member: it authorizes +> `notifications/message` for that request only, delivered on the request's own response stream. +> A request without the member (or with an unrecognized level) receives no log messages. + +## Log Levels + +The SDK supports 8 log levels with increasing severity: + +- `debug` - Detailed debugging information +- `info` - General informational messages +- `notice` - Normal but significant events +- `warning` - Warning conditions +- `error` - Error conditions +- `critical` - Critical conditions +- `alert` - Action must be taken immediately +- `emergency` - System is unusable + +## How Logging Works + +1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level +2. **Server Filtering**: The server only sends log messages at the configured level or higher severity +3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client + +For 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](https://modelcontextprotocol.io/specification/latest/server/utilities/logging). + +**Usage Example:** + +The client first configures the level with a `logging/setLevel` request: + +```json +{ "jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": { "level": "info" } } +``` + +The server then emits messages at each severity; only those at or above the configured level are delivered: + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) + +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:** + +- Server has capability `logging` to send log messages +- Messages are only sent if a transport is configured +- Messages are filtered based on the client's configured log level +- If the log level hasn't been set by the client, no messages will be sent + +## Transport Support + +- **stdio**: Notifications are sent as JSON-RPC 2.0 messages to stdout +- **Streamable HTTP**: Notifications are sent as JSON-RPC 2.0 messages over HTTP with streaming (chunked transfer or SSE) diff --git a/docs/_server/multi-round-trip-results.md b/docs/_server/multi-round-trip-results.md new file mode 100644 index 00000000..44a8f0c3 --- /dev/null +++ b/docs/_server/multi-round-trip-results.md @@ -0,0 +1,85 @@ +--- +layout: default +title: Multi-Round-Trip Results +nav_order: 10 +--- + +# Multi-Round-Trip Results + +The [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28) forbids server-initiated requests. Instead, per SEP-2322, a `tools/call`, `prompts/get`, or `resources/read` handler that +opts in to [`server_context:`](/server/server-context/) may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for +additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes): + +```ruby +class GreetingTool < MCP::Tool + description "Greets the user by name" + + def self.call(server_context:, **_args) + response = server_context.input_response("user_name") + + unless response + return MCP::Server::InputRequiredResult.new( + input_requests: { + user_name: { + method: "elicitation/create", + params: { + message: "What is your name?", + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + ) + end + + MCP::Tool::Response.new([{ type: "text", text: "Hello, #{response.dig(:content, :name)}!" }]) + end +end +``` + +## Deterministic Replay + +On the retried request the handler re-runs from the start and reads the answers via +`server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` +(deterministic replay; the server holds no memory between rounds). The server returns `-32021` +when an embedded request needs a client capability the request did not declare. + +## Securing `requestState` + +The echoed `requestState` arrives as client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via +`Server.new(request_state_security:)` to have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, +all transparently to handlers. Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses +the wire exactly as the handler wrote it and protecting it is the handler author's responsibility. + +```ruby +server = MCP::Server.new( + name: "my_server", + tools: [GreetingTool], + request_state_security: MCP::Server::RequestStateSecurity.new(key: ENV.fetch("MCP_REQUEST_STATE_KEY")), +) +``` + +## `resultType` Stamping + +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. + +## Legacy Clients + +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. + +## Client Side + +`call_tool`, `get_prompt`, and `read_resource` drive `input_required` results automatically once the matching handlers are registered; +see the client [Multi-Round-Trip Results](/client/multi-round-trip-results/) page. diff --git a/docs/_server/notification-subscriptions.md b/docs/_server/notification-subscriptions.md new file mode 100644 index 00000000..33e23171 --- /dev/null +++ b/docs/_server/notification-subscriptions.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Notification Subscriptions +nav_order: 12 +--- + +# Notification Subscriptions + +`subscriptions/listen` is the long-lived notification subscription stream of MCP 2026-07-28 (SEP-2575), +replacing the legacy HTTP GET listening stream. + +## Subscribing + +The client opts in via the `notifications` filter (`toolsListChanged` / `promptsListChanged` / `resourcesListChanged` / `resourceSubscriptions`), +the server acknowledges the honored subset with `notifications/subscriptions/acknowledged` as the first stream message, +and every delivered notification carries the correlating `io.modelcontextprotocol/subscriptionId` in `_meta`. +The honored subset follows the capabilities the server declares (`listChanged` and `subscribe` flags). + +The client opens the stream with a `subscriptions/listen` request: + +```json +{ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": { + "notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///a.txt"] }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "my_client", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +``` + +The first SSE event on the stream is the acknowledgement: + +```json +{ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///a.txt"] }, + "_meta": { "io.modelcontextprotocol/subscriptionId": "listen-1" } + } +} +``` + +## Transport Support + +The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. + +## Limits and Keepalives + +Concurrent streams are capped by `max_listen_subscriptions:` (default 1000; pass `nil` to remove the cap), and each stream receives an SSE keepalive +comment frame every `listen_keepalive_interval:` seconds (default 15) so a dropped connection frees its slot; pass `listen_keepalive_interval: nil` +when an upstream proxy already keeps the stream alive. + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + max_listen_subscriptions: 500, + listen_keepalive_interval: 30, +) +``` + +A stream stays open until the client closes the connection; on graceful shutdown via `transport.close`, +each open stream receives its `subscriptions/listen` response before closing. + +See [Notifications](/server/notifications/) for the notification types themselves and their session scoping. diff --git a/docs/_server/notifications.md b/docs/_server/notifications.md new file mode 100644 index 00000000..9c87fb8a --- /dev/null +++ b/docs/_server/notifications.md @@ -0,0 +1,68 @@ +--- +layout: default +title: Notifications +nav_order: 11 +--- + +# Notifications + +The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling. + +## Notification Methods + +The server provides the following notification methods: + +- `notify_tools_list_changed` - Send a notification when the tools list changes +- `notify_prompts_list_changed` - Send a notification when the prompts list changes +- `notify_resources_list_changed` - Send a notification when the resources list changes +- `notify_log_message` - Send a structured logging notification message (see [Logging](/server/logging/)) + +## Session Scoping + +When 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`](/server/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. + +On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28), clients receive these broadcasts through the `subscriptions/listen` stream; +see [Notification Subscriptions](/server/notification-subscriptions/). + +## Notification Format + +Notifications follow the JSON-RPC 2.0 specification and use these method names: + +- `notifications/tools/list_changed` +- `notifications/prompts/list_changed` +- `notifications/resources/list_changed` +- `notifications/cancelled` (see [Cancellation](/server/cancellation/)) +- `notifications/progress` (see [Progress](/server/progress/)) +- `notifications/message` (see [Logging](/server/logging/)) + +## Broadcasting a List Change + +Call the matching `notify_*` method after changing a server-wide list: + +```ruby +server = 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| MCP::Tool::Response.new([{ type: "text", text: "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_changed +``` diff --git a/docs/_server/pagination.md b/docs/_server/pagination.md new file mode 100644 index 00000000..c721f18a --- /dev/null +++ b/docs/_server/pagination.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Pagination +nav_order: 18 +--- + +# Pagination + +The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/latest/server/utilities/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`, +including lists produced by a custom `resources_list_handler`. + +## Enabling Pagination + +Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default), +list responses contain all items in a single response, preserving the pre-pagination behavior. + +```ruby +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: + +```json +{ + "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. + +## List Result Caching + +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; legacy protocol versions keep the opt-in behavior. + +```ruby +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: + +```ruby +server.resources_read_handler do |params| + { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "..." }], ttlMs: 5_000 } +end +``` + +## Client Side + +Iterating pages, fetching whole collections with the `max_pages` guard, and reading the cache hints +on the result structs are documented on the client [Pagination](/client/pagination/) page. diff --git a/docs/_server/ping.md b/docs/_server/ping.md new file mode 100644 index 00000000..c247fef7 --- /dev/null +++ b/docs/_server/ping.md @@ -0,0 +1,50 @@ +--- +layout: default +title: Ping +nav_order: 15 +--- + +# Ping + +The MCP Ruby SDK supports +the [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping), +which allows either side of the connection to verify that the peer is still responsive. +A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result. + +{: .note } +> `ping` belongs to the handshake lifecycle: MCP 2026-07-28 removes the method altogether (SEP-2575), +> since requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are single POST exchanges whose connection +> itself signals liveness, leaving nothing to probe between requests. The server answers `ping` on +> the handshake lifecycle only - a modern request naming it is rejected with `-32601` Method not found - +> and calling `ping` on a `server_context` while serving a modern request raises an error. The long-lived +> [`subscriptions/listen`](/server/notification-subscriptions/) stream is kept alive by SSE keepalive +> frames instead. + +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`. +`ping` is exempt from the SEP-2260 association requirement, so it may also be sent outside a handler. +Inside a tool handler that receives [`server_context:`](/server/server-context/), call `ping` on it: + +```ruby +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. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Client Side + +Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page. diff --git a/docs/_server/progress.md b/docs/_server/progress.md new file mode 100644 index 00000000..1a4a8e82 --- /dev/null +++ b/docs/_server/progress.md @@ -0,0 +1,64 @@ +--- +layout: default +title: Progress +nav_order: 14 +--- + +# Progress + +The MCP Ruby SDK supports progress tracking for long-running tool operations, +following the [MCP Progress specification](https://modelcontextprotocol.io/specification/latest/server/utilities/progress). + +## How Progress Works + +1. **Client Request**: The client sends a `progressToken` in the `_meta` field when calling a tool +2. **Server Notification**: The server sends `notifications/progress` messages back to the client during tool execution +3. **Tool Integration**: Tools call `server_context.report_progress` to report incremental progress + +## Reporting Progress from Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `report_progress` on it. +The server automatically wraps the context in an `MCP::ServerContext` instance that provides this method: + +```ruby +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 percentage +- `message:` (optional) - human-readable status message + +`report_progress` is a no-op when the request carried no `progressToken`, and both numeric and +string tokens are supported. + +{: .note } +> On the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), progress notifications emitted during a request ride +> the request's own SSE response stream. The bundled transport buffers them and flushes after +> the handler returns, so they preserve order but arrive together with the final response rather +> than in real time. + +## Client Side + +Requesting progress is the client's side of the contract: pass `progress_token:` to `MCP::Client#call_tool` +and the token is sent as `_meta.progressToken`, as shown on the client [Transports](/client/transports/) page. +The bundled client transports do not currently expose a callback for observing the incoming `notifications/progress` messages; +the token's effect is visible on the server side only. diff --git a/docs/_server/prompts.md b/docs/_server/prompts.md new file mode 100644 index 00000000..e8f5a484 --- /dev/null +++ b/docs/_server/prompts.md @@ -0,0 +1,194 @@ +--- +layout: default +title: Prompts +nav_order: 5 +--- + +# Prompts + +MCP spec includes [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts), which enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. + +## Defining Prompts + +The `MCP::Prompt` class provides three ways to create prompts. + +### 1. As a class definition with metadata + +Subclass `MCP::Prompt` and declare the metadata with class-level helpers; `template` builds the result: + +```ruby +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 +``` + +### 2. Using the `MCP::Prompt.define` method + +`MCP::Prompt.define` builds a prompt from keyword arguments, with the block as its template: + +```ruby +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 +``` + +### 3. Using the `MCP::Server#define_prompt` method + +`MCP::Server#define_prompt` registers the prompt directly on a server instance: + +```ruby +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`](/server/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. + +## Key Components + +- `MCP::Prompt::Argument` - Defines input parameters for the prompt template with name, title, description, and required flag +- `MCP::Prompt::Message` - Represents a message in the conversation with a role and content +- `MCP::Prompt::Result` - The output of a prompt template containing description and messages +- `MCP::Content::Text` - Text content for messages + +## Registering Prompts + +Register prompts with the MCP server: + +```ruby +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 schemas +- `prompts/get` - Retrieves and executes a specific prompt with arguments + +## Prompts with Image and Embedded Resource Content + +Prompt messages are not limited to text. The same `MCP::Content` types used in [tool responses](/server/tools/) 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: + +```ruby +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 +end +``` diff --git a/docs/_server/resources.md b/docs/_server/resources.md new file mode 100644 index 00000000..f53548a4 --- /dev/null +++ b/docs/_server/resources.md @@ -0,0 +1,319 @@ +--- +layout: default +title: Resources +nav_order: 6 +--- + +# Resources + +MCP spec includes [Resources](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Defining Resources + +Like [tools](/server/tools/) and [prompts](/server/prompts/), resources can be defined in three ways. + +### 1. As a class definition + +Define a class that inherits from `MCP::Resource`, implementing `contents` to serve the resource body: + +```ruby +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 of +`MCP::Resource::TextContents` / `MCP::Resource::BlobContents` objects (or plain hashes), or a single one. +Like tools, `contents` can opt in to a [`server_context:`](/server/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. + +### 2. Using the `MCP::Resource.define` method + +The block implements `contents`: + +```ruby +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!")] +end +``` + +### 3. Using the `MCP::Server#define_resource` method + +`MCP::Server#define_resource` registers the resource directly on a server instance: + +```ruby +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!")] +end +``` + +Alternatively, resources can be registered as plain data objects with `MCP::Resource.new`, +in which case the server only lists them: + +```ruby +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. + +```ruby +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. Note that a `resources_read_handler` fully replaces +the default `resources/read` handling, including the automatic routing to class-based resources described above. + +To make the resource *list* depend on the request, register a `resources_list_handler`. The block returns the resource collection to serve, +so the visible resources can vary by the authenticated principal or the granted scope. The framework paginates the returned array +and stamps the same cache hints it applies to the constructor-provided resources, so the block returns only the array. +A block that declares `server_context:` receives it: + +```ruby +server.resources_list_handler do |params, server_context:| + server_context[:authenticated] ? real_resources : demo_resources +end +``` + +The block is invoked once per page, so it must return a stable ordering across the pages of one query; the cursor is a positional offset +into the returned collection. When no handler is set, the resources passed to `MCP::Server.new` are served unchanged. + +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: + +```ruby +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 +``` + +## Reading Binary Resources + +For 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: + +```ruby +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 +end +``` + +## Resource Templates + +Resource 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`: + +```ruby +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](https://www.rfc-editor.org/rfc/rfc6570) level 1 `{variable}` expressions only: + +- Operator expressions such as `{+path}`, `{#fragment}`, or `{?query}` are treated as literal text and never match an expanded URI. +- A variable matches one or more characters excluding `/`. +- Extracted values are not percent-decoded. + +The `MCP::ResourceTemplate.define` and `MCP::Server#define_resource_template` methods are also available, +mirroring the resource variants: + +```ruby +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 + )] +end +``` + +Resource 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`: + +```ruby +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`: + +```ruby +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/(?[^/]+)\z})) + [{ + uri: params[:uri], + mimeType: "application/json", + text: { id: match[:item_id] }.to_json, + }] + else + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) + end +end +``` + +## Resource Subscriptions + +Resource 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` requests +- `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests +- `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client + +```ruby +subscribed_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 `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows +alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it +returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta` +rather than returning it at the top level, which interoperating clients reject: + +```ruby +server.resources_subscribe_handler do |params| + id = subscriptions.create(params[:uri].to_s) + { _meta: { "myapp.example/subscriptionId" => id } } +end +``` diff --git a/docs/_server/roots.md b/docs/_server/roots.md new file mode 100644 index 00000000..a3341c6c --- /dev/null +++ b/docs/_server/roots.md @@ -0,0 +1,82 @@ +--- +layout: default +title: Roots +nav_order: 7 +--- + +# Roots + +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. + +{: .warning } +> MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. Prefer tool parameters, resource URIs, server configuration, or environment +> variables for new servers. A client declaring the `roots` capability on a modern connection emits a deprecation warning. + +{: .important } +> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with +> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association +> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding +> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Key Concepts + +- **Server-to-Client Request**: Like [sampling](/server/sampling/), roots listing is initiated by the server +- **Client Capability**: Clients must declare `roots` capability during initialization +- **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change + +## Using Roots in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `list_roots` on it. +The request is automatically routed to the correct client session: + +```ruby +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: + +```ruby +{ + 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: + +```ruby +server.roots_list_changed_handler do + puts "Client's roots have changed, tools will see updated roots on next call." +end +``` + +## Error Handling + +- Raises `RuntimeError` if client does not support `roots` capability +- Raises `StandardError` if client returns an error response diff --git a/docs/_server/sampling.md b/docs/_server/sampling.md new file mode 100644 index 00000000..af37df00 --- /dev/null +++ b/docs/_server/sampling.md @@ -0,0 +1,87 @@ +--- +layout: default +title: Sampling +nav_order: 8 +--- + +# Sampling + +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. + +{: .warning } +> MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version `2026-07-28` (SEP-2577), +> while remaining fully supported under `2025-11-25`. New servers should call LLM provider APIs directly. +> A client declaring the `sampling` capability on a modern connection emits a deprecation warning. + +{: .important } +> Per SEP-2260, server-to-client requests (`roots/list`, `sampling/createMessage`, `elicitation/create`) must be associated with +> an originating client request (`ping` is exempt). Use the `server_context` passed to your handler, which stamps the association +> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding +> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning. + +Server-to-client requests are bounded by a timeout on the Streamable HTTP transport; see [Timeouts](/server/elicitation/#timeouts). + +## Key Concepts + +- **Server-to-Client Request**: Unlike typical MCP methods (client to server), sampling is initiated by the server +- **Client Capability**: Clients must declare `sampling` capability during initialization +- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability +- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs + +## Using Sampling in Tools + +Tools that accept a [`server_context:`](/server/server-context/) parameter can call `create_sampling_message` on it. +The request is automatically routed to the correct client session: + +```ruby +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 `content` +- `max_tokens:` (Integer) - Maximum tokens in the response + +Optional: + +- `system_prompt:` (String) - System prompt for the LLM +- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`) +- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated) +- `temperature:` (Float) - Sampling temperature +- `stop_sequences:` (Array) - Sequences that stop generation +- `metadata:` (Hash) - Additional metadata +- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability) +- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`) + +## Error Handling + +- Raises `RuntimeError` if client does not support `sampling` capability +- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability +- Raises `StandardError` if client returns an error response diff --git a/docs/_server/server-context.md b/docs/_server/server-context.md new file mode 100644 index 00000000..f45f5766 --- /dev/null +++ b/docs/_server/server-context.md @@ -0,0 +1,111 @@ +--- +layout: default +title: Server Context +nav_order: 19 +--- + +# Server Context + +The `server_context` is a user-defined hash that is passed into the server instance and made available to [tool](/server/tools/) and [prompt](/server/prompts/) calls. +It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. + +**Type:** + +```ruby +server_context: { [String, Symbol] => Any } +``` + +**Example:** + +```ruby +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 [Configuration](/server/configuration/) page for the arguments they receive. + +## Request-specific `_meta` Parameter + +The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/latest/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. + +{: .note } +> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). +> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it +> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. + +**Access Pattern:** + +When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: + +```ruby +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:** + +A `tools/call` request carrying `_meta`, as read by the tool above: + +```json +{ + "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 + +Per SEP-414, the keys `traceparent`, `tracestate`, and `baggage` are reserved un-prefixed `_meta` keys for propagating +[W3C Trace Context](https://www.w3.org/TR/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: + +```ruby +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: + +```ruby +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) +``` diff --git a/docs/_server/tools.md b/docs/_server/tools.md new file mode 100644 index 00000000..0ebf6fed --- /dev/null +++ b/docs/_server/tools.md @@ -0,0 +1,419 @@ +--- +layout: default +title: Tools +nav_order: 4 +--- + +# Tools + +MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps. + +## Defining Tools + +This gem provides a `MCP::Tool` class that can be used to create tools in three ways. + +### 1. As a class definition + +Subclass `MCP::Tool` and declare the metadata with class-level helpers; `call` implements the tool: + +```ruby +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 +``` + +### 2. Using the `MCP::Tool.define` method + +`MCP::Tool.define` builds a tool from keyword arguments, with the block as its implementation: + +```ruby +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 +``` + +### 3. Using the `MCP::Server#define_tool` method + +`MCP::Server#define_tool` registers the tool directly on a server instance: + +```ruby +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`](/server/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 argument keys + +Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects +and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`, +so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`. + +This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`), +and nested objects must be read with symbol keys: + +```ruby +class ExampleTool < MCP::Tool + description "Echoes a nested argument" + input_schema( + properties: { + message: { type: "string" }, + payload: { + type: "object", + properties: { + subject: { type: "string" }, + } + } + }, + required: ["message"] + ) + + def self.call(message:, payload: nil, server_context:) + subject = payload && payload[:subject] # symbol key, not payload["subject"] + MCP::Tool::Response.new([{ + type: "text", + text: "Message: #{message}; subject: #{subject}" + }]) + end +end +``` + +Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract: +Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too. + +Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys +that a transport never delivers, so string-key access can pass tests yet fail against a real client. +Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does: + +```ruby +delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) +MyTool.call(**delivered, server_context: nil) +``` + +## Tool Annotations + +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 true +- `idempotent_hint`: Indicates if the tool's operations are idempotent. Defaults to false +- `open_world_hint`: Indicates if the tool operates in an open world context. Defaults to true +- `read_only_hint`: Indicates if the tool only reads data (doesn't modify state). Defaults to false +- `title`: A human-readable title for the tool + +Annotations 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'`. + +## Tool Output Schemas + +Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways. + +### 1. Class definition with `output_schema` + +Declare the schema with the `output_schema` class helper, alongside `input_schema`: + +```ruby +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 +``` + +### 2. Using `Tool.define` with `output_schema` + +Pass the schema as the `output_schema:` keyword argument: + +```ruby +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 +``` + +### 3. Using `OutputSchema` objects + +Construct an `MCP::Tool::OutputSchema` object explicitly: + +```ruby +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: + +```ruby +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`, +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: + +```ruby +class FlexibleTool < MCP::Tool + output_schema( + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "number" } } + ] + ) +end +``` + +Input 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 `$ref`s (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](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that: + +- **Server Validation**: Servers MUST provide structured results that conform to the output schema +- **Client Validation**: Clients SHOULD validate structured results against the output schema +- **Better Integration**: Enables strict schema validation, type information, and improved developer experience +- **Backward Compatibility**: Tools returning structured content SHOULD also include serialized JSON in a TextContent block + +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` on the server [configuration](/server/configuration/): + +```ruby +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. + +## Tool Responses with Structured Content + +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. + +```ruby +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 +``` + +## Tool Responses with Errors + +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. + +```ruby +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 +``` + +## Tool Responses with Image, Audio, and Embedded Resources + +Tool 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: + +```ruby +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 +end +``` + +An [embedded resource](/server/resources/) wraps `MCP::Resource::TextContents` or `MCP::Resource::BlobContents`, allowing a tool to return resource contents inline: + +```ruby +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 +end +``` diff --git a/docs/_server/transports.md b/docs/_server/transports.md new file mode 100644 index 00000000..65cf2914 --- /dev/null +++ b/docs/_server/transports.md @@ -0,0 +1,274 @@ +--- +layout: default +title: Transports +nav_order: 2 +--- + +# Transports + +The server ships two transports: [stdio](#stdio-transport) for command-line and desktop integrations, +and [Streamable HTTP](#streamable-http-transport) for web deployments. +This page covers starting each transport, mounting the HTTP transport in Rack and Rails applications, +and its deployment, session, and security settings. + +## Stdio Transport + +If you want to build a local command-line application, you can use the stdio transport: + +```ruby +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 +``` + +`StdioTransport.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. + +```console +$ 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"}}} +``` + +## Streamable HTTP Transport + +`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 } +> On the legacy handshake lifecycle, `MCP::Server::Transports::StreamableHTTPTransport` stores session and +> SSE stream state in memory, so it must run in a single process. Use a single-process server (e.g., Puma with +> `workers 0`). Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that +> do not share memory, which breaks session management and SSE connections. +> +> When running multiple server instances behind a load balancer, configure your load balancer to use +> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always +> routed to the same instance. +> +> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. +> Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are likewise sessionless single exchanges and work +> with any configuration, with two caveats: a [`subscriptions/listen`](/server/notification-subscriptions/) stream +> is held in memory by the process that accepted it, so only notifications emitted in that process reach it, +> and the [multi round-trip](/server/multi-round-trip-results/) `requestState` crosses workers only when they +> share a `RequestStateSecurity` key. + +{: .important } +> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to +> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. +> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, +> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` +> header are unaffected. +> +> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: ["https://app.example.com"], +> ) +> ``` +> +> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, +> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` +> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). +> The check runs before any lifecycle dispatch, so it protects requests of +> the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) as well. + +### Rails (mount) + +`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: + +```ruby +# config/routes.rb +server = MCP::Server.new( + name: "my_server", + title: "Example Server Display Name", + version: "1.0.0", + instructions: "Use the tools of this server as a last resort", + tools: [SomeTool, AnotherTool], + prompts: [MyPrompt], +) +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) + +Rails.application.routes.draw do + mount transport => "/mcp" +end +``` + +`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches +`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), +`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per +the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http), +so no additional route configuration is needed. + +A complete runnable application using this approach is available in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails). + +### Rails (controller) + +While the mount approach creates a single server at boot time, the controller approach creates a new server per request. +This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). + +`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): + +```ruby +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 +``` + +### Stateless Mode + +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`): + +```ruby +# 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. + +{: .note } +> This transport option is distinct from the sessionless [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) of MCP 2026-07-28. +> The option governs how handshake-lifecycle clients are served; modern requests carry their own `_meta` envelope +> and are served as single exchanges whether or not `stateless: true` is set. + +### JSON Response Mode + +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`: + +```ruby +# 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. + +{: .note } +> Like [stateless mode](#stateless-mode), this option applies to handshake-lifecycle clients only. +> Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:` +> and are served as SSE-framed single exchanges, so request-scoped notifications still reach the client. + +### Session Limits + +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). + +```ruby +# 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. The same holds +for requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle), which never create a session; +their long-lived [`subscriptions/listen`](/server/notification-subscriptions/) streams are bounded separately +by `max_listen_subscriptions:`. + +### Session Ownership + +`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: + +```ruby +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. + +Requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session, +so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them +(the per-request `Origin` validation of the DNS rebinding protection above still applies); +on that path, authorization is enforced per request by the deploying application. + +### Request Size Limits + +`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory +with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, +and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON +string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only +if you exchange unusually large payloads: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) +``` + +Unlike the deployment options above, these bounds apply to both lifecycles: +requests of the [modern lifecycle](/server/discovery/#the-stateless-modern-lifecycle) are read through the same byte and nesting limits. diff --git a/docs/assets/css/just-the-docs-dark.scss b/docs/assets/css/just-the-docs-dark.scss index ac92fb15..20226a04 100644 --- a/docs/assets/css/just-the-docs-dark.scss +++ b/docs/assets/css/just-the-docs-dark.scss @@ -1,3 +1,3 @@ --- --- -{% include css/just-the-docs.scss.liquid color_scheme="dark" %} +{% include css/just-the-docs.scss.liquid color_scheme="ruby-dark" %} diff --git a/docs/assets/css/just-the-docs-light.scss b/docs/assets/css/just-the-docs-light.scss index ac69688d..c2a0250e 100644 --- a/docs/assets/css/just-the-docs-light.scss +++ b/docs/assets/css/just-the-docs-light.scss @@ -1,3 +1,3 @@ --- --- -{% include css/just-the-docs.scss.liquid color_scheme="light" %} +{% include css/just-the-docs.scss.liquid color_scheme="ruby-light" %} diff --git a/docs/building-clients.md b/docs/building-clients.md deleted file mode 100644 index 6b4c7d01..00000000 --- a/docs/building-clients.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -layout: default -title: Building Clients -nav_order: 4 ---- - -# Building an MCP Client - -The `MCP::Client` class provides an interface for interacting with MCP servers. - -**Supported operations:** - -- Tool listing (`MCP::Client#tools`) and invocation (`MCP::Client#call_tool`) -- Resource listing (`MCP::Client#resources`) and reading (`MCP::Client#read_resource`) -- Resource template listing (`MCP::Client#resource_templates`) -- Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`) -- Completion requests (`MCP::Client#complete`) - -## Handshake - -Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions): - -```ruby -client.connect -# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} } - -client.connected? # => true -client.server_info # => cached InitializeResult -``` - -`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again. - -This applies to both the Stdio and HTTP transports below. - -## Stdio Transport - -Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses: - -```ruby -stdio_transport = MCP::Client::Stdio.new( - command: "bundle", - args: ["exec", "ruby", "path/to/server.rb"], - env: { "API_KEY" => "my_secret_key" }, - read_timeout: 30 -) -client = MCP::Client.new(transport: stdio_transport) -client.connect - -tools = client.tools -tools.each do |tool| - puts "Tool: #{tool.name} - #{tool.description}" -end - -response = client.call_tool( - tool: tools.first, - arguments: { message: "Hello, world!" } -) - -stdio_transport.close -``` - -| Parameter | Required | Description | -|---|---|---| -| `command:` | Yes | The command to spawn the server process. | -| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. | -| `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. | -| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. | -| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). | - -## HTTP Transport - -Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem, plus `event_stream_parser` if the server uses SSE (`text/event-stream`) responses: - -```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' -gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses -``` - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") -client = MCP::Client.new(transport: http_transport) -client.connect - -tools = client.tools -tools.each do |tool| - puts "Tool: #{tool.name} - #{tool.description}" -end - -response = client.call_tool( - tool: tools.first, - arguments: { message: "Hello, world!" } -) -``` - -### Sessions - -After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state: - -```ruby -http_transport.session_id # => "abc123..." -http_transport.protocol_version # => "2025-11-25" -``` - -If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again. - -To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op. - -```ruby -http_transport.close -``` - -### Authorization - -Provide custom headers for authentication: - -```ruby -http_transport = MCP::Client::HTTP.new( - url: "https://api.example.com/mcp", - headers: { - "Authorization" => "Bearer my_token" - } -) -client = MCP::Client.new(transport: http_transport) -``` - -### Customizing the Faraday Connection - -Pass a block to customize the underlying Faraday connection: - -```ruby -http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday| - faraday.use MyApp::Middleware::HttpRecorder - faraday.adapter :typhoeus -end -``` - -## Custom Transport - -If the built-in transports do not fit your needs, you can implement your own: - -```ruby -class CustomTransport - def send_request(request:) - # Your transport-specific logic here. - # Returns a Hash modeling a JSON-RPC response object. - end -end - -client = MCP::Client.new(transport: CustomTransport.new) -``` - -For more details, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-client). diff --git a/docs/building-servers.md b/docs/building-servers.md deleted file mode 100644 index 408f4bec..00000000 --- a/docs/building-servers.md +++ /dev/null @@ -1,332 +0,0 @@ ---- -layout: default -title: Building Servers -nav_order: 3 ---- - -# Building an MCP Server - -The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. It implements the Model Context Protocol specification. - -## Supported Methods - -- `initialize` - Initializes the protocol and returns server capabilities -- `ping` - Simple health check -- `tools/list` - Lists all registered tools and their schemas -- `tools/call` - Invokes a specific tool with provided arguments -- `prompts/list` - Lists all registered prompts and their schemas -- `prompts/get` - Retrieves a specific prompt by name -- `resources/list` - Lists all registered resources and their schemas -- `resources/read` - Retrieves a specific resource by name -- `resources/templates/list` - Lists all registered resource templates and their schemas -- `resources/subscribe` - Subscribes to updates for a specific resource -- `resources/unsubscribe` - Unsubscribes from updates for a specific resource -- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs -- `sampling/createMessage` - Requests LLM completion from the client (server-to-client) - -## Stdio Transport - -If you want to build a local command-line application, you can use the stdio transport: - -```ruby -require "mcp" - -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 - -server = MCP::Server.new( - name: "example_server", - tools: [ExampleTool], -) - -transport = MCP::Server::Transports::StdioTransport.new(server) -transport.open -``` - -`StdioTransport.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). - -## Streamable HTTP Transport - -`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::StreamableHTTPTransport` stores session and SSE stream state in memory, -> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`). -> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that -> do not share memory, which breaks session management and SSE connections. -> -> When running multiple server instances behind a load balancer, configure your load balancer to use -> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always -> routed to the same instance. -> -> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. - -### Rails (mount) - -`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: - -```ruby -# config/routes.rb -server = MCP::Server.new( - name: "my_server", - title: "Example Server Display Name", - version: "1.0.0", - instructions: "Use the tools of this server as a last resort", - tools: [SomeTool, AnotherTool], - prompts: [MyPrompt], -) -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -Rails.application.routes.draw do - mount transport => "/mcp" -end -``` - -`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches -`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE), -`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per -the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http), -so no additional route configuration is needed. - -### Rails (controller) - -While the mount approach creates a single server at boot time, the controller approach creates a new server per request. -This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route). - -`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications): - -```ruby -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 }, - ) - 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 -``` - -## Tools - -Tools provide functionality to LLM applications. There are three ways to define tools: - -### Class Definition - -```ruby -class MyTool < MCP::Tool - title "My Tool" - description "This tool performs specific functionality..." - input_schema( - properties: { - message: { type: "string" }, - }, - required: ["message"] - ) - annotations( - read_only_hint: true, - destructive_hint: false, - ) - - def self.call(message:, server_context:) - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) - end -end -``` - -### Block Definition - -```ruby -tool = MCP::Tool.define( - name: "my_tool", - description: "This tool performs specific functionality...", -) do |args, server_context:| - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -### Server-level Definition - -```ruby -server = MCP::Server.new -server.define_tool( - name: "my_tool", - description: "This tool performs specific functionality...", -) do |args, server_context:| - MCP::Tool::Response.new([{ type: "text", text: "OK" }]) -end -``` - -### Tool argument keys - -Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects -and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`, -so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`. - -This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`), -and nested objects must be read with symbol keys: - -```ruby -class ExampleTool < MCP::Tool - description "Echoes a nested argument" - input_schema( - properties: { - message: { type: "string" }, - payload: { - type: "object", - properties: { - subject: { type: "string" }, - } - } - }, - required: ["message"] - ) - - def self.call(message:, payload: nil, server_context:) - subject = payload && payload[:subject] # symbol key, not payload["subject"] - MCP::Tool::Response.new([{ - type: "text", - text: "Message: #{message}; subject: #{subject}" - }]) - end -end -``` - -Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract: -Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too. - -Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys -that a transport never delivers, so string-key access can pass tests yet fail against a real client. -Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does: - -```ruby -delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) -MyTool.call(**delivered, server_context: nil) -``` - -## Prompts - -Prompts are templates for LLM interactions. Like tools, they can be defined in three ways: - -### Class Definition - -```ruby -class CodeReviewPrompt < MCP::Prompt - prompt_name "code_review" - description "Review code for best practices" - arguments [ - MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true), - ] - - class << self - def template(args, server_context:) - MCP::Prompt::Result.new( - description: "Code review", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}") - ), - ] - ) - end - end -end -``` - -### Server-level Definition - -```ruby -server.define_prompt( - name: "code_review", - description: "Review code for best practices", - arguments: [ - MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true), - ] -) do |args, server_context:| - MCP::Prompt::Result.new( - description: "Code review", - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}") - ), - ] - ) -end -``` - -## Resources - -Resources provide data access to LLM applications: - -```ruby -class MyResource < MCP::Resource - uri "file:///data/config.json" - resource_name "config" - description "Application configuration" - mime_type "application/json" - - class << self - def contents - [MCP::Resource::TextContents.new( - uri: uri, - mime_type: mime_type, - text: File.read("config.json") - )] - end - end -end - -server = MCP::Server.new( - name: "my_server", - resources: [MyResource] -) -``` - -The server automatically routes `resources/read` requests to the matching class-based resource's `contents` method. -Requests for unregistered URIs respond with the JSON-RPC Invalid Params error (`-32602`). To handle reads manually instead, -register a block with `server.resources_read_handler`, which fully replaces the automatic routing. - -## Configuration - -```ruby -MCP.configure do |config| - config.exception_reporter = ->(exception, server_context) { - Bugsnag.notify(exception) do |report| - report.add_metadata(:model_context_protocol, server_context) - end - } - - config.instrumentation_callback = ->(data) { - puts "Got instrumentation data #{data.inspect}" - } -end -``` - -For more details on sampling, notifications, progress tracking, completions, logging, and advanced features, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-server). diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 00000000..819f4c11 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,48 @@ +--- +layout: default +title: Examples +nav_order: 3 +permalink: /examples/ +--- + +# Examples + +Runnable examples live in [`examples/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples) in the repository. + +## Standalone Scripts + +- [`stdio_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/stdio_server.rb) - a stdio server for desktop applications and command-line tools +- [`stdio_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/stdio_client.rb) - a client that spawns the stdio server as a subprocess and exercises its tools, prompts, and resources +- [`http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_server.rb) - a Rack-based Streamable HTTP server with session management and SSE support +- [`http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_client.rb) - a client driving the HTTP server through all MCP protocol methods +- [`streamable_http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server.rb) - an SSE-focused server with tools that trigger notifications and progress updates +- [`streamable_http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_client.rb) - an interactive, menu-driven client for testing the SSE stream + +Each script is standalone and run from the repository root: + +```console +$ ruby examples/stdio_server.rb +``` + +See [`examples/README.md`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/README.md) for per-example usage and the requests each one demonstrates. + +## Rails Application + +[`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails) is a complete, minimal +Rails application serving an MCP server over the Streamable HTTP transport. The server and transport are built once +at boot and mounted at `/mcp` in `config/routes.rb`; tools live in `app/tools/`, and a text resource is served through +a `resources_read_handler`. + +```console +$ cd examples/rails +$ bundle install +$ bundle exec puma --port 9292 +``` + +The MCP endpoint is then available at `http://localhost:9292/mcp`. See +[`examples/rails/README.md`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/rails/README.md) +for a step-by-step cURL walkthrough of the session handshake and tool calls. + +The mount pattern this application uses is explained in [Rails (mount)](/server/transports/#rails-mount) +on the Transports page, alongside [Rails (controller)](/server/transports/#rails-controller), an alternative +that builds a server per request so tools and configuration can vary by request. diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 00000000..a280d7fd --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 564b5f0f..79b95ca6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,23 +4,30 @@ title: Introduction nav_order: 1 --- +# MCP Ruby SDK + The official Ruby SDK for the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), implementing both server and client functionality for JSON-RPC 2.0 based communication between LLM applications and context providers. -**Key features:** +## Features -- JSON-RPC 2.0 message handling with protocol initialization and capability negotiation -- Tool, prompt, and resource registration and invocation -- Stdio and Streamable HTTP (including SSE) transports -- Client support for communicating with MCP servers -- Notifications, sampling, progress tracking, and completions +- Build [MCP servers](/server/) that expose tools, prompts, and resources to any MCP host +- Build [MCP clients](/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization +- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration +- Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination ## Quick Start -Here is a minimal MCP server using the stdio transport: +The following minimal programs show both sides of the protocol: a server that exposes a single tool, +and a client that spawns such a server and drives it over stdio. + +### MCP Server + +A minimal server defines a tool and serves it over the stdio transport: ```ruby require "mcp" +# Create a simple tool class ExampleTool < MCP::Tool description "A simple example tool that echoes back its arguments" input_schema( @@ -40,25 +47,72 @@ class ExampleTool < MCP::Tool 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 ``` -Run the script and send JSON-RPC requests via stdin: +Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: ```console $ ruby server.rb -{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"example","version":"0.1.0"}}} +{"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"}}} ``` -For comprehensive documentation, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md). +The same server can also run over Streamable HTTP, including mounted inside a Rails application; +see [Server Transports](/server/transports/). + +### MCP Client + +A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: + +```ruby +stdio_transport = MCP::Client::Stdio.new( + command: "bundle", + args: ["exec", "ruby", "path/to/server.rb"], + env: { "API_KEY" => "my_secret_key" }, + read_timeout: 30 +) +client = MCP::Client.new(transport: stdio_transport) + +# 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 same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; +see [Client Transports](/client/transports/). + +For comprehensive documentation, see: + +- [Installation](/installation/) - installing the gem and optional feature dependencies +- [Examples](/examples/) - runnable example scripts and a complete Rails application +- [Protocol Versions](/protocol-versions/) - supported versions, the era model, and client negotiation +- [Building Servers](server/) - transports, discovery, tools, prompts, resources, server-to-client requests, multi round-trip results, notifications, protocol utilities, and configuration +- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip results, and OAuth 2.1 authorization +- [Extensions](/extensions/) - capability extensions and MCP Apps ## API Documentation @@ -74,4 +128,4 @@ Full API reference is hosted on [RubyDoc.info](https://rubydoc.info/gems/mcp). S ## License -This project is transitioning from the MIT License to the Apache License 2.0. See [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) for details. +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/LICENSE) file for details. diff --git a/docs/installation.md b/docs/installation.md index 0822994e..f93c6e5d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,14 +2,25 @@ layout: default title: Installation nav_order: 2 +permalink: /installation/ +redirect_from: + - /installation.html --- # Installation +Install the gem and any optional dependencies for the features you use. + +## Requirements + +Ruby 2.7.0 or later. + +## Installing the Gem + Add this line to your application's Gemfile: ```ruby -gem 'mcp' +gem "mcp" ``` And then execute: @@ -27,6 +38,6 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem: ```ruby -gem 'mcp' -gem 'faraday', '>= 2.0' +gem "mcp" +gem "faraday", ">= 2.0" ``` diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 00000000..735a8ed3 --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,62 @@ +--- +layout: default +title: Protocol Versions +nav_order: 4 +permalink: /protocol-versions/ +--- + +# Protocol Versions + +The SDK supports the following MCP protocol versions: + +| Version | Era | Notes | +|--------------|-----------|-------------------------------------------------------------------------------------------------| +| `2026-07-28` | Modern | Stateless lifecycle: no handshake, version carried on every request (SEP-2575) | +| `2025-11-25` | Handshake | The default handshake version; adds URL mode elicitation, enum schemas, and sampling tools | +| `2025-06-18` | Handshake | Adds elicitation, structured tool output, and OAuth resource servers; removes JSON-RPC batching | +| `2025-03-26` | Handshake | Adds Streamable HTTP, OAuth 2.1 authorization, tool annotations, and audio content | +| `2024-11-05` | Handshake | Initial protocol revision | + +## The Era Model + +Per the SEP-2575 era model, an era is a property of the protocol version itself: + +- **The modern version** (`2026-07-28`) has no handshake at all: clients discover the server through + [`server/discover`](/server/discovery/), and every request carries its version in the `_meta` envelope, + validated per request. +- **Handshake versions** (`2025-11-25` and earlier) establish a session through the `initialize` handshake. + The server offers `2025-11-25` by default, and the version can be pinned with `MCP::Configuration.new(protocol_version:)`; + see [Server Protocol Version](/server/configuration/#server-protocol-version). + +Pinning the server's handshake version: + +```ruby +configuration = MCP::Configuration.new(protocol_version: "2025-06-18") +MCP::Server.new(name: "my_server", configuration: configuration) +``` + +The handshake never negotiates a modern version: a client asking `initialize` for `2026-07-28` is counter-offered +the latest handshake version, matching the TypeScript and Python SDKs. The bundled transports serve both eras +side by side with no configuration needed. + +## Client Negotiation + +`MCP::Client#connect` negotiates the lifecycle automatically by default: it probes `server/discover` and adopts +the modern lifecycle when the server serves it, falling back to the `initialize` handshake otherwise. +`connect(mode: :modern)`, `connect(mode: :legacy)`, and an explicit `protocol_version:` pin select a lifecycle +directly; see [Lifecycle](/client/lifecycle/). + +```ruby +client.connect # negotiate automatically (default) +client.connect(mode: :modern) # require the modern lifecycle +client.connect(mode: :legacy) # force the classic initialize handshake +client.connect(protocol_version: "2025-11-25") # pin the handshake version, no probe +``` + +## Deprecations + +The `2026-07-28` revision deprecates [Roots](/server/roots/), [Sampling](/server/sampling/), and +[Logging](/server/logging/) per SEP-2577; all three remain fully supported on the handshake versions. + +Check the [MCP specification](https://modelcontextprotocol.io/specification/versioning) to understand what each +protocol version includes. diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 89c43e07..051a8928 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1578,7 +1578,7 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. - # See docs/building-servers.md ("Tool argument keys"). + # See docs/server/tools.md ("Tool argument keys"). args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call))