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))