diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index ff178c2..dccb8af 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -26,4 +26,4 @@ jobs: - uses: actions/setup-node@v7 with: node-version: '24' # Specify the latest Node.js version. - - run: bundle exec rake conformance:test + - run: bundle exec rake conformance diff --git a/.gitignore b/.gitignore index de8444e..9053d96 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,3 @@ /spec/reports/ /tmp/ Gemfile.lock -docs/_site/ -docs/.jekyll-cache/ -docs/.jekyll-metadata -docs/_data/versions.yml diff --git a/.rubocop.yml b/.rubocop.yml index aefa67a..f0c764e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,7 +7,6 @@ plugins: AllCops: TargetRubyVersion: 2.7 - SuggestExtensions: false Gemspec/DevelopmentDependencies: Enabled: true diff --git a/AGENTS.md b/AGENTS.md index 6635c3b..5f0ee62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing - `bundle install` - Install dependencies - `rake test` - Run all tests - `rake rubocop` - Run linter -- `rake` - Run linting, tests, and the conformance suite (default task) -- `bundle exec rake conformance:test` - Run the MCP conformance suite (see conformance/README.md) -- `bundle exec rake docs:preview` - Serve the documentation site locally at http://localhost:4000 (PORT to override) +- `rake` - Run tests and linting (default task) - `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file - `gem build mcp.gemspec` - Build the gem @@ -36,14 +34,6 @@ 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/CHANGELOG.md b/CHANGELOG.md index 88ec62e..e0ba320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,129 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.5.1] - 2026-09-09 - -This release keeps the HTTP client working with json 3.0. That release accepts the options of `JSON.parse` -as keywords only, and Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash, -which Ruby 3 no longer converts, so every JSON body the client received failed as an internal error. -The client now parses response bodies itself, the way it already handled streamed ones, and no longer -registers the middleware. A malformed JSON body delivered whole by an adapter without streaming support -now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did. - -### Fixed - -- Parse HTTP client response bodies without Faraday's JSON middleware, which json 3.0 breaks (#546) - -## [1.5.0] - 2026-09-05 - -This release makes the client answer a server's `ping` with the empty result the specification requires. -Earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio, -so a server that checks liveness dropped their long-lived sessions. On the OAuth side, -the embedding application can refuse an authorization request through `authorization_request_validator`, -and stored tokens are refreshed only against the authorization server that issued them: -when the server named for a session differs, the client reauthorizes instead of refreshing, -and the validator sees the newly named server. Tokens stored by earlier releases carry no issuer and keep refreshing. - -### Added - -- Add `authorization_request_validator` to let the embedding application refuse an authorization request (#539) - -### Fixed - -- Answer server-to-client pings in the client (#541) - -## [1.4.0] - 2026-08-28 - -This release repairs the `subscriptions/listen` stream. A host that buffers its responses, -the documented Rails controller pattern among them, can decline the method with -`serve_subscriptions_listen: false` instead of answering a modern client with a streaming body -it cannot render, and the stream's ordering guarantees are enforced: no notification reaches -a client before the acknowledgement, and none follows the graceful closing result. - -### Added - -- Add `serve_subscriptions_listen:` for hosts that cannot hold an SSE stream open (#533) - -### Fixed - -- Deliver `subscriptions/listen` notifications only after the acknowledgement (#532) -- Serialize `subscriptions/listen` writes so the graceful result is the final message (#535) - -## [1.3.0] - 2026-08-22 - -User-facing documentation now lives on the documentation site at https://ruby.sdk.modelcontextprotocol.io, -and README.md keeps the quick start. Two entries under "Changed" reject traffic that earlier releases accepted -and ship in a minor release under the exceptions described in [VERSIONING.md](VERSIONING.md). - -### Added - -- Add a `resources_list_handler` for context-dependent resource lists (#509) -- Pass a handler-returned `_meta` through the subscribe result (#510) - -### Changed - -- Bound OAuth response bodies in the client (#520) -- Reject duplicate in-flight JSON-RPC request ids (#521) -- Move the documentation from README.md to the documentation site (#523) - -## [1.2.0] - 2026-08-15 - -This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with -the SEP-2322, SEP-2549, and SEP-2243 features that revision builds on. Several entries under "Changed" -are incompatible with 1.1.0 and ship in a minor release under the spec-conformance and security exceptions -described in [VERSIONING.md](VERSIONING.md). - -### Added - -- Handle the SEP-2575 modern request envelope in the server core (#475) -- Serve both lifecycle eras over stdio with an era lock per SEP-2575 (#478) -- Serve the sessionless modern path over Streamable HTTP per SEP-2575 (#479) -- Finalize `server/discover` and add client modern lifecycle support per SEP-2575 (#480) -- Let handlers return multi round-trip `input_required` results per SEP-2322 (#481) -- Add `MCP::Elicitation::EnumSchema` builders per SEP-1330 (#482) -- Stamp the required `resultType` on modern results per SEP-2322 (#487) -- Enforce the modern lifecycle admission rules per SEP-2575 (#489) -- Stream modern request notifications and honor the envelope `logLevel` per SEP-2575 (#490) -- Expose the user-defined `server_context` in instrumentation data (#493) -- Serve the `subscriptions/listen` notification stream per SEP-2575 (#495) -- Add opt-in `requestState` sealing via `MCP::Server::RequestStateSecurity` (#496) -- Mirror `x-mcp-header` tool parameters into `Mcp-Param-*` headers per SEP-2243 (#498) -- Stamp the required cache hints on modern cacheable results per SEP-2549 (#499) -- Drive multi round-trip `input_required` results on the client per SEP-2322 (#500) -- Fulfill `input_required` results on the legacy wire per SEP-2322 (#501) - -### Changed - -- Align modern envelope validation with the finalized specification (#491) -- Require the `Mcp-Method` header on the modern path (#492) -- Bound server-to-client requests with a timeout (#502) -- Refuse server-to-client requests in the modern lifecycle per SEP-2575 (#503) -- Bound the total wait across SSE reconnection attempts (#504) -- Bound automatic pagination in the MCP client (#505) -- Reject modern-removed methods before the connection era locks (#511) -- Stop negotiating modern protocol versions through the `initialize` handshake (#516) - -### Deprecated - -- Warn on modern client connects that declare the Roots or Sampling capabilities deprecated per SEP-2577 (#406, #516) - -### Fixed - -- Stop leaking exception messages to clients via JSON-RPC error data (#486) -- Return Invalid Params for unknown prompts and missing prompt arguments (#517) -- Restrict OAuth discovery to same-origin metadata URLs and refuse private-network destinations - -## [1.1.0] - 2026-08-01 - -### Added - -- Support 2026-07-28 as the Latest Protocol Version (#476) -- Expose server tool annotations on MCP::Client::Tool (#445) - -### Fixed - -- Preserve explicit tool response content (#469) - ## [1.0.0] - 2026-07-24 First stable release. The public API is now stable: breaking changes ship only in major releases, diff --git a/README.md b/README.md index 7a71fb2..bad0473 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,12 @@ 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 requests, notifications, progress, logging, cancellation, completions, and pagination - ## Installation Add this line to your application's Gemfile: ```ruby -gem "mcp" +gem 'mcp' ``` And then execute: @@ -33,14 +24,51 @@ $ gem install mcp You may need to add additional dependencies depending on which features you wish to access. -## Quick Start +## Building an MCP Server -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. +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. -### MCP Server +### Key Features -A minimal server defines a tool and serves it over the stdio transport: +- 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 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`, + and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id` +- `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) + +### Usage + +#### Stdio Transport + +If you want to build a local command-line application, you can use the stdio transport: ```ruby require "mcp" @@ -76,21 +104,2138 @@ transport = MCP::Server::Transports::StdioTransport.new(server) transport.open ``` -Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: +`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 server.rb +$ 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"}}} ``` -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/). +#### Streamable HTTP Transport -### MCP Client +`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. -A minimal client spawns a stdio server as a subprocess, connects, and lists and calls its tools: +> [!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" => {} } }) +``` + +### MCP Apps (SEP-1865) + +MCP Apps is a Final extension (negotiated via the Capability Extensions mechanism above) that lets a server ship interactive +HTML user interfaces which the host renders for tool results. On the server side the extension is a thin convention, +and `MCP::Apps` provides the vocabulary and helpers: + +```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.client_capabilities) # => true when the host declared the extension + MCP::Tool::Response.new([{ type: "text", text: "Sunny, 22 degrees Celsius" }]) +end +``` + +Everything else the extension defines (the sandboxed iframe, the `ui/*` postMessage bridge, consent for UI-initiated actions) +is the HOST's responsibility; a server only ever receives ordinary `resources/read` and `tools/call` requests. +See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). + +### Server Context and Configuration Block Data + +#### `server_context` + +The `server_context` is a user-defined hash that is passed into the server instance and made available to tool and prompt calls. +It can be used to provide contextual information such as authentication state, user IDs, or request-specific data. + +**Type:** + +```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 exception and instrumentation callbacks do not receive this user-defined hash. +See the relevant sections below for the arguments they receive. + +#### Request-specific `_meta` Parameter + +The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/2025-06-18/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`. + +> [!NOTE] +> `_meta` is only merged when `server_context` is a `Hash` (or `nil`, in which case a new `{ _meta: ... }` hash is synthesized). +> If you assign a non-`Hash` value to `server_context`, `_meta` is not merged and tools will not see it +> under `server_context[:_meta]`. Keep `server_context` as a `Hash` if your tools need access to `_meta`. + +**Access Pattern:** + +When a client includes `_meta` in the request params, it becomes available as `server_context[:_meta]`: + +```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` +- 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 +``` + +##### Instrumentation Callback (soft-deprecated) + +> [!NOTE] +> `instrumentation_callback` is soft-deprecated. Use `around_request` instead. +> +> To migrate, wrap the call in `begin/ensure` so the callback still runs when the request fails: +> +> ```ruby +> # Before +> config.instrumentation_callback = ->(data) { log(data) } +> +> # After +> config.around_request = ->(data, &request_handler) do +> request_handler.call +> ensure +> log(data) +> end +> ``` +> +> Note that `data[:duration]` is not available inside `around_request`. +> If you need it, measure elapsed time yourself within the hook, or keep using `instrumentation_callback`. + +The instrumentation callback is called after each request finishes, whether successfully or with an error. +It receives a hash with the following possible keys: + +- `method`: (String) The protocol method called (e.g., "ping", "tools/list") +- `tool_name`: (String, optional) The name of the tool called +- `tool_arguments`: (Hash, optional) The arguments passed to the tool +- `prompt_name`: (String, optional) The name of the prompt called +- `resource_uri`: (String, optional) The URI of the resource called +- `error`: (String, optional) Error code if a lookup failed +- `duration`: (Float) Duration of the call in seconds +- `client`: (Hash, optional) Client information with `name` and `version` keys, from the initialize request + +**Signature:** + +```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 stable version will be applied by default. +The latest stable version includes new features from the [draft version](https://modelcontextprotocol.io/specification/draft). + +This will make all new server instances use the specified protocol version instead of the default version. The protocol version can be reset to the default by setting it to `nil`: + +```ruby +MCP::Configuration.new(protocol_version: nil) +``` + +If an invalid `protocol_version` value is set, an `ArgumentError` is raised. + +Be sure to check the [MCP spec](https://modelcontextprotocol.io/specification/versioning) for the protocol version to understand the supported features for the version being set. + +### Exception Reporting + +The exception reporter receives two arguments: + +- `exception`: The Ruby exception object that was raised +- `server_context`: A hash containing contextual information about where the error occurred + +The `server_context` hash includes: + +- For request handling failures: `{ request: { ... } }` (the raw JSON-RPC request hash) +- For notification delivery failures: `{ notification: "tools_list_changed" }` (or the relevant notification name) + +When an exception occurs: + +1. The exception is reported via the configured reporter +2. For tool calls, a generic error response is returned to the client: `{ error: "Internal error occurred", isError: true }` +3. For other requests, the exception is re-raised after reporting + +If no exception reporter is configured, a default no-op reporter is used that silently ignores exceptions. + +### Tools + +MCP spec includes [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) which provide functionality to LLM apps. + +This gem provides a `MCP::Tool` class that can be used to create tools in three ways: + +1. As a class definition: + +```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 +``` + +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 +``` + +2. **Using Tool.define with output_schema:** + +```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:** + +```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`: + +```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 +``` + +### 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 + +### 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. + +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 +``` + +### 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], +) +``` + +### 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. + +**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 +``` + +### 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`, `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 +``` + +#### 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 +``` + +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 and break out of the pagination loop if the server returns the same `nextCursor` +twice in a row as a safety measure. + +```ruby +tools = client.tools # => Array of every tool on the server. +``` + +Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need +fine-grained iteration (e.g. to stream-process pages without loading everything into memory). + +#### List Result Caching (`ttlMs` / `cacheScope`) + +Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`, max-age semantics in milliseconds; +`0` means do not cache) and whether shared intermediaries may cache it (`cacheScope`: `"public"` or `"private"`). + +Emission is opt-in: pass `ttl_ms:` and/or `cache_scope:` to `MCP::Server.new` and both fields are added to `tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, and `resources/read` results (a missing field is filled with the defaults `ttlMs: 0` / `cacheScope: "public"`). +When neither is set, responses are serialized exactly as before. + +```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. + +## 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( @@ -120,20 +2265,373 @@ response = client.call_tool( stdio_transport.close ``` -The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`; -see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transports/). +The stdio transport automatically handles: -## Examples +- 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 -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). +### 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. + +#### 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. + +#### 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 recognizes such results and raises `MCP::Client::InputRequiredError` instead of returning them as if they were final. The error exposes `input_requests`, `request_state`, +and the raw `result`; automatic resumption is not implemented yet, so callers respond manually if they opt into the draft flow. `MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` +are provided for forward compatibility. Servers on stable protocol versions never send `resultType`, so existing behavior is unchanged. + +## Conformance Testing + +The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance). + +See [conformance/README.md](conformance/README.md) for usage instructions. ## Documentation -- [SDK guides](https://ruby.sdk.modelcontextprotocol.io) - [SDK API documentation](https://rubydoc.info/gems/mcp) - [Model Context Protocol documentation](https://modelcontextprotocol.io) - -## License - -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/ROADMAP.md b/ROADMAP.md index aa58422..6953b4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,29 +5,21 @@ It is a living document and will evolve as the SDK matures. ## Current Status -The SDK implements the 2025-06-18, 2025-11-25, and 2026-07-28 spec revisions, and passes all scored server -and client conformance scenarios for each revision's requirement set. -Optional protocol extensions (such as DPoP, workload identity federation, and the tasks extension) -are not yet implemented; SEP-1730 does not require extensions for any tier. +The SDK implements the 2025-06-18 and 2025-11-25 spec revisions, and passes all server and client conformance scenarios. ## API Stability -The 1.0.0 release marked the public API as stable: breaking changes ship only in major releases, +The 1.0.0 release will mark the public API as stable: breaking changes then ship only in major releases, apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md), which also defines the Semantic Versioning scheme and breaking-change policy. ## Deprecated Features The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577). -These features remain supported throughout 1.x: on the legacy lifecycle they work unchanged -(the deprecations do not apply there), and the 2026-07-28 lifecycle carries them through its own mechanisms: -roots-shaped and sampling-shaped input requests embedded in `InputRequiredResult` responses -(the SEP-2322 multi round-trip requests pattern) and per-request log delivery through the envelope's `logLevel`. -The replacements SEP-2577 names live outside the protocol: tool parameters or configuration for roots, -direct LLM provider APIs for sampling, and stderr or OpenTelemetry for logging. -The client emits a deprecation warning when a modern connect declares the deprecated Roots or Sampling capabilities. -Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: whether 2.0.0 removes -them depends on how future MCP spec revisions treat these features and on adoption of their replacements. +These features remain fully supported throughout 1.x, and deprecation warnings will be added in a future minor release. +Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet: +whether 2.0.0 removes them depends on how future MCP spec revisions treat these features +and on adoption of their replacements. ## Conformance @@ -36,16 +28,12 @@ Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provide ## Documentation -Reference documentation covers all non-experimental features with examples. +Reference documentation covers all core features. ## Tracking New Spec Revisions The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per release based on feature complexity. -The 2026-07-28 revision shipped during 1.x: the server serves both lifecycle eras side by side, -so the SEP-2575 stateless lifecycle (including `server/discover` and `subscriptions/listen`), -SEP-2322 multi round-trip requests, SEP-2549 cache hints, and SEP-2243 custom headers are available alongside -the legacy handshake. The few incompatible adjustments the revision required shipped in -a minor release under the spec-conformance and security exceptions described in [VERSIONING.md](VERSIONING.md). -Removing legacy-era support is reserved for a future major release. -The tasks extension (SEP-2663) remains planned ([#391](https://github.com/modelcontextprotocol/ruby-sdk/issues/391)). +Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests, +and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision, +such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0. diff --git a/Rakefile b/Rakefile index 409eef9..36da72c 100644 --- a/Rakefile +++ b/Rakefile @@ -14,85 +14,41 @@ require "rubocop/rake_task" RuboCop::RakeTask.new -task default: [:rubocop, :test, "conformance:test"] +task default: [:rubocop, :test, :conformance] -namespace :conformance do - desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)" - task :test do |t| - next unless npx_available?(t.name) +desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)" +task :conformance do |t| + next unless npx_available?(t.name) - require_relative "conformance/server_runner" - require_relative "conformance/client_runner" + require_relative "conformance/server_runner" + require_relative "conformance/client_runner" - options = {} - options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] - options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"] - options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] - options[:verbose] = true if ENV["VERBOSE"] + options = {} + options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] + options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"] + options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] + options[:verbose] = true if ENV["VERBOSE"] - Conformance::ServerRunner.new(**options).run - Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run - end - - desc "List available conformance scenarios" - task :list do |t| - next unless npx_available?(t.name) - - system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") - system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client") - end - - desc "Start the conformance server (PORT)" - task :server do - require_relative "conformance/server" - - options = {} - options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] - - Conformance::Server.new(**options).start - end + Conformance::ServerRunner.new(**options).run + Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run end -# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance` -# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without -# running anything. This keeps the name pointing at the suite instead of at that silent no-op. -desc "Alias for conformance:test" -task conformance: "conformance:test" +desc "List available conformance scenarios" +task :conformance_list do |t| + next unless npx_available?(t.name) -namespace :docs do - desc "Serve the documentation site locally at http://localhost:4000 (PORT)" - task :preview do - docs_dir = File.expand_path("docs", __dir__) - generate_docs_versions_data(docs_dir) - - env = { - "BUNDLE_GEMFILE" => File.join(docs_dir, "Gemfile"), - "RUBYOPT" => "-r#{File.join(docs_dir, "_preview", "taint_shim.rb")}", - } - port = ENV.fetch("PORT", "4000") - - Bundler.with_unbundled_env do - system(env, "bundle", "install", "--quiet", chdir: docs_dir, exception: true) - system(env, "bundle", "exec", "jekyll", "serve", "--port", port, chdir: docs_dir, exception: true) - rescue Interrupt - # Ctrl-C is the way to stop the preview, not an error. - end - end + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") + system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client") end -# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from -# the version tags at deploy time, and the preview generates the same data so the nav footer -# shows the released-gem version line. -def generate_docs_versions_data(docs_dir) - versions = %x(git tag --list).split("\n").filter_map { |tag| - tag[/\A[^0-9]*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\z/, 1] - }.sort_by { |version| - Gem::Version.new(version) - }.reverse - return if versions.empty? +desc "Start the conformance server (PORT)" +task :conformance_server do + require_relative "conformance/server" - mkdir_p(File.join(docs_dir, "_data")) - File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join) + options = {} + options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] + + Conformance::Server.new(**options).start end def npx_available?(task_name) diff --git a/conformance/README.md b/conformance/README.md index 1e2e660..5179012 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont ### Run all scenarios ```bash -bundle exec rake conformance:test +bundle exec rake conformance ``` Runs both server and client conformance tests in sequence. Server conformance starts the @@ -31,26 +31,26 @@ are allowed to fail without affecting the exit code. ```bash # Run a single server scenario -bundle exec rake conformance:test SCENARIO=ping +bundle exec rake conformance SCENARIO=ping # Use a different port with verbose output -bundle exec rake conformance:test PORT=3000 VERBOSE=1 +bundle exec rake conformance PORT=3000 VERBOSE=1 # Start the server on a specific port -bundle exec rake conformance:server PORT=3000 +bundle exec rake conformance_server PORT=3000 ``` ### Start the server and test separately ```bash # Terminal 1: start the server -bundle exec rake conformance:server +bundle exec rake conformance_server # Terminal 2: run all scenarios -npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp +npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp # Terminal 2: run a single scenario -npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp --scenario ping +npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp --scenario ping ``` Keeps the server alive between test runs, which avoids the startup overhead when iterating @@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done. ### List available scenarios ```bash -bundle exec rake conformance:list +bundle exec rake conformance_list ``` Prints all scenario names that can be passed to `SCENARIO`. @@ -76,7 +76,7 @@ repository with the conformance server running: ```bash # Terminal 1 (this repository): start the conformance server -bundle exec rake conformance:server +bundle exec rake conformance_server # Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code /mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp @@ -102,4 +102,4 @@ conformance/ Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to fail without affecting the exit code and are tracked to catch regressions. -These are shown in the output of `bundle exec rake conformance:test`. +These are shown in the output of `bundle exec rake conformance`. diff --git a/conformance/client.rb b/conformance/client.rb index b568e2e..c41a30b 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -162,13 +162,6 @@ end oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) - -# SEP-2322: the MRTR scenario exercises the automatic driver; this elicitation handler accepts -# every embedded confirmation request, and the driver echoes `requestState` byte-exactly. -# The same registration answers a real `elicitation/create` request, so nothing here is MRTR-specific. -if scenario == "sep-2322-client-request-state" - client.on_elicitation { |_params| { action: "accept", content: { confirmed: true } } } -end capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {} # The conformance harness asserts on the observed protocol exchange, not on the client's exit status, # and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios @@ -178,84 +171,14 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation # free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace # and a failing exit status. begin - # The harness names the wire version per scenario run: the 2026 draft selects the stateless modern lifecycle, - # and every dated version through 2025-11-25 keeps the legacy `initialize` handshake - # (pinned rather than `:auto`, whose `server/discover` probe the legacy scenario servers do not expect). - mode = ENV["MCP_CONFORMANCE_PROTOCOL_VERSION"] == "2026-07-28" ? :modern : :legacy - connect = lambda do - client.connect( - client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, - capabilities: capabilities, - mode: mode, - ) - end - - begin - connect.call - rescue MCP::Client::RequestHandlerError - # SEP-2575 version negotiation: a server may reject the first request with `-32022` naming - # its supported versions. Retry once so the harness observes a follow-up request carrying - # a mutually supported version (this client already speaks the latest draft). - raise unless mode == :modern - - connect.call - end + client.connect( + client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, + capabilities: capabilities, + ) case scenario when "initialize" client.tools - when "json-schema-2020-12-preservation" - # SEP-1613/SEP-2106: echo the focal tool's observed inputSchema verbatim so the harness - # can diff what the client preserved. Preservation holds because `Client::Tool#input_schema` - # is the parsed response untouched. - tools = client.tools - focal = tools.find { |t| t.name == "json_schema_2020_12_tool" } - abort("Tool json_schema_2020_12_tool not found") unless focal - client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema }) - when "request-metadata" - # SEP-2575: the checks read the envelope and headers of the requests the modern connect - # and this listing already produce. - client.tools - when "sep-2322-client-request-state" - # SEP-2322: the driver resumes `test_mrtr_echo_state` automatically (fresh JSON-RPC id, - # byte-exact `requestState` echo); `test_mrtr_unrelated` must carry neither retry field; - # `test_mrtr_no_state` retries without inventing a state; `test_mrtr_no_result_type` - # has no `resultType` and therefore must be treated as complete, with no retry. - client.tools - client.call_tool(name: "test_mrtr_echo_state", arguments: {}) - client.call_tool(name: "test_mrtr_unrelated", arguments: {}) - client.call_tool(name: "test_mrtr_no_state", arguments: {}) - client.call_tool(name: "test_mrtr_no_result_type", arguments: {}) - when "http-custom-headers" - # SEP-2243: the harness names the exact tool calls (with encoding edge-case values) via context; - # listing first teaches the transport the `x-mcp-header` declarations it mirrors. - client.tools - (conformance_context["toolCalls"] || []).each do |tool_call| - client.call_tool(name: tool_call["name"], arguments: tool_call["arguments"]) - end - when "http-invalid-tool-headers" - # SEP-2243: the listing carries one valid tool among invalid `x-mcp-header` definitions; - # calling only the valid one shows the invalid definitions did not block it. - client.tools - client.call_tool(name: "valid_tool", arguments: { "message" => "hello" }) - when "http-standard-headers" - # SEP-2243: the harness inspects the `Mcp-Method` header on every POST and the `Mcp-Name` header on - # the name-carrying requests, so drive one of each standard request kind. - tools = client.tools - tool = tools.find { |t| t.name == "test_headers" } || tools.first - abort("No tool exposed by conformance server for #{scenario}") unless tool - client.call_tool(tool: tool, arguments: {}) - - resource = client.resources.first - abort("No resource exposed by conformance server for #{scenario}") unless resource - client.read_resource(uri: resource["uri"]) - - prompt = client.prompts.first - abort("No prompt exposed by conformance server for #{scenario}") unless prompt - client.get_prompt(name: prompt["name"]) - when "json-schema-ref-no-deref" - # SEP-2106: listing tools whose schemas carry `$ref` must not trigger network dereferencing. - client.tools when "tools_call" tools = client.tools add_numbers = tools.find { |t| t.name == "add_numbers" } diff --git a/conformance/client_runner.rb b/conformance/client_runner.rb index 418e7f4..a99fbac 100644 --- a/conformance/client_runner.rb +++ b/conformance/client_runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Runs `npx @modelcontextprotocol/conformance@alpha client` against the conformance client script. +# Runs `npx @modelcontextprotocol/conformance client` against the conformance client script. require "English" module Conformance @@ -29,7 +29,7 @@ module Conformance npx_command = [ "npx", "--yes", - "@modelcontextprotocol/conformance@alpha", + "@modelcontextprotocol/conformance", "client", "--command", "bundle exec ruby #{client_script}", diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 9325842..0bbbbfb 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -1,6 +1,2 @@ server: [] -client: - # Unimplemented authorization extensions. - - auth/dpop - - auth/dpop-nonce - - auth/wif-jwt-bearer +client: [] diff --git a/conformance/server.rb b/conformance/server.rb index a3f781d..7b697d8 100644 --- a/conformance/server.rb +++ b/conformance/server.rb @@ -2,7 +2,6 @@ require "rackup" require "json" -require "securerandom" require "set" require "uri" require_relative "../lib/mcp" @@ -14,43 +13,6 @@ module Conformance # Minimal WAV file (matches TypeScript SDK and Python SDK) BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" - # SEP-2322 MRTR fixture helpers. A retried request re-runs its handler from the start, - # so every round reads the client's earlier answers back through these. - module Mrtr - module_function - - # The transport parses JSON with symbolized keys, but retries may also arrive through - # `Server#handle` with string keys; tolerate both like the SDK's own readers. - def read(hash, key) - return unless hash.is_a?(Hash) - - value = hash[key.to_sym] - value.nil? ? hash[key.to_s] : value - end - - # The `content` of an accepted `elicitation/create` response, or `nil` for a declined, - # malformed, or missing one (a `nil` return re-requests the input). - def accepted_content(response) - content = read(response, :content) - content if read(response, :action) == "accept" && content.is_a?(Hash) - end - - # The `text` of a `sampling/createMessage` response, or `nil` when malformed. - def sampled_text(response) - text = read(read(response, :content), :text) - text if text.is_a?(String) - end - - def parse_state(request_state) - return unless request_state.is_a?(String) - - parsed = JSON.parse(request_state) - parsed.is_a?(Hash) ? parsed : nil - rescue JSON::ParserError - nil - end - end - module Tools class TestSimpleText < MCP::Tool tool_name "test_simple_text" @@ -257,9 +219,7 @@ module Conformance name: { type: "string", default: "John Doe" }, age: { type: "integer", default: 30 }, score: { type: "number", default: 95.5 }, - status: MCP::Elicitation::EnumSchema.untitled_single_select( - values: ["active", "inactive", "pending"], default: "active", - ).to_h, + status: { type: "string", enum: ["active", "inactive", "pending"], default: "active" }, verified: { type: "boolean", default: true }, }, }, @@ -275,37 +235,45 @@ module Conformance class << self def call(server_context:, **_args) - # Built with the SEP-1330 builders so the conformance run exercises the same schemas - # the SDK produces for users. result = server_context.create_form_elicitation( message: "Please select options", requested_schema: { type: "object", properties: { - untitledSingle: MCP::Elicitation::EnumSchema.untitled_single_select( - values: ["option1", "option2", "option3"], - ).to_h, - titledSingle: MCP::Elicitation::EnumSchema.titled_single_select( - options: [ - { value: "value1", title: "First Option" }, - { value: "value2", title: "Second Option" }, - { value: "value3", title: "Third Option" }, + untitledSingle: { + type: "string", + enum: ["option1", "option2", "option3"], + }, + titledSingle: { + type: "string", + oneOf: [ + { const: "value1", title: "First Option" }, + { const: "value2", title: "Second Option" }, + { const: "value3", title: "Third Option" }, ], - ).to_h, - legacyEnum: MCP::Elicitation::EnumSchema.legacy_titled( - values: ["opt1", "opt2", "opt3"], - value_titles: ["Option One", "Option Two", "Option Three"], - ).to_h, - untitledMulti: MCP::Elicitation::EnumSchema.untitled_multi_select( - values: ["option1", "option2", "option3"], - ).to_h, - titledMulti: MCP::Elicitation::EnumSchema.titled_multi_select( - options: [ - { value: "value1", title: "First Choice" }, - { value: "value2", title: "Second Choice" }, - { value: "value3", title: "Third Choice" }, - ], - ).to_h, + }, + legacyEnum: { + type: "string", + enum: ["opt1", "opt2", "opt3"], + enumNames: ["Option One", "Option Two", "Option Three"], + }, + untitledMulti: { + type: "array", + items: { + type: "string", + enum: ["option1", "option2", "option3"], + }, + }, + titledMulti: { + type: "array", + items: { + anyOf: [ + { const: "value1", title: "First Choice" }, + { const: "value2", title: "Second Choice" }, + { const: "value3", title: "Third Choice" }, + ], + }, + }, }, }, ) @@ -324,377 +292,6 @@ module Conformance end end end - - class TestMissingCapability < MCP::Tool - tool_name "test_missing_capability" - description "A tool that requires the sampling client capability (SEP-2575)" - - class << self - def call(server_context:, **_args) - server_context.require_client_capability!(:sampling) - - MCP::Tool::Response.new([MCP::Content::Text.new("Sampling capability is declared").to_h]) - end - end - end - - class TestStreamingElicitation < MCP::Tool - tool_name "test_streaming_elicitation" - description "A tool whose response stream carries only notifications, never independent requests (SEP-2575)" - - class << self - def call(server_context:, **_args) - # No independent server-to-client request is ever written to the response stream; - # the progress notification only goes out when the request carried a progressToken. - server_context.report_progress(50, total: 100) - - MCP::Tool::Response.new([MCP::Content::Text.new("Streaming complete").to_h]) - end - end - end - - class TestInputRequiredResultElicitation < MCP::Tool - tool_name "test_input_required_result_elicitation" - description "A tool that asks for the user's name through an input_required result (SEP-2322)" - - class << self - def call(server_context:, **_args) - content = Mrtr.accepted_content(server_context.input_response("user_name")) - name = content && Mrtr.read(content, :name) - - unless name.is_a?(String) - 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([MCP::Content::Text.new("Hello, #{name}!").to_h]) - end - end - end - - class TestInputRequiredResultSampling < MCP::Tool - tool_name "test_input_required_result_sampling" - description "A tool that asks for an LLM completion through an input_required result (SEP-2322)" - - class << self - def call(server_context:, **_args) - text = Mrtr.sampled_text(server_context.input_response("sample_request")) - - unless text - return MCP::Server::InputRequiredResult.new( - input_requests: { - sample_request: { - method: "sampling/createMessage", - params: { - messages: [{ role: "user", content: { type: "text", text: "What is the capital of France?" } }], - maxTokens: 100, - }, - }, - }, - ) - end - - MCP::Tool::Response.new([MCP::Content::Text.new("Sampled: #{text}").to_h]) - end - end - end - - class TestInputRequiredResultListRoots < MCP::Tool - tool_name "test_input_required_result_list_roots" - description "A tool that asks for the client's roots through an input_required result (SEP-2322)" - - class << self - def call(server_context:, **_args) - roots = Mrtr.read(server_context.input_response("roots_request"), :roots) - - unless roots.is_a?(Array) - return MCP::Server::InputRequiredResult.new( - input_requests: { roots_request: { method: "roots/list" } }, - ) - end - - uris = roots.filter_map { |root| Mrtr.read(root, :uri) } - MCP::Tool::Response.new([MCP::Content::Text.new("Roots: #{uris.join(", ")}").to_h]) - end - end - end - - class TestInputRequiredResultRequestState < MCP::Tool - tool_name "test_input_required_result_request_state" - description "A tool whose input_required result carries an opaque requestState (SEP-2322)" - - class << self - def call(server_context:, **_args) - state = Mrtr.parse_state(server_context.request_state) - confirmed = Mrtr.accepted_content(server_context.input_response("confirm")) - - if state && state["kind"] == "request-state" && confirmed - return MCP::Tool::Response.new([MCP::Content::Text.new("state-ok: requestState validated").to_h]) - end - - MCP::Server::InputRequiredResult.new( - input_requests: { - confirm: { - method: "elicitation/create", - params: { - message: "Confirm to continue", - requestedSchema: { - type: "object", - properties: { ok: { type: "boolean" } }, - }, - }, - }, - }, - request_state: JSON.generate({ kind: "request-state", nonce: SecureRandom.hex(8) }), - ) - end - end - end - - class TestInputRequiredResultMultipleInputs < MCP::Tool - tool_name "test_input_required_result_multiple_inputs" - description "A tool that embeds elicitation, sampling, and roots/list inputs in one input_required result (SEP-2322)" - - class << self - def call(server_context:, **_args) - content = Mrtr.accepted_content(server_context.input_response("user_name")) - name = content && Mrtr.read(content, :name) - greeting = Mrtr.sampled_text(server_context.input_response("greeting")) - roots = Mrtr.read(server_context.input_response("client_roots"), :roots) - - if name.is_a?(String) && greeting && roots.is_a?(Array) - return MCP::Tool::Response.new( - [MCP::Content::Text.new("#{greeting} #{name} (#{roots.length} roots)").to_h], - ) - end - - 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"], - }, - }, - }, - greeting: { - method: "sampling/createMessage", - params: { - messages: [{ role: "user", content: { type: "text", text: "Write a one-word greeting" } }], - maxTokens: 50, - }, - }, - client_roots: { method: "roots/list" }, - }, - request_state: JSON.generate({ kind: "multiple-inputs" }), - ) - end - end - end - - class TestInputRequiredResultMultiRound < MCP::Tool - tool_name "test_input_required_result_multi_round" - description "A tool that needs two elicitation rounds before completing (SEP-2322)" - - class << self - def call(server_context:, **_args) - state = Mrtr.parse_state(server_context.request_state) - - case state && state["round"] - when 1 - content = Mrtr.accepted_content(server_context.input_response("user_name")) - name = content && Mrtr.read(content, :name) - return second_round(name) if name.is_a?(String) - when 2 - content = Mrtr.accepted_content(server_context.input_response("favorite_color")) - color = content && Mrtr.read(content, :color) - if color.is_a?(String) - return MCP::Tool::Response.new([MCP::Content::Text.new("#{state["name"]} likes #{color}").to_h]) - end - end - - first_round - end - - private - - def first_round - 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"], - }, - }, - }, - }, - request_state: JSON.generate({ round: 1 }), - ) - end - - def second_round(name) - MCP::Server::InputRequiredResult.new( - input_requests: { - favorite_color: { - method: "elicitation/create", - params: { - message: "What is your favorite color?", - requestedSchema: { - type: "object", - properties: { color: { type: "string" } }, - required: ["color"], - }, - }, - }, - }, - request_state: JSON.generate({ round: 2, name: name }), - ) - end - end - end - - class TestInputRequiredResultTamperedState < MCP::Tool - tool_name "test_input_required_result_tampered_state" - description "A tool whose sealed requestState rejects tampered echoes (SEP-2322)" - - class << self - # A tampered `requestState` never reaches this handler: the server-level unsealing rejects it with -32602 - # before dispatch, so the tool body is a plain MRTR flow. - def call(server_context:, **_args) - state = Mrtr.parse_state(server_context.request_state) - confirmed = Mrtr.accepted_content(server_context.input_response("confirm")) - - if state && state["kind"] == "tamper-check" && confirmed - return MCP::Tool::Response.new([MCP::Content::Text.new("requestState integrity verified").to_h]) - end - - MCP::Server::InputRequiredResult.new( - input_requests: { - confirm: { - method: "elicitation/create", - params: { - message: "Confirm to continue", - requestedSchema: { - type: "object", - properties: { ok: { type: "boolean" } }, - }, - }, - }, - }, - request_state: JSON.generate({ kind: "tamper-check", nonce: SecureRandom.hex(8) }), - ) - end - end - end - - class TestInputRequiredResultCapabilities < MCP::Tool - tool_name "test_input_required_result_capabilities" - description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)" - - class << self - def call(server_context:, **_args) - capabilities = server_context.client_capabilities || {} - elicited = Mrtr.accepted_content(server_context.input_response("elicit_input")) - sampled = Mrtr.sampled_text(server_context.input_response("sample_input")) - - requests = {} - if Mrtr.read(capabilities, :elicitation) && !elicited - requests[:elicit_input] = { - method: "elicitation/create", - params: { - message: "Provide a value", - requestedSchema: { - type: "object", - properties: { value: { type: "string" } }, - }, - }, - } - end - if Mrtr.read(capabilities, :sampling) && !sampled - requests[:sample_input] = { - method: "sampling/createMessage", - params: { - messages: [{ role: "user", content: { type: "text", text: "Say hello" } }], - maxTokens: 50, - }, - } - end - - if requests.empty? - MCP::Tool::Response.new( - [MCP::Content::Text.new("Inputs: elicit=#{!elicited.nil?}, sample=#{!sampled.nil?}").to_h], - ) - else - MCP::Server::InputRequiredResult.new(input_requests: requests) - end - end - end - end - - class TestLoggingTool < MCP::Tool - tool_name "test_logging_tool" - description "A tool that logs only for requests opting in via the _meta logLevel (SEP-2575)" - - class << self - def call(server_context:, **_args) - # On modern requests the notification is dropped unless `io.modelcontextprotocol/logLevel` authorized it, - # so the call itself is unconditional. - server_context.notify_log_message(data: "Log level honored", level: "info", logger: "conformance") - - MCP::Tool::Response.new([MCP::Content::Text.new("Logging evaluated").to_h]) - end - end - end - - class TestTriggerToolChange < MCP::Tool - tool_name "test_trigger_tool_change" - description "A diagnostic tool that broadcasts notifications/tools/list_changed to listen streams (SEP-2575)" - - class << self - # The broadcast alone exercises the `subscriptions/listen` delivery, so no actual - # tool-list mutation is needed, matching the suite's TypeScript reference fixture. - def call(server_context:, **_args) - server_context.notify_tools_list_changed - - MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h]) - end - end - end - - class TestTriggerPromptChange < MCP::Tool - tool_name "test_trigger_prompt_change" - description "A diagnostic tool that broadcasts notifications/prompts/list_changed to listen streams (SEP-2575)" - - class << self - def call(server_context:, **_args) - server_context.notify_prompts_list_changed - - MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h]) - end - end - end end module Prompts @@ -793,45 +390,6 @@ module Conformance end end end - - class TestInputRequiredResultPrompt < MCP::Prompt - prompt_name "test_input_required_result_prompt" - description "A prompt that asks for user context through an input_required result (SEP-2322)" - - class << self - def template(_args, server_context: nil) - content = server_context && Mrtr.accepted_content(server_context.input_response("user_context")) - context = content && Mrtr.read(content, :context) - - unless context.is_a?(String) - return MCP::Server::InputRequiredResult.new( - input_requests: { - user_context: { - method: "elicitation/create", - params: { - message: "Provide context for the prompt", - requestedSchema: { - type: "object", - properties: { context: { type: "string" } }, - required: ["context"], - }, - }, - }, - }, - ) - end - - MCP::Prompt::Result.new( - messages: [ - MCP::Prompt::Message.new( - role: "user", - content: MCP::Content::Text.new("Context: #{context}"), - ), - ], - ) - end - end - end end class Server @@ -924,33 +482,15 @@ module Conformance Tools::TestElicitationSep1034Defaults, Tools::TestElicitationSep1330Enums, Tools::TestReconnection, - Tools::TestMissingCapability, - Tools::TestInputRequiredResultElicitation, - Tools::TestInputRequiredResultSampling, - Tools::TestInputRequiredResultListRoots, - Tools::TestInputRequiredResultRequestState, - Tools::TestInputRequiredResultMultipleInputs, - Tools::TestInputRequiredResultMultiRound, - Tools::TestInputRequiredResultTamperedState, - Tools::TestInputRequiredResultCapabilities, - Tools::TestStreamingElicitation, - Tools::TestLoggingTool, - Tools::TestTriggerToolChange, - Tools::TestTriggerPromptChange, ], prompts: [ Prompts::TestSimplePrompt, Prompts::TestPromptWithArguments, Prompts::TestPromptWithEmbeddedResource, Prompts::TestPromptWithImage, - Prompts::TestInputRequiredResultPrompt, ], resources: resources, resource_templates: resource_templates, - # A per-boot random key is enough for conformance: every MRTR round trip completes - # within one server process, and sealing keeps the fixture's `requestState` values - # tamper-evident without any tool-level verification code. - request_state_security: MCP::Server::RequestStateSecurity.new(key: SecureRandom.bytes(32)), capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, @@ -1037,9 +577,7 @@ module Conformance ).to_h, ] else - # Per SEP-2164, an unknown URI answers with -32602 carrying the URI in `error.data`; - # an empty `contents` array is forbidden. - raise MCP::Server::ResourceNotFoundError.new(uri, params) + [] end end end diff --git a/conformance/server_runner.rb b/conformance/server_runner.rb index 3decbad..0a4e9e2 100644 --- a/conformance/server_runner.rb +++ b/conformance/server_runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Starts the conformance server and runs `npx @modelcontextprotocol/conformance@alpha` against it. +# Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it. require "English" require "net/http" require_relative "server" @@ -31,7 +31,7 @@ module Conformance def build_command expected_failures_yml = File.expand_path("expected_failures.yml", __dir__) - npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance@alpha", "server", "--url", "http://localhost:#{@port}/mcp"] + npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance", "server", "--url", "http://localhost:#{@port}/mcp"] npx_command += ["--scenario", @scenario] if @scenario npx_command += ["--spec-version", @spec_version] if @spec_version npx_command += ["--verbose"] if @verbose diff --git a/docs/Gemfile b/docs/Gemfile deleted file mode 100644 index eb295d0..0000000 --- a/docs/Gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -# Dependencies for the local docs preview (`rake docs:preview`), kept out of the gem's own Gemfile: -# github-pages mirrors the GitHub Pages runtime that builds the released site -# (jekyll-remote-theme, jekyll-redirect-from, and the Jekyll version Pages actually runs). -source "https://rubygems.org" - -gem "github-pages", group: :jekyll_plugins -gem "webrick" - -# Former default gems that the Jekyll version pinned by github-pages still requires on Ruby 4.0. -gem "base64" -gem "bigdecimal" -gem "csv" -gem "logger" diff --git a/docs/_client/authorization.md b/docs/_client/authorization.md deleted file mode 100644 index 72efd01..0000000 --- a/docs/_client/authorization.md +++ /dev/null @@ -1,405 +0,0 @@ ---- -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` and must name that origin as its `issuer` (RFC 8414 Section 3.3), - and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed. - When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`, - raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright. -- 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). - `ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead; - their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again. -- 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. -- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built. - Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request). -- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard. - See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client). -- `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). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what - lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members - instead drops these bindings with no error. -- `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`. -- `token_request_params`: Hash of String keys and values added to every token request the provider makes, - for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`. - Defaults to `nil`, which adds nothing. The authorization request is not affected. - A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity, - or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, - when the provider is built, and the accepted Hash is copied and frozen. - Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes; - the flow checks the returned value by the same rules and raises the same error before the token request is sent. - -{: .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")), -) -``` - -### Token Endpoint Errors - -When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and -the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2). -For example: - -```text -Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods -``` - -The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string -diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers. -An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token -recovery behavior is unchanged. - -Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when -truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed. -The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors. -Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging -and redaction policy before persisting them or displaying them to users. - -### 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 is not expected to issue a refresh token (RFC 6749 Section 4.4.3); -a refresh token the authorization server issues anyway is used on the next `401`. - -```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) - # token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires) -) - -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`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. - Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`. - -### 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`. -A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again. -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`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`. - -### Customizing the OAuth HTTP Client - -The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery, -dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own, -not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server. -To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider: - -```ruby -provider = MCP::Client::OAuth::ClientCredentialsProvider.new( - client_id: "my-service", - client_secret: ENV.fetch("MCP_CLIENT_SECRET"), - http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder }, -) -``` - -The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL, -and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection. -It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently; -today it runs once per authorization attempt, but that is not a promise. -A few constraints follow from the checks described below: - -- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send - to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError` - before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)` - that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off. -- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off. -- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request - and to turn a token endpoint error into `Flow::InvalidGrantError`. -- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives. -- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens, - and the access tokens in token responses; redact them before they reach a log. - -### 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. - -On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request -goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)` -replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against -the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused. -A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself. - -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. - -### Reviewing the authorization request - -The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want -your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes -it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects, -so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on -MCP servers rather than on clients. - -`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so: - -```ruby -ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"] - -provider = MCP::Client::OAuth::Provider.new( - # client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example. - authorization_request_validator: ->(request) { - ALLOWED_ISSUERS.include?(request.authorization_server) - }, -) -``` - -The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither -the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification -can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide -(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes. - -`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized -(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one, -the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce -a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured. - -Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path, -and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising. - -The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has -any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it: - -```ruby -ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] } - -provider = MCP::Client::OAuth::Provider.new( - # client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example. - authorization_request_validator: ->(request) { - ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) } - }, -) -``` - -A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them. - -It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential, -or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened, -and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server -the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected. -Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it, -while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure. - -The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification -requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for, -which is what every MCP SDK does today. - -It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for. -An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to -present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is. -The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse. -Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization. diff --git a/docs/_client/cancellation.md b/docs/_client/cancellation.md deleted file mode 100644 index 2a9409c..0000000 --- a/docs/_client/cancellation.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -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 deleted file mode 100644 index 2e3077c..0000000 --- a/docs/_client/index.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -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`), and automatic answers to server pings; - see [Answering Server Pings](/client/ping/#answering-server-pings) -- 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 [Completion](/server/completion/) -- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`, - or `on_roots` handlers are registered; see [Multi Round-Trip Requests](/client/mrtr/) -- 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 deleted file mode 100644 index 632db50..0000000 --- a/docs/_client/lifecycle.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -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/discover/) 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/discover/) page -for the wire shapes. - -```ruby -result = client.discover -result.supported_versions # => ["2026-07-28"] -``` diff --git a/docs/_client/mrtr.md b/docs/_client/mrtr.md deleted file mode 100644 index 4a68e5f..0000000 --- a/docs/_client/mrtr.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -layout: default -title: Multi Round-Trip Requests -nav_order: 4 -redirect_from: - - /client/multi-round-trip-results/ ---- - -# Multi Round-Trip Requests - -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 Requests](/server/mrtr/) page. diff --git a/docs/_client/pagination.md b/docs/_client/pagination.md deleted file mode 100644 index 1f89c02..0000000 --- a/docs/_client/pagination.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -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 deleted file mode 100644 index f546afe..0000000 --- a/docs/_client/ping.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -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. - -## Answering Server Pings - -On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with -the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open -(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings. -Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when -the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is -dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:` -on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits. - -To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces -the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`, -as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page. - -## 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 deleted file mode 100644 index d69e5e9..0000000 --- a/docs/_client/transports.md +++ /dev/null @@ -1,285 +0,0 @@ ---- -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 -- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings) - -{: .note } -> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create` -> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer -> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route, -> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation` -> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/). - -## 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, and `event_stream_parser` -to read SSE (`text/event-stream`) responses. -Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both, -so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always -does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well. -`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to -answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode) -serving handshake-lifecycle clients, and only while no listening stream is opened: - -```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 Requests](/client/mrtr/). - -### 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 -``` - -The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through -the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client). - -{: .note } -> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside -> the SSE streaming callback of another response, re-entering the connection on the same thread. -> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter -> with persistent connections must tolerate that re-entry. - -## 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 4f101a5..b589e4e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,44 +2,18 @@ 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 -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 +# Include generated files and directories which may start with underscores +include: + - "_*" # Search search_enabled: true -# Callouts -callouts: - note: - title: Note - color: blue - important: - title: Important - color: yellow - warning: - title: Warning - color: red +# 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" diff --git a/docs/_extensions/capability-extensions.md b/docs/_extensions/capability-extensions.md deleted file mode 100644 index ba3f0c0..0000000 --- a/docs/_extensions/capability-extensions.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -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/discover/#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 deleted file mode 100644 index 5b52c0f..0000000 --- a/docs/_extensions/index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -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 deleted file mode 100644 index 0c9c668..0000000 --- a/docs/_extensions/mcp-apps.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -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 deleted file mode 100644 index bfd1278..0000000 --- a/docs/_includes/footer_custom.html +++ /dev/null @@ -1,48 +0,0 @@ -{%- 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 34a88e6..701d0ae 100644 --- a/docs/_includes/head_custom.html +++ b/docs/_includes/head_custom.html @@ -1,5 +1,3 @@ - - - - diff --git a/docs/_includes/nav_footer_custom.html b/docs/_includes/nav_footer_custom.html deleted file mode 100644 index f366cb0..0000000 --- a/docs/_includes/nav_footer_custom.html +++ /dev/null @@ -1,14 +0,0 @@ -{%- comment -%} - Replaces the theme's "This site uses Just the Docs" attribution, shown at - the bottom of the sidebar on desktop and in the page footer on mobile. - _data/versions.yml exists only on the deployed gh-pages tree, where - bin/generate-gh-pages.sh generates it; without it (local previews of - the docs/ source) only the license sentence renders. -{%- endcomment -%} -{% if site.data.versions %} -Documentation for mcp {{ site.data.versions | first }}, -licensed CC-BY-4.0. -{% else %} -Documentation licensed CC-BY-4.0. -{% endif %} -See LICENSE for the code. diff --git a/docs/_preview/taint_shim.rb b/docs/_preview/taint_shim.rb deleted file mode 100644 index 9d43470..0000000 --- a/docs/_preview/taint_shim.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Liquid 4.0.3 (pinned by github-pages) still calls the taint API that Ruby 3.2 removed. -# Restore it as a no-op for the local docs preview only; `rake docs:preview` loads -# this file via `RUBYOPT`, so nothing outside the preview process is affected. -class Object - def tainted? - false - end - - def taint - self - end - - def untaint - self - end -end diff --git a/docs/_sass/color_schemes/ruby-dark.scss b/docs/_sass/color_schemes/ruby-dark.scss deleted file mode 100644 index 5bd0ef1..0000000 --- a/docs/_sass/color_schemes/ruby-dark.scss +++ /dev/null @@ -1,17 +0,0 @@ -// 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 deleted file mode 100644 index 5da81c3..0000000 --- a/docs/_sass/color_schemes/ruby-light.scss +++ /dev/null @@ -1,8 +0,0 @@ -// 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 7f89847..5149a2a 100644 --- a/docs/_sass/custom/custom.scss +++ b/docs/_sass/custom/custom.scss @@ -62,88 +62,6 @@ color-scheme: dark; } -/* - * Applied before the dark stylesheet finishes loading, so browsers that - * do not support blocking="render" avoid a light-colored first paint. - * 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: #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 deleted file mode 100644 index b34691c..0000000 --- a/docs/_sass/custom/setup.scss +++ /dev/null @@ -1,2 +0,0 @@ -// 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 deleted file mode 100644 index 1bc0b17..0000000 --- a/docs/_server/cancellation.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -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/discover/#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/completion.md b/docs/_server/completion.md deleted file mode 100644 index 583a4bc..0000000 --- a/docs/_server/completion.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -layout: default -title: Completion -nav_order: 16 -redirect_from: - - /server/completions/ ---- - -# Completion - -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 deleted file mode 100644 index 9480df8..0000000 --- a/docs/_server/configuration.md +++ /dev/null @@ -1,206 +0,0 @@ ---- -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/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. diff --git a/docs/_server/custom-methods.md b/docs/_server/custom-methods.md deleted file mode 100644 index 85dc87a..0000000 --- a/docs/_server/custom-methods.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -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/discover.md b/docs/_server/discover.md deleted file mode 100644 index 20f8bd6..0000000 --- a/docs/_server/discover.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -layout: default -title: Discovery -nav_order: 3 -redirect_from: - - /server/discovery/ ---- - -# 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 deleted file mode 100644 index 4eea52b..0000000 --- a/docs/_server/elicitation.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -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/discover/#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 Requests](/server/mrtr/). - -{: .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 deleted file mode 100644 index 5d938f1..0000000 --- a/docs/_server/index.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -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; deprecated as of 2026-07-28) -- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28) -- 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/discover/) -- `subscriptions/listen` - Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), replacing the legacy HTTP GET - listening stream; see [Subscriptions](/server/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 Requests](/server/mrtr/) -- `ping` - Simple health check -- `logging/setLevel` - Configures the minimum log level for the server (deprecated as of 2026-07-28) -- `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; deprecated as of 2026-07-28) -- `sampling/createMessage` - Requests LLM completion from the client (server-to-client; deprecated as of 2026-07-28) -- `elicitation/create` - Requests user input from the client (server-to-client) diff --git a/docs/_server/logging.md b/docs/_server/logging.md deleted file mode 100644 index b861628..0000000 --- a/docs/_server/logging.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -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/discover/#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/mrtr.md b/docs/_server/mrtr.md deleted file mode 100644 index 3113bdf..0000000 --- a/docs/_server/mrtr.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -layout: default -title: Multi Round-Trip Requests -nav_order: 10 -redirect_from: - - /server/multi-round-trip-results/ ---- - -# Multi Round-Trip Requests - -The [modern lifecycle](/server/discover/#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 Requests](/client/mrtr/) page. diff --git a/docs/_server/notifications.md b/docs/_server/notifications.md deleted file mode 100644 index 7cf9460..0000000 --- a/docs/_server/notifications.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -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/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28), clients receive these broadcasts through the `subscriptions/listen` stream; -see [Subscriptions](/server/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 deleted file mode 100644 index c721f18..0000000 --- a/docs/_server/pagination.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -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 deleted file mode 100644 index d8c6627..0000000 --- a/docs/_server/ping.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -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/discover/#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/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. -The Ruby client answers the pings sent with `ServerSession#ping` automatically; -see [Answering Server Pings](/client/ping/#answering-server-pings). diff --git a/docs/_server/progress.md b/docs/_server/progress.md deleted file mode 100644 index f82645b..0000000 --- a/docs/_server/progress.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -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/discover/#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 deleted file mode 100644 index e8f5a48..0000000 --- a/docs/_server/prompts.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -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 deleted file mode 100644 index f53548a..0000000 --- a/docs/_server/resources.md +++ /dev/null @@ -1,319 +0,0 @@ ---- -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 deleted file mode 100644 index a3341c6..0000000 --- a/docs/_server/roots.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -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 deleted file mode 100644 index af37df0..0000000 --- a/docs/_server/sampling.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -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 deleted file mode 100644 index f45f576..0000000 --- a/docs/_server/server-context.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -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/subscriptions.md b/docs/_server/subscriptions.md deleted file mode 100644 index 22116aa..0000000 --- a/docs/_server/subscriptions.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -layout: default -title: Subscriptions -nav_order: 12 -redirect_from: - - /server/notification-subscriptions/ ---- - -# 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" } - } -} -``` - -## Delivery Semantics - -A stream delivers only the changes that happen after its acknowledgement; it is not a replay log. -Nothing is queued while no stream is connected, and a dropped stream is gone together with anything it missed. -Clients fetch the current state after subscribing and treat each notification as a cue to refetch, -rather than as a payload. - -## Transport Support - -The stream is served on the Streamable HTTP modern path; stdio answers `-32601`. -A host that cannot hold an SSE response open (such as the [Rails controller pattern](/server/transports/#rails-controller), -which buffers the body) declines the method with `serve_subscriptions_listen: false`, answering `-32601` and dropping -the `listChanged`/`subscribe` flags from discovery. - -## 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/tools.md b/docs/_server/tools.md deleted file mode 100644 index 0ebf6fe..0000000 --- a/docs/_server/tools.md +++ /dev/null @@ -1,419 +0,0 @@ ---- -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 deleted file mode 100644 index 009c506..0000000 --- a/docs/_server/transports.md +++ /dev/null @@ -1,288 +0,0 @@ ---- -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/discover/#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/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/mrtr/) `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/discover/#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, - # and since `render` buffers the whole body, `subscriptions/listen` streams are declined. - transport = MCP::Server::Transports::StreamableHTTPTransport.new( - server, - stateless: true, - serve_subscriptions_listen: false, - ) - status, headers, body = transport.handle_request(request) - - render(json: body.first, status: status, headers: headers) - end -end -``` - -{: .important } -> The controller pattern builds a fresh transport per request and `render` buffers the whole body, -> so it cannot serve the open SSE stream that [`subscriptions/listen`](/server/subscriptions/) needs: -> without `serve_subscriptions_listen: false`, that request returns a streaming `Proc` body -> that `body.first` cannot render, and a modern client's notification stream fails with a 500. -> With the flag, the method answers 404 with JSON-RPC `-32601`, and `server/discover` stops -> advertising the `listChanged`/`subscribe` capability flags, so well-behaved clients do not ask. -> To actually serve listen streams, use the [mount approach](#rails-mount). - -### 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/discover/#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/discover/#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/discover/#the-stateless-modern-lifecycle), which never create a session; -their long-lived [`subscriptions/listen`](/server/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/discover/#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/discover/#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 20226a0..ac92fb1 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="ruby-dark" %} +{% include css/just-the-docs.scss.liquid color_scheme="dark" %} diff --git a/docs/assets/css/just-the-docs-light.scss b/docs/assets/css/just-the-docs-light.scss index c2a0250..ac69688 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="ruby-light" %} +{% include css/just-the-docs.scss.liquid color_scheme="light" %} diff --git a/docs/building-clients.md b/docs/building-clients.md new file mode 100644 index 0000000..6b4c7d0 --- /dev/null +++ b/docs/building-clients.md @@ -0,0 +1,153 @@ +--- +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 new file mode 100644 index 0000000..408f4be --- /dev/null +++ b/docs/building-servers.md @@ -0,0 +1,332 @@ +--- +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 deleted file mode 100644 index 819f4c1..0000000 --- a/docs/examples.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -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 deleted file mode 100644 index a280d7f..0000000 --- a/docs/favicon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/docs/index.md b/docs/index.md index ee275fc..564b5f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,30 +4,23 @@ 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. -## Features +**Key features:** -- 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 requests, notifications, progress, logging, cancellation, completions, and pagination +- 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 ## Quick Start -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: +Here is a minimal MCP server using 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( @@ -47,72 +40,25 @@ 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 ``` -Save the script as `server.rb`, run it, and send JSON-RPC requests via stdin: +Run the script and send JSON-RPC requests via stdin: ```console $ ruby server.rb -{"jsonrpc":"2.0","id":"1","method":"ping"} +{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"example","version":"0.1.0"}}} {"jsonrpc":"2.0","id":"2","method":"tools/list"} {"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}} ``` -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 requests, notifications, protocol utilities, and configuration -- [Building Clients](client/) - transports, lifecycle negotiation, multi round-trip requests, and OAuth 2.1 authorization -- [Extensions](/extensions/) - capability extensions and MCP Apps +For comprehensive documentation, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md). ## API Documentation @@ -128,4 +74,4 @@ Full API reference is hosted on [RubyDoc.info](https://rubydoc.info/gems/mcp). S ## License -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. +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. diff --git a/docs/installation.md b/docs/installation.md index 91aaee1..0822994 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,25 +2,14 @@ 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: @@ -38,16 +27,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 "faraday", ">= 2.0" +gem 'mcp' +gem 'faraday', '>= 2.0' ``` - -Reading SSE (`text/event-stream`) responses needs `event_stream_parser` as well. -Whether a response is JSON or SSE is the server's choice, made for each response, and this SDK's own server picks SSE by default, -so a client written for arbitrary servers needs both gems: - -```ruby -gem "faraday", ">= 2.0" -gem "event_stream_parser", ">= 1.0" -``` - -The [Transports](/client/transports/#http-transport-layer) page describes the one setup where `faraday` alone is enough. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md deleted file mode 100644 index 54e5e53..0000000 --- a/docs/protocol-versions.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -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/discover/), 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/examples/README.md b/examples/README.md index af1828a..86931c5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -98,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S **Available Tools:** -- `notification_tool` - Sends progress notifications over SSE, with optional delays +- `notification_tool` - Send custom SSE notifications with optional delays - `echo` - Simple echo tool for basic testing **Usage:** @@ -124,15 +124,14 @@ An interactive client that connects to the SSE stream and provides a menu-driven 1. Start the SSE test server in one terminal: -```console -$ ruby examples/streamable_http_server.rb -``` + ```console + $ ruby examples/streamable_http_server.rb + ``` 2. Run the SSE test client in another terminal: - -```console -$ ruby examples/streamable_http_client.rb -``` + ```console + $ ruby examples/streamable_http_client.rb + ``` The client will: @@ -156,34 +155,6 @@ $ bundle exec puma --port 9292 The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough. -### 8. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`) - -A server and client pair demonstrating the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake and per-session state with sessionless, self-contained requests. - -**Features:** - -- `server/discover` capability discovery before (or instead of) a handshake -- The per-request `_meta` envelope and `Mcp-Method` / `Mcp-Name` headers, stamped by the SDK automatically -- `resultType` stamping and SEP-2549 cache hints (`ttlMs`, `cacheScope`) on results -- A multi round-trip `deploy` tool (SEP-2322), resumed automatically by the client's elicitation handler -- The removal of legacy-only methods such as `ping` - -**Usage:** - -1. Start the server in one terminal: - -```console -$ ruby examples/modern_http_server.rb -``` - -2. Run the client in another terminal: - -```console -$ ruby examples/modern_http_client.rb -``` - -The same server still accepts the legacy `initialize` flow: the transport routes each request to the legacy or modern lifecycle by its `MCP-Protocol-Version` header. - ### Testing with MCP Inspector [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers. @@ -217,78 +188,24 @@ You can also test SSE functionality manually using cURL: ```console SESSION_ID=$(curl -D - -s -o /dev/null http://localhost:9393 \ - -H "Accept: application/json, text/event-stream" \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r') ``` -2. Optionally connect the standalone SSE stream, which carries server-initiated messages that are not tied to a request (in another terminal): +2. Connect to SSE stream (in one terminal): ```console curl -i -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393 ``` -3. Call the notification tool. The `notifications/progress` events (requested via the `progressToken` in `_meta`) and the final response arrive as SSE events on the POST response itself: +3. Trigger notifications (in another terminal): ```console +# Send immediate notification curl -i http://localhost:9393 \ - -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: $SESSION_ID" \ - --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!","delay":0.5},"_meta":{"progressToken":"curl-progress"}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}' ``` -### Testing the modern lifecycle with cURL - -The modern lifecycle (2026-07-28, SEP-2575) is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. Start `examples/modern_http_server.rb` and walk it manually: - -1. Probe capabilities with `server/discover`: - -```console -curl -s http://localhost:9494 \ - -H "Accept: application/json, text/event-stream" \ - --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' -``` - -2. List tools (the `MCP-Protocol-Version` header selects the era, `Mcp-Method` mirrors the method, and `params._meta` carries the SEP-2575 envelope): - -```console -curl -s http://localhost:9494 \ - -H "Accept: application/json, text/event-stream" \ - -H "MCP-Protocol-Version: 2026-07-28" \ - -H "Mcp-Method: tools/list" \ - --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' -``` - -3. Call a tool (name-bearing methods additionally mirror the name in the `Mcp-Name` header): - -```console -curl -s http://localhost:9494 \ - -H "Accept: application/json, text/event-stream" \ - -H "MCP-Protocol-Version: 2026-07-28" \ - -H "Mcp-Method: tools/call" \ - -H "Mcp-Name: greet" \ - --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' -``` - -4. Observe a multi round-trip result (SEP-2322), then resume it by echoing the `requestState` back together with the answer. Declaring the `elicitation` capability in the envelope is required before the server may embed elicitation requests: - -```console -curl -s http://localhost:9494 \ - -H "Accept: application/json, text/event-stream" \ - -H "MCP-Protocol-Version: 2026-07-28" \ - -H "Mcp-Method: tools/call" \ - -H "Mcp-Name: deploy" \ - --json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' - -curl -s http://localhost:9494 \ - -H "Accept: application/json, text/event-stream" \ - -H "MCP-Protocol-Version: 2026-07-28" \ - -H "Mcp-Method: tools/call" \ - -H "Mcp-Name: deploy" \ - --json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"inputResponses":{"environment":{"action":"accept","content":{"environment":"staging"}}},"requestState":"{\"app\":\"storefront\"}","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}' -``` - -The same server still accepts the legacy flow from the previous sections, routed by the `MCP-Protocol-Version` header. - ## Streamable HTTP Transport Details ### Protocol Flow @@ -319,15 +236,13 @@ Initialize a session: ```console curl -i http://localhost:9292 \ - -H "Accept: application/json, text/event-stream" \ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' ``` List tools (using the session ID from initialization): ```console curl -i http://localhost:9292 \ - -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/list","id":2}' ``` @@ -336,7 +251,6 @@ Call a tool: ```console curl -i http://localhost:9292 \ - -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: YOUR_SESSION_ID" \ --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}' ``` diff --git a/examples/http_client.rb b/examples/http_client.rb index b61b563..4ef95c7 100644 --- a/examples/http_client.rb +++ b/examples/http_client.rb @@ -4,7 +4,7 @@ require "net/http" require "json" require "uri" -# Simple HTTP client example for interacting with the MCP HTTP server. +# Simple HTTP client example for interacting with the MCP HTTP server class MCPHTTPClient def initialize(base_url = "http://localhost:9292") @base_url = base_url @@ -30,51 +30,19 @@ class MCPHTTPClient response = http.request(request) - # Store session ID if provided. + # Store session ID if provided if response["Mcp-Session-Id"] @session_id = response["Mcp-Session-Id"] puts "Session ID: #{@session_id}" end - parse_response_body(response) - end - - # In the transport's default SSE mode, POST responses on an established session arrive as - # a Server-Sent Events stream whose `data:` line carries the JSON-RPC response; unwrap it - # before parsing. - def parse_response_body(response) - body = response.body - - if response["Content-Type"]&.start_with?("text/event-stream") - data_lines = body.lines.select { |line| line.start_with?("data:") } - body = data_lines.map { |line| line.sub(/\Adata:\s*/, "") }.join - end - - JSON.parse(body) - end - - def send_notification(method, params = nil) - uri = URI(@base_url) - http = Net::HTTP.new(uri.host, uri.port) - - request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path) - request["Content-Type"] = "application/json" - request["Mcp-Session-Id"] = @session_id if @session_id - - # Notifications carry no `id` and receive no JSON-RPC response body. - request.body = { - jsonrpc: "2.0", - method: method, - params: params, - }.compact.to_json - - http.request(request) + JSON.parse(response.body) end def initialize_session puts "=== Initializing session ===" result = send_request("initialize", { - protocolVersion: "2025-11-25", + protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "example_client", @@ -86,14 +54,6 @@ class MCPHTTPClient result end - def notify_initialized - puts "=== Sending notifications/initialized ===" - response = send_notification("notifications/initialized") - puts "Response status: #{response.code} #{response.message}" - - response - end - def ping puts "=== Sending ping ===" result = send_request("ping") @@ -187,40 +147,37 @@ def main client = MCPHTTPClient.new begin - # Initialize session. + # Initialize session client.initialize_session - # Complete the handshake. - client.notify_initialized - - # Test ping. + # Test ping client.ping - # List available tools. + # List available tools client.list_tools - # Call the example_tool (note: snake_case name). + # Call the example_tool (note: snake_case name) client.call_tool("example_tool", { a: 5, b: 3 }) - # Call the echo tool. + # Call the echo tool client.call_tool("echo", { message: "Hello from client!" }) - # List prompts. + # List prompts client.list_prompts - # Get a prompt (note: snake_case name). + # Get a prompt (note: snake_case name) client.get_prompt("example_prompt", { message: "This is a test message" }) - # List resources. + # List resources client.list_resources - # Read a resource (the URI registered by http_server.rb). - client.read_resource("https://test_resource.invalid") + # Read a resource + client.read_resource("test_resource") rescue => e puts "Error: #{e.message}" puts e.backtrace ensure - # Clean up session. + # Clean up session client.close_session end end diff --git a/examples/http_server.rb b/examples/http_server.rb index 33d8d80..4f3edee 100644 --- a/examples/http_server.rb +++ b/examples/http_server.rb @@ -56,7 +56,6 @@ end # Set up the server server = MCP::Server.new( name: "example_http_server", - version: "1.0.0", tools: [ExampleTool], prompts: [ExamplePrompt], resources: [ @@ -170,9 +169,7 @@ puts <<~MESSAGE Starting MCP HTTP server on http://localhost:9292 Use POST requests to initialize and send JSON-RPC commands Example initialization: - curl -i http://localhost:9292 \\ - -H "Accept: application/json, text/event-stream" \\ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + curl -i http://localhost:9292 --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' The server will return a session ID in the Mcp-Session-Id header. Use this session ID for subsequent requests. diff --git a/examples/modern_http_client.rb b/examples/modern_http_client.rb deleted file mode 100644 index ca2cd1f..0000000 --- a/examples/modern_http_client.rb +++ /dev/null @@ -1,133 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) -require "mcp" -require "json" - -# Client example for the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake -# and per-session state with sessionless, self-contained requests. -class MCPModernHTTPClient - def initialize(base_url = "http://localhost:9494") - @transport = MCP::Client::HTTP.new(url: base_url) - @client = MCP::Client.new(transport: @transport) - end - - # `server/discover` (SEP-2575) is sessionless capability discovery that works before, or instead of, `connect`. - def discover - puts "=== Discovering server capabilities (server/discover) ===" - result = @client.discover - puts <<~MESSAGE - Supported versions: #{result.supported_versions.inspect} - Server: #{result.server_info} - Cache hints: ttlMs=#{result.ttl_ms}, cacheScope=#{result.cache_scope} (SEP-2549) - Instructions: #{result.instructions} - MESSAGE - - result - end - - # The handler answers `elicitation/create` requests embedded in SEP-2322 `input_required` results; once registered, - # `call_tool` resumes those results automatically. - # Register it before `connect` so the connection never needs a server-to-client route. - def register_elicitation_handler - @client.on_elicitation do |params| - puts " Server asked: #{params["message"]}" - - { action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]) } - end - end - - # `mode: :modern` requires the modern lifecycle and fails on legacy-only servers; - # the default `:auto` probes `server/discover` first and falls back to the legacy `initialize` handshake. - # The `elicitation` capability must be declared before the server may embed elicitation requests. - def connect - puts "=== Adopting the modern lifecycle ===" - result = @client.connect( - client_info: { name: "modern-http-client", version: "1.0" }, - capabilities: { elicitation: {} }, - mode: :modern, - ) - puts <<~MESSAGE - Modern connection: #{@transport.modern?} - Protocol version: #{@client.protocol_version} - MESSAGE - - result - end - - # Modern requests are self-contained: the SDK stamps the `_meta` envelope (protocol version, - # client info, capabilities) and the Mcp-Method and Mcp-Name headers on every request automatically. - def list_tools - puts "=== Listing tools ===" - page = @client.list_tools - page.tools.each { |tool| puts " - #{tool.name}: #{tool.description}" } - puts "Cache hints: ttlMs=#{page.ttl_ms}, cacheScope=#{page.cache_scope}" - - page - end - - def call_tool(name, arguments) - puts "=== Calling tool: #{name} ===" - response = @client.call_tool(name: name, arguments: arguments) - puts <<~MESSAGE - resultType: #{response.dig("result", "resultType")} - Response: #{response.dig("result", "content", 0, "text")} - MESSAGE - - response - end - - # The modern lifecycle removed `initialize`, `ping`, `logging/setLevel`, and resource subscriptions (SEP-2575); - # calling one is an error. - def ping - puts "=== Calling removed method: ping ===" - @client.ping - rescue MCP::Client::RequestHandlerError => e - puts "ping was removed by the modern lifecycle (SEP-2575): #{e.message}" - end - - def close - # No session exists on a modern connection; this only clears local state. - @transport.close - end -end - -puts <<~MESSAGE - MCP Modern Lifecycle Client (2026-07-28) - Make sure the server is running (ruby examples/modern_http_server.rb) - #{"=" * 60} -MESSAGE - -client = MCPModernHTTPClient.new - -begin - # Probe the server before adopting a lifecycle. - client.discover - - # Answer the elicitation requests the server embeds in its results. - client.register_elicitation_handler - - # Adopt the modern lifecycle. - client.connect - - # List available tools. - client.list_tools - - # Call a plain single round-trip tool. - client.call_tool("greet", { name: "MCP" }) - - # Call a multi round-trip tool (SEP-2322): the driver fulfills the embedded elicitation via the handler registered above - # and re-issues the call with `inputResponses` plus the echoed `requestState`. Without a handler, `call_tool` raises - # `MCP::Client::InputRequiredError` for manual driving via the `input_responses:` / `request_state:` keywords. - client.call_tool("deploy", { app: "storefront" }) - - # Call a method the modern lifecycle removed. - client.ping -rescue => e - puts <<~MESSAGE - Error: #{e.message} - #{e.backtrace.join("\n")} - MESSAGE -ensure - client.close -end diff --git a/examples/modern_http_server.rb b/examples/modern_http_server.rb deleted file mode 100644 index b21c35d..0000000 --- a/examples/modern_http_server.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) -require "mcp" -require "rackup" -require "json" -require "logger" - -# Example server for the 2026-07-28 modern lifecycle (SEP-2575). -# -# The modern lifecycle is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. -# Clients probe the server with `server/discover` and then send self-contained requests that carry -# a `_meta` envelope (protocol version, client info, capabilities) plus `MCP-Protocol-Version` / `Mcp-Method` headers. -# -# The same server still accepts the legacy `initialize` flow: the transport routes each request to -# the legacy or modern lifecycle by its `MCP-Protocol-Version` header, so no constructor flag is needed. -server = MCP::Server.new( - name: "modern_lifecycle_server", - version: "1.0.0", - instructions: "Example server demonstrating the 2026-07-28 modern lifecycle.", - # SEP-2549 cache hints, advertised in `server/discover` and stamped on cacheable results. - # The defaults are 0 / "private"; non-default values are used here so they are visible - # in the example output. - ttl_ms: 60_000, - cache_scope: "public", -) - -server.define_tool( - name: "greet", - description: "Greets a name; a plain single round-trip tool", - input_schema: { properties: { name: { type: "string" } }, required: ["name"] }, -) do |name:| - MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}!" }]) -end - -# A multi round-trip tool (SEP-2322). The first round returns an `input_required` result asking -# the client to answer an embedded `elicitation/create` request; the client re-issues the call with -# `inputResponses` plus the echoed opaque `requestState`, and the handler re-runs from the start -# and reads the answer via `server_context`. -# Over a legacy connection the default `input_required_legacy_shim` fulfills the same result through -# a real server-to-client elicitation request instead. -server.define_tool( - name: "deploy", - description: "Deploys an app; asks which environment via a multi round-trip elicitation (SEP-2322)", - input_schema: { properties: { app: { type: "string" } }, required: ["app"] }, -) do |app:, server_context:| - answer = server_context.input_response("environment") - - if answer.nil? - MCP::Server::InputRequiredResult.new( - input_requests: { - "environment" => { - method: "elicitation/create", - params: { - message: "Which environment should #{app} deploy to?", - requestedSchema: { - type: "object", - properties: { - environment: { type: "string", title: "Environment", default: "staging" }, - }, - required: ["environment"], - }, - }, - }, - }, - request_state: JSON.generate(app: app), - ) - else - # Transports parse JSON with `symbolize_names: true`, so answers normally arrive symbol-keyed; - # the string fallback covers custom transports. - action = answer[:action] || answer["action"] - - if action == "accept" - environment = answer.dig(:content, :environment) || answer.dig("content", "environment") - MCP::Tool::Response.new([{ type: "text", text: "Deployed #{app} to #{environment}" }]) - else - MCP::Tool::Response.new([{ type: "text", text: "Deployment of #{app} was cancelled (#{action})" }]) - end - end -end - -transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) - -# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app. -# See http_server.rb for a CORS setup for browser-based clients. -rack_app = Rack::Builder.new do - use(Rack::CommonLogger, Logger.new($stdout)) - use(Rack::ShowExceptions) - - run(transport) -end - -puts <<~MESSAGE - === MCP Modern Lifecycle Server (2026-07-28) === - - Starting server on http://localhost:9494 - - Run the SDK client against it: - ruby examples/modern_http_client.rb - - Or walk the modern lifecycle with cURL: - - 1. Probe capabilities (sessionless, works without any protocol headers): - curl -s http://localhost:9494 \\ - -H "Accept: application/json, text/event-stream" \\ - --json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}' - - 2. Modern tools/list (the MCP-Protocol-Version header selects the era, the - Mcp-Method header mirrors the method, and params._meta carries the - SEP-2575 envelope): - curl -s http://localhost:9494 \\ - -H "Accept: application/json, text/event-stream" \\ - -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" \\ - --json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' - - 3. Modern tools/call (name-bearing methods additionally mirror the name in - the Mcp-Name header; no Mcp-Session-Id is ever sent): - curl -s http://localhost:9494 \\ - -H "Accept: application/json, text/event-stream" \\ - -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: greet" \\ - --json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' - - Press Ctrl+C to stop the server -MESSAGE - -Rackup::Handler.get("puma").run(rack_app, Port: 9494, Host: "localhost") diff --git a/examples/rails/README.md b/examples/rails/README.md index eaee531..6a599c3 100644 --- a/examples/rails/README.md +++ b/examples/rails/README.md @@ -36,7 +36,7 @@ per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` line ```console SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \ -H "Accept: application/json, text/event-stream" \ - --json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ + --json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \ | grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') ``` diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index ab7aeed..79e3f38 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -18,11 +18,8 @@ def create_logger logger end -# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler -# (e.g. `client.on_elicitation`) is registered. This example instead opens the stream with -# raw `Net::HTTP` to show the SSE wire format. The GET stream carries server-initiated messages -# that are not tied to a client request; request-scoped notifications such as progress arrive on -# the POST's own SSE response stream (see `stream_tool_call` below). +# The SDK does not yet implement the optional GET SSE stream, so this example +# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream. def connect_sse(session_id, logger) uri = URI(SERVER_URL) @@ -67,42 +64,6 @@ def print_response(response) end end -# Calls a tool with a raw streaming POST so each SSE event on the response stream is printed as -# it arrives: the `notifications/progress` events requested via `_meta.progressToken`, -# then the final JSON-RPC response. `MCP::Client#call_tool` reads the same stream but consumes -# the progress notifications internally, so the raw request makes them visible here. -def stream_tool_call(session_id, request_id, name:, arguments:, logger:) - uri = URI(SERVER_URL) - - body = { - jsonrpc: "2.0", - id: request_id, - method: "tools/call", - params: { - name: name, - arguments: arguments, - _meta: { progressToken: "streamable-http-client" }, - }, - } - - Net::HTTP.start(uri.host, uri.port) do |http| - request = Net::HTTP::Post.new(uri) - request["Content-Type"] = "application/json" - request["Accept"] = "application/json, text/event-stream" - request["Mcp-Session-Id"] = session_id - request.body = JSON.generate(body) - - http.request(request) do |response| - parser = EventStreamParser::Parser.new - response.read_body do |chunk| - parser.feed(chunk) do |_type, data, _id| - logger.info("SSE event: #{data}") - end - end - end - end -end - def main logger = create_logger @@ -115,17 +76,11 @@ def main http_transport = MCP::Client::HTTP.new(url: SERVER_URL) client = MCP::Client.new(transport: http_transport) sse_thread = nil - raw_request_id = 0 begin puts "=== Initializing session ===" - - # The default `mode: :auto` adopts the sessionless modern lifecycle (2026-07-28) - # when the server supports it. This example demonstrates the legacy session's SSE stream, - # so pin the legacy `initialize` handshake. server_info = client.connect( client_info: { name: "streamable-http-client", version: "1.0" }, - mode: :legacy, ) puts <<~MESSAGE @@ -149,6 +104,8 @@ def main sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) } sleep(1) + # Once the optional SSE stream is active, POST requests may receive only a + # 202 ACK while the actual JSON-RPC response is delivered over SSE. loop do puts <<~MENU.chomp @@ -169,15 +126,12 @@ def main print("Enter delay in seconds (0 for immediate): ") delay = gets.chomp.to_f - puts "=== Calling tool: notification_tool (raw streaming POST) ===" - raw_request_id += 1 - stream_tool_call( - http_transport.session_id, - "raw-#{raw_request_id}", - name: notification_tool.name, + puts "=== Calling tool: notification_tool ===" + response = client.call_tool( + tool: notification_tool, arguments: { message: message, delay: delay }, - logger: logger, ) + print_response(response) else puts "notification_tool not available" end diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index f122296..a20d28e 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -13,16 +13,14 @@ sse_logger.formatter = proc do |severity, datetime, _progname, msg| "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" end -# Tool that emits `notifications/progress` events, delivered over the SSE stream. -# The client must request them by sending a `progressToken` in the request `_meta`. +# Tool that returns a response that will be sent via SSE if a stream is active class NotificationTool < MCP::Tool tool_name "notification_tool" - description "Sends the message back as progress notifications via SSE, then returns a summary" + description "Returns a notification message that will be sent via SSE if stream is active" input_schema( properties: { message: { type: "string", description: "Message to send via SSE" }, - steps: { type: "number", description: "Number of progress notifications to send (default: 3)" }, - delay: { type: "number", description: "Delay in seconds between notifications (optional)" }, + delay: { type: "number", description: "Delay in seconds before returning (optional)" }, }, required: ["message"], ) @@ -30,19 +28,14 @@ class NotificationTool < MCP::Tool class << self attr_accessor :logger - def call(message:, server_context:, steps: 3, delay: 0) - steps = steps.to_i.clamp(1, 10) + def call(message:, delay: 0) + sleep(delay) if delay > 0 - steps.times do |i| - sleep(delay) if delay > 0 - - logger&.info("Reporting progress #{i + 1}/#{steps}: #{message}") - server_context.report_progress(i + 1, total: steps, message: message) - end + logger&.info("Returning notification message: #{message}") MCP::Tool::Response.new([{ type: "text", - text: "Sent #{steps} progress notifications for: #{message}", + text: "Notification: #{message} (timestamp: #{Time.now.iso8601})", }]) end end @@ -51,7 +44,6 @@ end # Create the server server = MCP::Server.new( name: "sse_test_server", - version: "1.0.0", tools: [NotificationTool], prompts: [], resources: [], @@ -157,34 +149,29 @@ puts <<~MESSAGE Starting server on http://localhost:9393 Available Tools: - 1. notification_tool - Sends the message back as progress notifications via SSE + 1. NotificationTool - Returns messages that are sent via SSE when stream is active" 2. echo - Simple echo tool Testing SSE: 1. Initialize session: curl -i http://localhost:9393 \\ - -H "Accept: application/json, text/event-stream" \\ - --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' + --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}' - 2. Connect SSE stream (use the session ID from step 1): + 2. Connect SSE stream (use the session ID from step 1):" curl -i -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393 3. In another terminal, test tools (responses will be sent via SSE if stream is active): Echo tool: curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ - -H "Accept: application/json, text/event-stream" \\ --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}' - Notification tool (progress notifications require a progressToken in _meta): + Notification tool (with 2 second delay): curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\ - -H "Accept: application/json, text/event-stream" \\ - --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!","delay":1},"_meta":{"progressToken":"curl-progress"}}}' + --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}' - Note: Each POST is answered with an SSE stream of its own; request-scoped notifications - (such as the progress events above) appear on it before the final response. - The standalone GET stream carries server-initiated messages that are not tied to a request. + Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return 202 Accepted with no body. Press Ctrl+C to stop the server MESSAGE diff --git a/lib/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 6cbce1c..0b008fa 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -135,12 +135,11 @@ module JsonRpcHandler success_response(id: id, result: result) rescue MCP::Server::RequestHandlerError => e handle_request_error(e, id, id_validation_pattern) - rescue StandardError - # The exception message is deliberately not echoed into `data`: it can carry - # internals that must not reach untrusted clients (CWE-209). + rescue StandardError => e error_response(id: id, id_validation_pattern: id_validation_pattern, error: { code: ErrorCode::INTERNAL_ERROR, message: "Internal error", + data: e.message, }) end end diff --git a/lib/mcp.rb b/lib/mcp.rb index 2213f23..5e0838e 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -2,7 +2,6 @@ require_relative "json_rpc_handler" require_relative "mcp/configuration" -require_relative "mcp/protocol_deprecations" require_relative "mcp/string_utils" require_relative "mcp/transport" require_relative "mcp/version" @@ -14,11 +13,9 @@ module MCP autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" autoload :Content, "mcp/content" - autoload :Elicitation, "mcp/elicitation" autoload :ErrorCodes, "mcp/error_codes" autoload :Icon, "mcp/icon" autoload :Prompt, "mcp/prompt" - autoload :RequestEnvelope, "mcp/request_envelope" autoload :Resource, "mcp/resource" autoload :ResourceTemplate, "mcp/resource_template" autoload :ResultType, "mcp/result_type" diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 7c9d8b9..9d139c6 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require_relative "client/elicitation" -require_relative "client/mcp_param_headers" -require_relative "client/modern_envelope" require_relative "client/oauth" require_relative "client/stdio" require_relative "client/http" @@ -12,12 +10,6 @@ require_relative "result_type" module MCP class Client - # Upper bound on the number of pages the all-pages methods (`tools`, `resources`, - # `resource_templates`, `prompts`) will walk. The cursor guard in `fetch_all_pages` only - # stops a server that repeats or cycles cursors; one that returns a fresh `nextCursor` on - # every response would otherwise be followed indefinitely, growing the retained pages with it. - MAX_PAGES = 1_000 - class ServerError < StandardError attr_reader :code, :data @@ -58,19 +50,13 @@ module MCP # server-returned JSON-RPC error, which is raised as `ServerError`. class ValidationError < StandardError; end - # Raised when an all-pages method reaches `max_pages` while the server is still offering - # another cursor. Use the single-page `list_*` methods to walk such a collection with - # a policy of your own. - class PaginationLimitError < StandardError; end - # Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of # a final result. The result is not an error on the wire: it asks the client to fulfill the server's # `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with # `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue # the original request with `inputResponses` plus the echoed opaque `requestState`. - # With handlers registered through `on_elicitation`, `on_sampling`, or `on_roots`, the resume loop runs - # automatically; this error surfaces when no matching handler exists (manual driving via - # `input_requests` and the `input_responses:`/`request_state:` kwargs), or when the round cap is exhausted. + # This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests` + # and respond manually. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 class InputRequiredError < StandardError attr_reader :input_requests, :request_state, :result @@ -93,100 +79,30 @@ module MCP end end - # The server's `DiscoverResult` (MCP 2026-07-28, SEP-2575): the modern protocol versions it serves, - # its capabilities and identity, optional instructions, and the REQUIRED `ttlMs`/`cacheScope` cache hints. - DiscoverResult = Struct.new( - :supported_versions, - :capabilities, - :server_info, - :instructions, - :ttl_ms, - :cache_scope, - keyword_init: true, - ) - - # Rounds the SEP-2322 driver runs before giving up, matching the TypeScript and - # Python SDK defaults. Every leg counts, including `requestState`-only retries. - DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10 - - # Backoff for `requestState`-only (load shedding) legs: exponential from 50ms to - # a 250ms cap, matching the Python SDK (the TypeScript SDK uses a fixed 250ms). - STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05 - STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25 - # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. # The transport should be a duck type that responds to `send_request`. See the README for more details. - # @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds. - # @param max_pages [Integer] Maximum number of pages the all-pages methods ({#tools}, {#resources}, - # {#resource_templates}, {#prompts}) will walk before raising {MCP::Client::PaginationLimitError}. - # - # Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`, - # `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers - # (or when a requested kind has no handler) they raise `InputRequiredError` for manual driving, - # exactly as before. # # @example # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") # client = MCP::Client.new(transport: transport) - def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, max_pages: MAX_PAGES) - # `nil` or a non-positive value would make the pagination unbounded and silently - # disable the protection, so reject it up front. - unless max_pages.is_a?(Integer) && max_pages > 0 - raise ArgumentError, "max_pages must be a positive Integer" - end - + def initialize(transport:) @transport = transport - @max_pages = max_pages - # Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways - # the server can ask for input: a real server-to-client request, and an embedded request inside - # a SEP-2322 `input_required` result. - @input_required_handlers = {} - @input_required_max_rounds = input_required_max_rounds end # The user may want to access additional transport-specific methods/attributes # So keeping it public attr_reader :transport - # The raw handshake result exactly as the server returned it, so its shape depends on the connection's era (SEP-2575): - # after the legacy handshake it is an `InitializeResult` (`protocolVersion`, top-level `serverInfo`), after modern adoption - # it is a `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`, `serverInfo` optionally under `_meta`). - # Code that must work against both eras should prefer the era-independent readers {#protocol_version}, {#server_capabilities}, - # {#instructions}, and {#server_implementation}; this raw form remains the window to everything they do not cover - # (`supportedVersions`, cache hints, `_meta`, extension data). Returns `nil` before `connect`, after `close`, - # or when the transport does not expose a cached handshake result. + # The server's `InitializeResult` (protocol version, capabilities, server info, + # instructions), as reported by the transport after a successful `connect`. + # Returns `nil` before `connect`, after `close`, or when the transport does + # not expose a cached handshake result. def server_info transport.server_info if transport.respond_to?(:server_info) end - # The protocol version in use on this connection, independent of its era: - # the version negotiated by `initialize` (legacy) or adopted via `server/discover` (modern). - # Returns `nil` before `connect`. - def protocol_version - return transport.protocol_version if transport.respond_to?(:protocol_version) - - server_info&.dig("protocolVersion") - end - - # The server's capabilities Hash, present in both eras. Returns `nil` before `connect`. - def server_capabilities - server_info&.dig("capabilities") - end - - # The server's instructions text, present in both eras when provided. - def instructions - server_info&.dig("instructions") - end - - # The server's identity (`name`/`version`), independent of where the era puts it: top-level `serverInfo` - # on legacy results, the optional `_meta` `io.modelcontextprotocol/serverInfo` stamp on modern results. - # Returns `nil` when a modern server does not identify itself. - def server_implementation - server_info&.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || server_info&.dig("serverInfo") - end - # Performs the MCP `initialize` handshake by delegating to the transport # (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's # `InitializeResult`. @@ -199,62 +115,16 @@ module MCP # @param capabilities [Hash] Capabilities advertised by the client. May include # an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers, # e.g. `{ extensions: { "com.example/feature" => {} } }`. - # @param mode [Symbol, nil] Lifecycle selection (SEP-2575). When omitted, transports whose - # `connect` declares `mode:` (the bundled `MCP::Client::HTTP` and `MCP::Client::Stdio`) - # negotiate with `:auto`: probe `server/discover` first and fall back to the legacy handshake - # when the server does not serve a mutually supported modern version. Transports without `mode:` - # keep receiving the historical legacy call shape. `:legacy` forces the `initialize` handshake - # exactly as before; `:modern` requires the modern lifecycle and fails without a mutual modern version. - # Passing an explicit `protocol_version` from a legacy generation (e.g. `"2025-11-25"`) pins - # the legacy handshake without a probe, so an explicitly requested version is never overridden - # by the default negotiation. - # @return [Hash, nil] The server's `InitializeResult` (legacy) or `DiscoverResult` (modern), - # or `nil` when the transport does not expose an explicit handshake. - # Prefer the era-independent readers over inspecting this Hash directly. + # @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport + # does not expose an explicit handshake. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: nil) + def connect(client_info: nil, protocol_version: nil, capabilities: {}) return unless transport.respond_to?(:connect) - effective_mode = resolve_connect_mode(mode, protocol_version) - - if effective_mode == :legacy - transport.connect( - client_info: client_info, - protocol_version: protocol_version, - capabilities: capabilities, - ) - else - transport.connect( - client_info: client_info, - protocol_version: protocol_version, - capabilities: capabilities, - mode: effective_mode, - ) - end - end - - # Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery - # that works before (or instead of) `connect`. - # - # @param meta [Hash, nil] Additional `_meta` entries to send with the request. - # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. - # @return [MCP::Client::DiscoverResult] - # @raise [ServerError] If the server returns a JSON-RPC error. - # @raise [ValidationError] If the response `result` is missing or not a Hash. - def discover(meta: nil, cancellation: nil) - response = request(method: Methods::SERVER_DISCOVER, meta: meta, cancellation: cancellation) - result = response.is_a?(Hash) ? response["result"] : nil - raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash) - - DiscoverResult.new( - supported_versions: result["supportedVersions"], - capabilities: result["capabilities"], - # The finalized spec (PR #3002) stamps the server identity into the result `_meta`; - # the top-level fallback tolerates servers built against the frozen SEP text. - server_info: result.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || result["serverInfo"], - instructions: result["instructions"], - ttl_ms: result["ttlMs"], - cache_scope: result["cacheScope"], + transport.connect( + client_info: client_info, + protocol_version: protocol_version, + capabilities: capabilities, ) end @@ -290,15 +160,12 @@ module MCP response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation) result = response["result"] || {} - tools = (result["tools"] || []).filter_map do |tool| - next if exclude_invalid_x_mcp_header?(tool) - + tools = (result["tools"] || []).map do |tool| Tool.new( name: tool["name"], description: tool["description"], input_schema: tool["inputSchema"], output_schema: tool["outputSchema"], - annotations: tool["annotations"], ) end @@ -321,7 +188,6 @@ module MCP # Cancelling it aborts whichever page is currently in flight; pages already returned are kept, # but the call raises `MCP::CancelledError` instead of returning the partial set. # @return [Array] An array of available tools. - # @raise [MCP::Client::PaginationLimitError] If the server offers more than `max_pages` pages. # # @example # tools = client.tools @@ -363,7 +229,6 @@ module MCP # # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available resources. - # @raise [MCP::Client::PaginationLimitError] See {#tools}. def resources(cancellation: nil) # TODO: consider renaming to `list_all_resources`. fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources) @@ -399,7 +264,6 @@ module MCP # # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available resource templates. - # @raise [MCP::Client::PaginationLimitError] See {#tools}. def resource_templates(cancellation: nil) # TODO: consider renaming to `list_all_resource_templates`. fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates) @@ -435,7 +299,6 @@ module MCP # # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}). # @return [Array] An array of available prompts. - # @raise [MCP::Client::PaginationLimitError] See {#tools}. def prompts(cancellation: nil) # TODO: consider renaming to `list_all_prompts`. fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts) @@ -476,10 +339,7 @@ module MCP # @note # The exact requirements for `arguments` are determined by the transport layer in use. # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. - # @param input_responses [Hash, nil] SEP-2322 answers to a previous `input_required` result's `inputRequests`, - # keyed identically (manual retry legs). - # @param request_state [String, nil] The opaque `requestState` echoed back byte-exactly. - def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) + def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil) tool_name = name || tool&.name raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name @@ -490,10 +350,8 @@ module MCP meta_entries[:progressToken] = progress_token end params[:_meta] = meta_entries unless meta_entries.empty? - params[:inputResponses] = input_responses if input_responses - params[:requestState] = request_state if request_state - drive_input_required(method: "tools/call", params: params, cancellation: cancellation) + request(method: "tools/call", params: params, cancellation: cancellation) end # Reads a resource from the server by URI and returns the contents. @@ -503,13 +361,8 @@ module MCP # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Array] An array of resource contents (text or blob). - def read_resource(uri:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) - params = { uri: uri } - params = params.merge(_meta: meta) if meta && !meta.empty? - params[:inputResponses] = input_responses if input_responses - params[:requestState] = request_state if request_state - - response = drive_input_required(method: "resources/read", params: params, cancellation: cancellation) + def read_resource(uri:, meta: nil, cancellation: nil) + response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation) response.dig("result", "contents") || [] end @@ -521,13 +374,8 @@ module MCP # e.g. SEP-414 trace context (see {MCP::TraceContext}). # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. # @return [Hash] A hash containing the prompt details. - def get_prompt(name:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil) - params = { name: name } - params = params.merge(_meta: meta) if meta && !meta.empty? - params[:inputResponses] = input_responses if input_responses - params[:requestState] = request_state if request_state - - response = drive_input_required(method: "prompts/get", params: params, cancellation: cancellation) + def get_prompt(name:, meta: nil, cancellation: nil) + response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation) response.fetch("result", {}) end @@ -556,11 +404,8 @@ module MCP # (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash: # `{ action: "accept" | "decline" | "cancel", content: { ... } }`. # - # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that - # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and an `elicitation/create` embedded in a SEP-2322 - # `input_required` result, which needs no server-to-client route, and is how the modern lifecycle asks now that it - # forbids server-to-client requests. Both routes need `capabilities: { elicitation: {} }` passed to `connect`: per SEP-2322, - # a server MUST NOT embed input requests of a kind the client has not declared. + # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); + # pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them. # # @example Accept with schema defaults applied (SEP-1034) # client.on_elicitation do |params| @@ -571,7 +416,11 @@ module MCP # end # https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation def on_elicitation(&handler) - register_input_handler(Methods::ELICITATION_CREATE, &handler) + unless transport.respond_to?(:on_server_request) + raise ArgumentError, "The transport does not support server-to-client requests" + end + + transport.on_server_request(Methods::ELICITATION_CREATE, &handler) end # Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight. @@ -582,11 +431,8 @@ module MCP # For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response # before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`. # - # The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that - # carries server-to-client requests (e.g. `MCP::Client::HTTP`); and a `sampling/createMessage` embedded in a SEP-2322 - # `input_required` result, which needs no server-to-client route. Both routes need `capabilities: { sampling: {} }` passed to - # `connect` (or `{ sampling: { tools: {} } }` for tool-enabled requests): per SEP-2322, a server MUST NOT embed input requests of - # a kind the client has not declared. + # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to + # `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them. # # @example Forward the request to an LLM and return its completion # @@ -608,23 +454,11 @@ module MCP # Register this handler only to interoperate with servers that still send sampling requests during the deprecation window; # new servers should call LLM provider APIs directly. def on_sampling(&handler) - register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler) - end + unless transport.respond_to?(:on_server_request) + raise ArgumentError, "The transport does not support server-to-client requests" + end - # Registers a handler for `roots/list`, answering both a server-to-client request on transports that - # support one and an embedded `roots/list` inside a SEP-2322 `input_required` result. The handler - # receives the request `params` (`nil` for `roots/list`) and must return a `ListRootsResult`-shaped Hash: - # `{ roots: [{ uri: "file:///project", name: "Project" }] }`. - # - # @example - # client.on_roots { { roots: [{ uri: "file:///project", name: "Project" }] } } - # - # @deprecated MCP Roots (`roots/list`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). - # Register this handler only to interoperate with servers that still ask for roots. - # - # https://modelcontextprotocol.io/specification/2025-11-25/client/roots - def on_roots(&handler) - register_input_handler(Methods::ROOTS_LIST, &handler) + transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler) end # Sends a `ping` request to the server to verify the connection is alive. @@ -650,77 +484,9 @@ module MCP private - # Records a handler for one of the three kinds of input a server can ask this client for, and wires it to - # the transport when the transport can carry server-to-client requests. One registration serves both routes: - # the real request a 2025-11-25 server sends mid-call, and the request embedded in a SEP-2322 - # `input_required` result, which is how the modern lifecycle asks now that it forbids server-to-client - # requests outright. Registering is therefore valid on a transport with no `on_server_request` (stdio, - # and every modern-lifecycle connection); only the wire route is skipped there. - def register_input_handler(method, &handler) - @input_required_handlers[method] = handler - transport.on_server_request(method, &handler) if transport.respond_to?(:on_server_request) - - handler - end - - # SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate - # the spec constraints MUST be excluded from `tools/list` results, so one malformed definition - # does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way. - # Legacy connections, and transports without a lifecycle notion, list everything as before. - def exclude_invalid_x_mcp_header?(tool) - return false unless transport.respond_to?(:modern?) && transport.modern? - - scan = McpParamHeaders.scan(tool["inputSchema"]) - return false if scan[:valid] - - warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}") - true - end - - # Resolves the effective SEP-2575 lifecycle mode for `connect`: - # - # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe, - # so the default negotiation can never override a version the caller asked for. - # - Absent an explicit mode, transports declaring `mode:` negotiate with `:auto`; other transports keep - # the historical legacy call shape. - # - An explicit `:modern`/`:auto` on a transport without `mode:` raises, rather than silently downgrading to - # a lifecycle the caller did not ask for. - def resolve_connect_mode(mode, protocol_version) - unless [nil, :legacy, :modern, :auto].include?(mode) - raise ArgumentError, "mode must be :legacy, :modern, or :auto" - end - - return :legacy if mode == :legacy - return :legacy if mode.nil? && protocol_version && !Configuration.modern_protocol_version?(protocol_version) - - if transport_connect_accepts_mode? - mode || :auto - elsif mode.nil? - :legacy - else - raise ArgumentError, "transport does not support mode: #{mode.inspect}" - end - end - - # `mode:` is forwarded only when the transport's `connect` declares it as a keyword. - # A bare `**kwargs` deliberately does not count, so wrappers and test doubles that - # absorb arbitrary keywords keep the historical legacy call shape. Objects that - # dispatch `connect` through `method_missing` (e.g. mocks) may not support `method`, - # which reads as not declaring `mode:`. - def transport_connect_accepts_mode? - connect_method = begin - transport.method(:connect) - rescue NameError - return false - end - - connect_method.parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :mode } - end - # Walks every page of a list endpoint, following `next_cursor`, and returns # the page results. The `seen` set guards against a server that repeats or - # cycles cursors, and `@max_pages` bounds one that returns a fresh cursor every - # time, so the loop always terminates. + # cycles cursors, so the loop always terminates. def fetch_all_pages pages = [] seen = Set.new @@ -732,11 +498,6 @@ module MCP next_cursor = page.next_cursor break if next_cursor.nil? || seen.include?(next_cursor) - if pages.size >= @max_pages - raise PaginationLimitError, "Server returned more than #{@max_pages} pages; pass a larger `max_pages:` to " \ - "`MCP::Client.new` if this is expected." - end - seen << next_cursor cursor = next_cursor end @@ -748,7 +509,7 @@ module MCP # without mutating the caller's hashes. Per SEP-414, `_meta` carries # request-specific metadata such as W3C trace context (`traceparent`, # `tracestate`, `baggage`); see {MCP::TraceContext}. - def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true) + def request(method:, params: nil, meta: nil, cancellation: nil) params = (params || {}).merge(_meta: meta) if meta && !meta.empty? request_body = { @@ -770,104 +531,20 @@ module MCP raise ServerError.new(error["message"], code: error["code"], data: error["data"]) end - raise_on_input_required(response) if raise_on_input_required + raise_on_input_required(response) response end - # Drives the SEP-2322 multi round-trip loop for `tools/call`, `prompts/get`, and `resources/read`. - # With no configured handlers this degrades to the plain request (an `input_required` result raises - # `InputRequiredError` for manual driving). Otherwise each `inputRequests` entry is fulfilled by - # the matching handler and the ORIGINAL request is re-issued with `inputResponses` under - # the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg. - # A `requestState`-only result (load shedding) retries after an exponential backoff. - # Every leg counts against `input_required_max_rounds`. - def drive_input_required(method:, params:, cancellation:) - response = request( - method: method, - params: params, - cancellation: cancellation, - raise_on_input_required: @input_required_handlers.empty?, - ) - return response unless input_required?(response) - - original_params = params.dup - original_params.delete(:inputResponses) - original_params.delete(:requestState) - rounds = 0 - backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS - - loop do - result = response["result"] - rounds += 1 - if rounds > @input_required_max_rounds - raise InputRequiredError.new( - "Server still returned `input_required` after #{@input_required_max_rounds} rounds (SEP-2322).", - input_requests: result["inputRequests"] || {}, - request_state: result["requestState"], - result: result, - ) - end - - input_requests = result["inputRequests"] || {} - responses = nil - if input_requests.empty? - sleep(backoff) - backoff = [backoff * 2, STATE_ONLY_BACKOFF_CAP_SECONDS].min - else - backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS - responses = fulfill_input_requests(input_requests, result) - end - - retry_params = original_params.dup - retry_params[:inputResponses] = responses if responses - retry_params[:requestState] = result["requestState"] if result["requestState"] - - response = request( - method: method, - params: retry_params, - cancellation: cancellation, - raise_on_input_required: false, - ) - return response unless input_required?(response) - end - end - - # Dispatches every embedded request to its configured handler and collects - # the answers under the same keys. A kind without a handler falls back to - # the manual path by raising `InputRequiredError` with the full result. - def fulfill_input_requests(input_requests, result) - input_requests.each_with_object({}) do |(key, entry), responses| - entry_method = entry.is_a?(Hash) ? entry["method"] || entry[:method] : nil - handler = @input_required_handlers[entry_method] - unless handler - raise InputRequiredError.new( - "Server requested #{entry_method.inspect} input (key #{key.inspect}) but no matching handler " \ - "is configured; inspect `input_requests` to respond manually (SEP-2322).", - input_requests: input_requests, - request_state: result["requestState"], - result: result, - ) - end - - responses[key] = handler.call(entry.is_a?(Hash) ? entry["params"] || entry[:params] : nil) - end - end - - def input_required?(response) - result = response.is_a?(Hash) ? response["result"] : nil - result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED - end - # Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result. # Servers on stable protocol versions never emit `resultType`, so this is a no-op for them. def raise_on_input_required(response) - return unless input_required?(response) + result = response.is_a?(Hash) ? response["result"] : nil + return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED - result = response["result"] raise InputRequiredError.new( - "Server returned `input_required` (SEP-2322). Register a handler with `on_elicitation`, " \ - "`on_sampling`, or `on_roots` to resume automatically, or inspect `input_requests` to respond manually.", + "Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \ + "Inspect `input_requests` to respond manually.", input_requests: result["inputRequests"] || {}, request_state: result["requestState"], result: result, diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 6969b07..112fe3e 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -4,10 +4,7 @@ require "securerandom" require_relative "../../json_rpc_handler" require_relative "../configuration" require_relative "../methods" -require_relative "../protocol_deprecations" require_relative "../version" -require_relative "mcp_param_headers" -require_relative "modern_envelope" module MCP class Client @@ -27,29 +24,6 @@ module MCP DEFAULT_RECONNECTION_DELAY_MS = 1000 MAX_RECONNECTION_ATTEMPTS = 2 - # Floor on the effective reconnection delay. `listen_for_server_requests` treats a graceful close as success - # and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and reconnects in a tight loop. - # Waiting longer than the server asked for is explicitly allowed: the `retry` field the spec points at is - # the one defined by WHATWG HTML, whose reconnection algorithm reads "Wait a delay equal to the reconnection time - # of the event source. Optionally, wait some more." Waiting *less* is what the spec's MUST rules out, - # and nothing here ever does that. - # - # https://html.spec.whatwg.org/multipage/server-sent-events.html#reconnection-time - MIN_RECONNECTION_DELAY_MS = 100 - - # Budget in seconds for `await_response_after_disconnect`: it gates every wait between reconnection attempts, - # and whatever is left of it becomes the read timeout of each resumed stream. That method runs on the calling thread, - # so without a deadline a server answering with a large `retry:` parks a thread of the embedding application for - # as long as it likes; `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, - # not how long it waits for each. A delay that would run past the deadline is not shortened - the client stops - # reconnecting instead, the same kind of decision the attempt cap already makes, so the server's `retry:` is - # always honored in full or not acted on at all. - # - # Matches `SSE_LISTENER_READ_TIMEOUT`, this client's other "how long to wait on a quiet SSE stream" value. - # `listen_for_server_requests` has no such deadline: it runs on a thread this client owns and is meant - # to poll indefinitely, so a long `retry:` there idles the SDK's own listener rather than the application. - MAX_RECONNECTION_WAIT = 300 - # How long the standalone GET listening stream may stay idle before the read times out # and the connection is counted as a failure and retried. Matches the Python SDK's # `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout @@ -63,12 +37,6 @@ module MCP # and the server transports' request cap. MAX_MESSAGE_BYTES = 4 * 1024 * 1024 - # Upper bound on the tools whose `x-mcp-header` declarations are retained for - # `Mcp-Param-*` mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing - # (the spec's guidance to send without custom headers), so a server rotating tool names across - # `tools/list` responses cannot grow the registry without bound. - MAX_MCP_PARAM_TOOLS = 1000 - # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host # is trivially observed and stolen. @@ -239,24 +207,13 @@ module MCP attr_reader :url, :session_id, :protocol_version, :server_info, :oauth - def initialize( - url:, - headers: {}, - oauth: nil, - max_message_bytes: MAX_MESSAGE_BYTES, - max_reconnection_wait: MAX_RECONNECTION_WAIT, - &block - ) + def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, &block) # `nil` or a non-positive value would make the buffering unbounded and silently # disable the protection, so reject it up front. unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0 raise ArgumentError, "max_message_bytes must be a positive Integer" end - unless max_reconnection_wait.is_a?(Numeric) && max_reconnection_wait > 0 - raise ArgumentError, "max_reconnection_wait must be a positive number" - end - if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) # Mask credentials (userinfo) and query parameters before quoting the URL in the error message # so they cannot leak into logs. @@ -271,7 +228,6 @@ module MCP @faraday_customizer = block @oauth = oauth @max_message_bytes = max_message_bytes - @max_reconnection_wait = max_reconnection_wait # Snapshot the canonical URL at construction time. This single value # serves two related roles, both of which need to see the query string: # @@ -295,9 +251,6 @@ module MCP @connected = false @server_request_handlers = {} @listener_thread = nil - @modern_client_info = nil - @modern_capabilities = nil - @mcp_param_declarations = {} end # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream. @@ -327,47 +280,77 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. - # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. - # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version - # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). + # @param protocol_version [String, nil] Protocol version to offer. Defaults + # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error # or a malformed result. - # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs - # the handshake below, `:modern` skips it and probes `server/discover`, - # and `:auto` probes `server/discover` first, falling back to the legacy handshake - # when the server does not serve a mutually supported modern version. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) + def connect(client_info: nil, protocol_version: nil, capabilities: {}) return @server_info if connected? - # Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake. - MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy - client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } + protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION - case mode - when :legacy - connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - when :modern - connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - when :auto - connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - else - raise ArgumentError, "mode must be :legacy, :modern, or :auto" + response = send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + }) + + if response.is_a?(Hash) && response.key?("error") + clear_session + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) end - end - # Whether the transport operates in the stateless modern lifecycle (SEP-2575): - # no handshake was performed and every request carries the `_meta` envelope. - def modern? - !@modern_client_info.nil? + unless response.is_a?(Hash) && response["result"].is_a?(Hash) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + clear_session + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + begin + send_request(request: { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + }) + rescue StandardError + clear_session + raise + end + + @connected = true + start_listening if @server_request_handlers.any? + @server_info end # Returns true once `connect` has completed the full handshake - # (`initialize` response received and `notifications/initialized` sent), - # or once the modern lifecycle was adopted via `server/discover`. + # (`initialize` response received and `notifications/initialized` sent). # Returns false before the first handshake and after `close`. def connected? @connected @@ -387,18 +370,6 @@ module MCP # a cancellation referring to an unknown request id when the cancel POST happens to arrive first. # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation def send_request(request:) - # Modern requests (never notifications, whose `_meta` has no envelope) carry - # the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers` - # matches it by construction. - if modern? && (request[:id] || request["id"]) - request = ModernEnvelope.stamp( - request, - protocol_version: @protocol_version, - client_info: @modern_client_info, - capabilities: @modern_capabilities, - ) - end - method = request[:method] || request["method"] params = request[:params] || request["params"] oauth_retried = false @@ -427,7 +398,6 @@ module MCP body = resolve_response_body(stream, response, method, params) capture_session_info(method, response, body) if response - capture_mcp_param_declarations(method, params, body) body rescue MessageTooLargeError => e @@ -562,169 +532,11 @@ module MCP attr_reader :headers - def connect_legacy(client_info:, protocol_version:, capabilities:) - protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION - - response = send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::INITIALIZE, - params: { - protocolVersion: protocol_version, - capabilities: capabilities, - clientInfo: client_info, - }, - }) - - if response.is_a?(Hash) && response.key?("error") - clear_session - error = response["error"] - raise RequestHandlerError.new( - "Server initialization failed: #{error["message"]}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - unless response.is_a?(Hash) && response["result"].is_a?(Hash) - clear_session - raise RequestHandlerError.new( - "Server initialization failed: missing result in response", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - @server_info = response["result"] - negotiated_protocol_version = @server_info["protocolVersion"] - # A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake - # settles on a legacy version by definition, and the TypeScript and Python clients refuse - # a modern counter-offer the same way. - unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) - clear_session - raise RequestHandlerError.new( - "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - begin - send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - method: MCP::Methods::NOTIFICATIONS_INITIALIZED, - }) - rescue StandardError - clear_session - raise - end - - @connected = true - start_listening if @server_request_handlers.any? - @server_info - end - - # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version. - # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`. - def connect_modern(client_info:, protocol_version:, capabilities:) - version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION - unless MCP::Configuration.modern_protocol_version?(version) - raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version" - end - - enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities) - - begin - result = probe_discover - rescue StandardError - leave_modern_mode - raise - end - - supported = result["supportedVersions"] - unless supported.is_a?(Array) && supported.include?(version) - leave_modern_mode - raise RequestHandlerError.new( - "Server discovery failed: no mutually supported modern protocol version " \ - "(server supports #{supported.inspect})", - { method: MCP::Methods::SERVER_DISCOVER }, - error_type: :internal_error, - ) - end - - # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, - # so the warning lives here now that the handshake cannot land on one. - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) - - @server_info = result - @connected = true - @server_info - end - - # Probes `server/discover` and adopts the modern lifecycle when the server serves - # a mutually supported modern version; otherwise falls back to the legacy handshake. - # The fallback intentionally covers a successful discovery without a mutual modern - # version as well: during the 2026-07-28 rollout a server may answer discovery while - # only serving legacy versions. - def connect_auto(client_info:, protocol_version:, capabilities:) - modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) - connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) - rescue RequestHandlerError - # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, - # so the probe's failure is the real answer and propagates. - raise if modern_pin - - connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - end - - def enter_modern_mode(protocol_version:, client_info:, capabilities:) - @modern_client_info = client_info - @modern_capabilities = capabilities || {} - # `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header, - # which the modern lifecycle requires to match the `_meta`-carried version. - @protocol_version = protocol_version - end - - def leave_modern_mode - @modern_client_info = nil - @modern_capabilities = nil - @protocol_version = nil - end - - def probe_discover - response = send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::SERVER_DISCOVER, - }) - - if response.is_a?(Hash) && response.key?("error") - error = response["error"] - raise RequestHandlerError.new( - "Server discovery failed: #{error["message"]}", - { method: MCP::Methods::SERVER_DISCOVER }, - error_type: :internal_error, - ) - end - - result = response.is_a?(Hash) ? response["result"] : nil - unless result.is_a?(Hash) - raise RequestHandlerError.new( - "Server discovery failed: missing result in response", - { method: MCP::Methods::SERVER_DISCOVER }, - error_type: :internal_error, - ) - end - - result - end - def client require_faraday! @client ||= Faraday.new(url) do |faraday| faraday.request(:json) - # The client parses response bodies itself (`resolve_response_body`), so streamed and buffered responses - # take the same path; Faraday's json response middleware is deliberately left out. + faraday.response(:json) faraday.response(:raise_error) faraday.headers["Accept"] = ACCEPT_HEADER @@ -771,56 +583,11 @@ module MCP name = params[:name] || params["name"] name = params[:uri] || params["uri"] unless name.is_a?(String) metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String) - - if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]]) - arguments = params[:arguments] || params["arguments"] - - metadata_headers.merge!(McpParamHeaders.build(declarations, arguments)) - end end metadata_headers end - # Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises, - # so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243). - # The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs, - # so legacy connections learn nothing. Only a valid, non-empty declaration set is kept: - # an invalid tool definition mirrors nothing, - # following the spec's guidance to send without custom headers when no reliable declarations are available. - def capture_mcp_param_declarations(method, params, body) - return unless modern? - return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash) - - tools = body.dig("result", "tools") - return unless tools.is_a?(Array) - - # An uncursored request answered without `nextCursor` is the complete tool universe, - # so knowledge about unlisted tools is stale; the registry is rebuilt from this listing, - # the same pruning the Python SDK applies to complete listings. - cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"]) - complete = !cursor && body.dig("result", "nextCursor").nil? - registry = complete ? {} : @mcp_param_declarations - - tools.each do |tool| - next unless tool.is_a?(Hash) - - name = tool["name"] - next unless name.is_a?(String) - - scan = McpParamHeaders.scan(tool["inputSchema"]) - if scan[:valid] && !scan[:declarations].empty? - next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS - - registry[name] = scan[:declarations] - else - registry.delete(name) - end - end - - @mcp_param_declarations = registry if complete - end - # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF, # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as # `=?base64??=`. Safe ASCII values are sent unchanged. @@ -960,8 +727,6 @@ module MCP @protocol_version = nil @server_info = nil @connected = false - @modern_client_info = nil - @modern_capabilities = nil end # Opens the standalone GET SSE listening stream on a background thread so server-to-client requests @@ -1015,26 +780,10 @@ module MCP stream.reset_parser! - sleep(reconnection_delay_seconds(stream)) + sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0) end end - # The reconnection delay in seconds: the server's `retry:` value when it sent one and - # the default otherwise, never shortened, raised to `MIN_RECONNECTION_DELAY_MS` when the server - # asked for less than that. A `retry:` that is negative or not a run of digits is not a value at all; - # the SSE parser drops it, so those arrive here as the default rather than as something to guard. - def reconnection_delay_seconds(stream) - delay_ms = stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS - - [delay_ms, MIN_RECONNECTION_DELAY_MS].max / 1000.0 - end - - # Seconds left before `deadline`, floored just above zero so a budget consumed down to the last instant - # still asks the adapter for a timeout rather than for "no timeout". - def remaining_reconnection_budget(deadline) - [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max - end - def require_faraday! require "faraday" rescue LoadError @@ -1075,8 +824,8 @@ module MCP elsif content_type&.include?("application/json") return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty? - # Adapters without `on_data` support deliver the body via `response.body`; a JSON middleware - # added by the Faraday customizer may have parsed it already. + # Adapters without `on_data` support deliver the body via `response.body`, + # already parsed by the json response middleware. response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body else raise RequestHandlerError.new( @@ -1119,19 +868,6 @@ module MCP }, } end - elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric)) - # The ping receiver "MUST respond promptly with an empty response" (MCP ping utility), - # so an unhandled ping is answered with the empty result rather than Method not found. - # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at - # schema validation, so a ping carrying one falls through to Method not found instead. - # The default lives here instead of in `@server_request_handlers`, whose `any?` opens - # the GET listening stream on connect: a built-in pong must not change listener behavior. - # A handler registered via `on_server_request("ping")` still wins through the branch above. - { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: message["id"], - result: {}, - } else { jsonrpc: JsonRpcHandler::Version::V2_0, @@ -1164,43 +900,23 @@ module MCP # SEP-1699 resumability: the server closed the SSE stream after a priming event # without delivering the response. Treat the graceful close like a network failure: - # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with + # wait the server-specified `retry:` interval (default 1000ms), then reconnect with # a GET carrying `Last-Event-ID` so the server can replay the pending response on # the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport` # reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap). - # - # This runs on the caller's thread, so the attempts are bounded by `max_reconnection_wait` as well as - # by their count: the deadline gates each wait, and what is left of it becomes the read timeout of - # the resumed stream. A delay that would run past the deadline is never shortened: the client stops - # reconnecting instead, so the server's `retry:` is honored in full or not acted on at all. # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 def await_response_after_disconnect(stream, method, params) stream.abortable = true - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @max_reconnection_wait - gave_up_waiting = false MAX_RECONNECTION_ATTEMPTS.times do - delay = reconnection_delay_seconds(stream) - if Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline - gave_up_waiting = true - break - end - - sleep(delay) + sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0) stream.reset_parser! - # Bound the resumed stream's idle time by what is left of the budget, rather than leaving it to - # whatever the Faraday adapter defaults to. `listen_for_server_requests` guards its own GET the same way; - # without this, a caller-supplied adapter with no default read timeout would let a server hold - # the connection open past the budget by simply sending nothing. - read_timeout = remaining_reconnection_budget(deadline) - reconnect_response = begin client.get("") do |req| req.headers.update(session_headers) req.headers["Accept"] = SSE_ACCEPT_HEADER req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id - req.options.read_timeout = read_timeout req.options.on_data = stream.on_data end rescue StreamAbort @@ -1216,14 +932,9 @@ module MCP return stream.response if stream.response end - reason = if gave_up_waiting - "the reconnection delay it asked for would exceed the #{@max_reconnection_wait} second reconnection budget" - else - "#{MAX_RECONNECTION_ATTEMPTS} reconnection attempts" - end - raise RequestHandlerError.new( - "Server closed the SSE stream without a response for #{method} after #{reason}", + "Server closed the SSE stream without a response for #{method} " \ + "after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts", { method: method, params: params }, error_type: :internal_error, ) diff --git a/lib/mcp/client/mcp_param_headers.rb b/lib/mcp/client/mcp_param_headers.rb deleted file mode 100644 index 4aa4d54..0000000 --- a/lib/mcp/client/mcp_param_headers.rb +++ /dev/null @@ -1,242 +0,0 @@ -# frozen_string_literal: true - -module MCP - class Client - # The custom-header half of SEP-2243 (MCP 2026-07-28): scanning a tool's `inputSchema` for - # `x-mcp-header` declarations and encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers, - # with the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values. - # Mirrors the TypeScript SDK's `mcpParamHeaders` codec; the standard-header half (`Mcp-Method`, `Mcp-Name`) - # lives with the transport. - # - # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters - module McpParamHeaders - # The fixed prefix every custom-parameter header carries. - HEADER_PREFIX = "Mcp-Param-" - - # The schema-extension property name a tool's `inputSchema` carries. - X_MCP_HEADER_KEY = "x-mcp-header" - - # RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces, - # control characters (including CR/LF), and the HTTP delimiters. - RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze - - # The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because - # the published conformance referee annotates `type: "number"` parameters and expects them - # mirrored; the TypeScript SDK makes the same accommodation. - PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze - - # JSON Schema keywords the SEP-2243 static-reachability constraint excludes from - # the `properties`-only chain. An `x-mcp-header` under any of these invalidates - # the tool definition rather than being silently ignored. - NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ - "items", - "prefixItems", - "contains", - "additionalProperties", - "unevaluatedProperties", - "unevaluatedItems", - "propertyNames", - "patternProperties", - "dependentSchemas", - "oneOf", - "anyOf", - "allOf", - "not", - "if", - "then", - "else", - "$defs", - "definitions", - ].freeze - - # Keywords whose value maps names to subschemas rather than being one subschema or a list of them. - OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze - - # Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored; - # the TypeScript SDK refuses unsafe integers the same way. - MAX_SAFE_INTEGER = (2**53) - 1 - - BASE64_SENTINEL_PREFIX = "=?base64?" - BASE64_SENTINEL_SUFFIX = "?=" - - class << self - # Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint - # the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed - # declaring properties, and static reachability through a chain of `properties` keys only. - # Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`, - # or `{ valid: false, reason: "..." }` on the first violation. - def scan(input_schema) - declarations = [] - fault = visit(input_schema, [], true, declarations, {}) - - fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations } - end - - # Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and - # the call's `arguments`. A `null` or absent value omits its header (the spec's MUST-omit rows); - # a non-primitive or non-representable value is omitted rather than emitted malformed. - def build(declarations, arguments) - declarations.each_with_object({}) do |declaration, headers| - value = value_at_path(arguments, declaration[:path]) - next if value.nil? - - string_value = primitive_to_string(value) - next unless string_value - - encoded = begin - encode_value(string_value) - rescue EncodingError - # A string that cannot be represented as UTF-8 (e.g. binary data) has no header - # representation; omit it like the other non-representable values. - next - end - - headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded - end - end - - # Converts a primitive argument to its header string per the spec's type-conversion rules: - # strings pass through, booleans become lowercase `"true"` / `"false"`, and numbers become - # their decimal string. `nil` means "not representable: do not emit a header". - def primitive_to_string(value) - case value - when String - value - when true - "true" - when false - "false" - when Integer - value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil - when Float - return unless value.finite? - - # JSON has one number type: an integral float serializes without the fractional part, - # matching the `String(42.0)` the JavaScript reference emits. - value == value.truncate ? value.truncate.to_s : value.to_s - end - end - - # Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value - # passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`. - def encode_value(value) - return value unless needs_base64?(value) - - "#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}" - end - - private - - def visit(node, path, reachable, declarations, seen_lower) - return unless node.is_a?(Hash) - - if key?(node, X_MCP_HEADER_KEY) - fault = validate_declaration(node, path, reachable, declarations, seen_lower) - - return fault if fault - end - - properties = read(node, "properties") - if properties.is_a?(Hash) - properties.each do |key, child| - fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower) - - return fault if fault - end - end - - # Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass - # through with `reachable: false`, so an annotation under any of them is reported. - # `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs is out of scope. - NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword| - next unless (sub = read(node, keyword)) - - branches = if sub.is_a?(Array) - sub - elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword) - sub.values - else - [sub] - end - - branches.each do |branch| - fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower) - - return fault if fault - end - end - - nil - end - - def validate_declaration(node, path, reachable, declarations, seen_lower) - if !reachable || path.empty? - return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys" - - end - - annotation = read(node, X_MCP_HEADER_KEY) - - unless annotation.is_a?(String) && !annotation.empty? - return "#{path_name(path)}: x-mcp-header MUST be a non-empty string" - end - - unless RFC9110_TOKEN.match?(annotation) - return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token" - end - - type = read(node, "type") - unless type.is_a?(String) && PERMITTED_TYPES.include?(type) - return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties " \ - "(got `#{type.inspect}`)" - end - - lower = annotation.downcase - prior = seen_lower[lower] - if prior - return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)" - end - - seen_lower[lower] = annotation - declarations << { path: path, header_name: annotation, type: type } - nil - end - - # A value cannot ride as a plain ASCII field value when it is empty, already shaped like - # the Base64 sentinel (the spec's ambiguity rule), carries edge whitespace that field parsing - # would strip, or contains a byte outside visible ASCII plus interior tab. - def needs_base64?(value) - return true if value.empty? - return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX) - return true if value != value.strip - - value.each_byte.any? { |byte| byte != 0x09 && !byte.between?(0x20, 0x7e) } - end - - def value_at_path(root, path) - path.reduce(root) do |node, key| - break unless node.is_a?(Hash) - - read(node, key) - end - end - - # Schemas and arguments arrive with string keys off the wire but may carry symbol keys - # when constructed in Ruby; read both forms like the SDK's other readers. - def read(hash, key) - value = hash[key] - - value.nil? ? hash[key.to_sym] : value - end - - def key?(hash, key) - hash.key?(key) || hash.key?(key.to_sym) - end - - def path_name(path) - path.empty? ? "" : path.join(".") - end - end - end - end -end diff --git a/lib/mcp/client/modern_envelope.rb b/lib/mcp/client/modern_envelope.rb deleted file mode 100644 index b9aed32..0000000 --- a/lib/mcp/client/modern_envelope.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require_relative "../request_envelope" - -module MCP - class Client - # Stamps the SEP-2575 per-request `_meta` envelope onto outgoing modern requests. - # The reserved key names are shared with the server-side `MCP::RequestEnvelope`. - # Reserved keys always win over caller-supplied `_meta` entries because they are - # wire vocabulary the SDK is responsible for; every other entry is preserved. - module ModernEnvelope - extend self - - # Returns a copy of `request` with `params._meta` carrying the modern triple. - # Neither `request` nor its nested hashes are mutated. - def stamp(request, protocol_version:, client_info:, capabilities:) - params_key = request.key?("params") ? "params" : :params - params = request[params_key] || {} - meta_key = params.key?("_meta") ? "_meta" : :_meta - meta = params[meta_key] || {} - - stamped_meta = meta.merge( - RequestEnvelope::PROTOCOL_VERSION_META_KEY.to_sym => protocol_version, - RequestEnvelope::CLIENT_INFO_META_KEY.to_sym => client_info, - RequestEnvelope::CLIENT_CAPABILITIES_META_KEY.to_sym => capabilities, - ) - - request.merge(params_key => params.merge(meta_key => stamped_meta)) - end - end - end -end diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index ecacc88..6e10000 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "oauth/authorization_request" -require_relative "oauth/bounded_body" require_relative "oauth/discovery" require_relative "oauth/flow" require_relative "oauth/in_memory_storage" diff --git a/lib/mcp/client/oauth/authorization_request.rb b/lib/mcp/client/oauth/authorization_request.rb deleted file mode 100644 index 57462f5..0000000 --- a/lib/mcp/client/oauth/authorization_request.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module MCP - class Client - module OAuth - # The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to, - # and with which scopes. - # - # Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it - # without changing the shape every implementer wrote. - # - # Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not, - # so the representation can change later. - AuthorizationRequest = Struct.new( - # `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata. - :authorization_server, - # The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`. - :scopes, - # The MCP server URL this client was configured with. - :server_url, - # The RFC 8707 `resource` the token is being requested for. - :resource, - keyword_init: true, - ) - end - end -end diff --git a/lib/mcp/client/oauth/bounded_body.rb b/lib/mcp/client/oauth/bounded_body.rb deleted file mode 100644 index f3b5efa..0000000 --- a/lib/mcp/client/oauth/bounded_body.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module MCP - class Client - module OAuth - # Bounds an OAuth response body while it arrives, rather than after it has been buffered. - # Discovery documents, registration responses, and token responses are all small by definition, - # so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of - # `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`. - class BoundedBody - MAX_RESPONSE_BYTES = 4 * 1024 * 1024 - - # Raised while the body is read. Each caller translates it into its own error type, - # so this never reaches an embedder. - class TooLargeError < StandardError; end - - # What the OAuth code reads from a response. The Faraday response itself is not passed on, - # so a later caller cannot reach the unbounded `response.body` by accident. - Response = Struct.new(:status, :body) - - def initialize(max_bytes: MAX_RESPONSE_BYTES) - @max_bytes = max_bytes - @buffer = +"" - end - - # Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates - # `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body - # that expands past the cap is refused partway through the expansion rather than after it. That holds only while - # the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`. - def on_data - proc do |chunk, _received_bytes, _env| - @buffer << chunk - - raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes - end - end - - # The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver - # the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then, - # but refusing them still keeps an over-cap document out of `JSON.parse`. - def response_for(response) - Response.new(response.status, bounded_body(response)) - end - - private - - def bounded_body(response) - return @buffer unless @buffer.empty? - - body = response.body - body = body.is_a?(String) ? body : body.to_s - raise TooLargeError, too_large_message if body.bytesize > @max_bytes - - body - end - - def too_large_message - # Not "the authorization server": protected resource metadata comes from the MCP server's own origin, - # so this message covers endpoints on both sides of the flow. - "Response body from the OAuth endpoint exceeds #{@max_bytes} bytes" - end - end - - private_constant :BoundedBody - end - end -end diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index 4e1e1cf..688ff17 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -37,17 +37,11 @@ module MCP # also take the algorithm as an explicit option). # - `scope` - String of space-separated scopes to request when the server's # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. - # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, - # and `save_client_information(info)`. Defaults to an `InMemoryStorage`. - # The `client_id` / `client_secret` are written into it so the token exchange reads - # them through the same path as a pre-registered authorization-code client. - # - `token_request_params` - Hash of String keys and values added to every - # token request this provider makes, for parameters the authorization - # server requires beyond the grant itself (Auth0's `audience`, for example). - # A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`. - # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. - # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for - # its own requests; see `StorageBackedProvider#http_client_customizer`. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, + # `client_information`, and `save_client_information(info)`. Defaults to + # an `InMemoryStorage`. The `client_id` / `client_secret` are written + # into it so the token exchange reads them through the same path as + # a pre-registered authorization-code client. class ClientCredentialsProvider include StorageBackedProvider @@ -66,10 +60,7 @@ module MCP private_key: nil, signing_algorithm: nil, scope: nil, - storage: nil, - authorization_request_validator: nil, - token_request_params: nil, - http_client_customizer: nil + storage: nil ) if blank?(client_id) raise InvalidCredentialsError, "client_id is required for the client_credentials grant." @@ -107,16 +98,11 @@ module MCP client_information["client_secret"] = client_secret end - http_client_customizer = validated_http_client_customizer(http_client_customizer) - @client_id = client_id @private_key = private_key @signing_algorithm = signing_algorithm @scope = scope @storage = storage || InMemoryStorage.new - @authorization_request_validator = authorization_request_validator - @token_request_params = frozen_token_request_params(token_request_params) - @http_client_customizer = http_client_customizer @storage.save_client_information(client_information) end diff --git a/lib/mcp/client/oauth/cross_app_access_provider.rb b/lib/mcp/client/oauth/cross_app_access_provider.rb index 44b5ffd..af5b599 100644 --- a/lib/mcp/client/oauth/cross_app_access_provider.rb +++ b/lib/mcp/client/oauth/cross_app_access_provider.rb @@ -25,12 +25,6 @@ module MCP # the Protected Resource Metadata do not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`. # Defaults to an `InMemoryStorage`. - # - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters - # the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises - # `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen. - # See `StorageBackedProvider#token_request_params`. - # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests; - # see `StorageBackedProvider#http_client_customizer`. # # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 class CrossAppAccessProvider @@ -41,16 +35,7 @@ module MCP attr_reader :scope, :storage - def initialize( - client_id:, - client_secret:, - assertion_provider:, - scope: nil, - storage: nil, - authorization_request_validator: nil, - token_request_params: nil, - http_client_customizer: nil - ) + def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil) if blank?(client_id) raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." end @@ -63,14 +48,9 @@ module MCP raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion." end - http_client_customizer = validated_http_client_customizer(http_client_customizer) - @assertion_provider = assertion_provider @scope = scope @storage = storage || InMemoryStorage.new - @authorization_request_validator = authorization_request_validator - @token_request_params = frozen_token_request_params(token_request_params) - @http_client_customizer = http_client_customizer @storage.save_client_information( "client_id" => client_id, "client_secret" => client_secret, diff --git a/lib/mcp/client/oauth/discovery.rb b/lib/mcp/client/oauth/discovery.rb index a8ad3be..885f1db 100644 --- a/lib/mcp/client/oauth/discovery.rb +++ b/lib/mcp/client/oauth/discovery.rb @@ -8,7 +8,7 @@ module MCP module OAuth # Stateless helpers that map MCP-authorization spec URLs and headers into something # the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on. - # The module bundles six concerns that share no state but are closely related to + # The module bundles five concerns that share no state but are closely related to # the spec's "Discovery" and "Communication Security" sections: # # - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls @@ -22,10 +22,6 @@ module MCP # - **Communication Security check** (`secure_url?`): enforces "HTTPS only" # for every OAuth-facing URL, with the loopback carve-out described in # `secure_url?`'s comment. - # - **Destination checks** (`same_origin?`, `private_network_host?`): - # answer *where* a URL points, which the scheme alone does not. `Flow` uses - # them to refuse server-supplied discovery URLs aimed at hosts the MCP server - # has no business steering the client toward. # - **URL canonicalization** (`canonicalize_url`): normalizes scheme, # host, port, path, percent-encoded dot segments, and fragments # so two URLs that *refer to the same resource* compare as equal, @@ -197,51 +193,6 @@ module MCP false end - # Returns true when `url` and `other` share an origin: same scheme, same host, - # and same port. Scheme and host compare case-insensitively, and an implicit default port compares - # equal to the same port written out, because `URI::HTTP#port` already resolves to the default. - # - # Anything that fails to parse or carries no host returns false, so a caller using this as - # a security gate refuses rather than admits on malformed input. - def same_origin?(url, other) - uri = URI.parse(url.to_s) - other_uri = URI.parse(other.to_s) - return false if uri.host.nil? || uri.host.empty? - return false if other_uri.host.nil? || other_uri.host.empty? - - uri.scheme&.downcase == other_uri.scheme&.downcase && - uri.host.downcase == other_uri.host.downcase && - uri.port == other_uri.port - rescue URI::InvalidURIError - false - end - - # Returns true when `host` is an IP literal that is not reachable from the public internet, - # or the `localhost` name. The ranges are the ones RFC 9728 Section 7.7 and the MCP security best practices name - # as SSRF targets, so `169.254.0.0/16` covers the `169.254.169.254` cloud metadata address that motivates the check. - # - # Hostnames are deliberately NOT resolved. A lookup here would be a second network request driven by - # the same untrusted input, and the address it returned could differ from the one the HTTP client connects to - # a moment later, so the check would read as a guarantee it cannot make. That leaves names like `vault.corp.internal` - # out of reach of this predicate; it is one layer of an SSRF defense, not the whole of one. - def private_network_host?(host) - return false if host.nil? || host.empty? - - normalized = host.downcase - return true if normalized == "localhost" - - literal = normalized.delete_prefix("[").delete_suffix("]") - address = parse_ip_address(literal) - return numeric_ipv4_spelling?(literal) unless address - - # `::ffff:169.254.169.254` addresses the same interface as `169.254.169.254`, - # so compare the IPv4 form rather than letting the mapped spelling through. - address = address.native if address.ipv6? && address.ipv4_mapped? - - ranges = address.ipv4? ? PRIVATE_IPV4_RANGES : PRIVATE_IPV6_RANGES - ranges.any? { |range| range.include?(address) } - end - # Returns true when `url` satisfies the structural requirements for # a Client ID Metadata Document URL per the MCP 2025-11-25 # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`. @@ -336,40 +287,20 @@ module MCP uri.to_s end - # Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting - # a URL next to the request that failed, where it must still match the server's access log; `URI#to_s` - # still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path` - # it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters - # for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry - # the very credentials being dropped. - def redact_url(url) - uri = URI.parse(url.to_s) - - uri.fragment = nil - uri.query = nil - # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version), - # so clear the components individually. - if uri.respond_to?(:user) && (uri.user || uri.password) - uri.user = nil - uri.password = nil - end - - uri.to_s - rescue URI::Error - "[unparseable URL]" - end - - # Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL): - # same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM - # also advertises a query string, the server's query MUST be identical to it (otherwise - # a hijacked PRM that advertises `?tenant=evil` would cover an MCP server at `?tenant=victim` - # and let the attacker mint a different tenant's token for the same origin + path). - # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier over - # the origin + path prefix and covers any server query. + # Returns true when `prm` (a PRM `resource` URL) covers `server` + # (the MCP endpoint URL): same scheme/host/port, with PRM's path being + # a prefix of the server's path. When PRM also advertises a query + # string, the server's query MUST be identical to it + # (otherwise a hijacked PRM that advertises `?tenant=evil` would cover + # an MCP server at `?tenant=victim` and let the attacker mint + # a different tenant's token for the same origin + path). + # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier + # over the origin + path prefix and covers any server query. # - # An empty query (`prm_url?` -- URI#query returns `""`) is NOT treated as wildcard: - # it represents the URI literally `<...>?`, which is distinct from "no query at all" - # and from any non-empty query, so it must match exactly. + # An empty query (`prm_url?` -- URI#query returns `""`) is NOT + # treated as wildcard: it represents the URI literally `<...>?`, + # which is distinct from "no query at all" and from any non-empty query, + # so it must match exactly. # # Both arguments must already be canonicalized. def resource_covers?(prm:, server:) @@ -409,29 +340,6 @@ module MCP IPV6_LOOPBACK = IPAddr.new("::1") private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK - # Backing ranges for `private_network_host?`. - # - # `0.0.0.0/8` is "this network" and `100.64.0.0/10` is the carrier-grade NAT - # space; neither is a destination a legitimate authorization server publishes, - # and both are reachable enough from a client's network position to be worth - # refusing. `::/96` covers the unspecified address, IPv6 loopback, and - # the deprecated IPv4-compatible form (`::10.0.0.1`) in one range. - PRIVATE_IPV4_RANGES = [ - IPAddr.new("0.0.0.0/8"), - IPAddr.new("10.0.0.0/8"), - IPAddr.new("100.64.0.0/10"), - IPAddr.new("127.0.0.0/8"), - IPAddr.new("169.254.0.0/16"), - IPAddr.new("172.16.0.0/12"), - IPAddr.new("192.168.0.0/16"), - ].freeze - PRIVATE_IPV6_RANGES = [ - IPAddr.new("::/96"), - IPAddr.new("fc00::/7"), - IPAddr.new("fe80::/10"), - ].freeze - private_constant :PRIVATE_IPV4_RANGES, :PRIVATE_IPV6_RANGES - def loopback_host?(host) return false if host.nil? || host.empty? @@ -454,31 +362,6 @@ module MCP nil end - # Matches one part of the `inet_aton` numeric grammar: hexadecimal (`0x7f`), - # octal (`0177`), or decimal (`127`). - INET_ATON_PART = /\A(?:0x\h+|0[0-7]*|[1-9]\d*)\z/.freeze - private_constant :INET_ATON_PART - - # Returns true when `host` is written in the numeric IPv4 grammar `inet_aton` accepts - # and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or - # decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and - # `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address, - # so a check that understood only the canonical form would refuse `169.254.169.254` - # while waving through every other spelling of it. - # - # Such a host is refused outright rather than decoded and range-checked, because its - # value is resolver-dependent: a leading zero reads as octal on one platform and decimal - # on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`. - # Deciding here which one it meant would leave whichever reading was not taken as a way - # through. No authorization server is legitimately addressed in this notation, so - # refusing the grammar entirely costs nothing and leaves no spelling to miss. - def numeric_ipv4_spelling?(host) - parts = host.split(".", -1) - return false if parts.empty? || parts.length > 4 - - parts.all? { |part| INET_ATON_PART.match?(part) } - end - # A redirect URI counts as native when it uses a custom non-http(s) scheme # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is # a loopback address. A URI without a scheme or one that fails to parse is not native. diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 58c234d..32669f2 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -14,38 +14,7 @@ module MCP # `Provider`; this class consumes a Provider plus signal data extracted from # the failing response (resource_metadata URL, scope challenge). class Flow - TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128 - TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512 - METADATA_DIAGNOSTIC_MAX_LENGTH = 128 - METADATA_URL_MAX_LENGTH = 2048 - - # Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`, - # so a provider naming one of these is refused rather than left believing its value was sent. - RESERVED_TOKEN_REQUEST_PARAMS = [ - "grant_type", - "client_id", - "client_secret", - "client_assertion", - "client_assertion_type", - "scope", - "resource", - "code", - "code_verifier", - "redirect_uri", - "refresh_token", - "assertion", - ].freeze - - class AuthorizationError < StandardError - attr_reader :http_status, :error, :error_description - - def initialize(message = nil, http_status: nil, error: nil, error_description: nil) - super(message) - @http_status = http_status - @error = error - @error_description = error_description - end - end + class AuthorizationError < StandardError; end # Raised specifically when the token endpoint rejects a grant with # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to @@ -54,141 +23,6 @@ module MCP # should leave the refresh token intact. class InvalidGrantError < AuthorizationError; end - # Raised when `authorization_request_validator` returns a falsy value. Separate from its parent - # so that a caller can tell a refusal by its own policy from a network, discovery, - # or authorization server metadata failure by rescuing a class rather than by matching the message text. - class AuthorizationRefusedError < AuthorizationError; end - - # Raised by metadata discovery when every candidate URL answered that nothing usable is published there - # (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object). - # The only discovery failure that may select the legacy 2025-03-26 path. - class MetadataNotPublishedError < AuthorizationError; end - - # Raised by metadata discovery when the answer says nothing about what is published: the request failed to - # reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move - # the flow to a different authorization server because of a transient failure, so it is surfaced instead, - # as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both. - class MetadataUnreachableError < AuthorizationError; end - - # Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity, - # or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake, - # not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on - # a failed refresh as a reason to run the interactive flow. - class InvalidTokenRequestParamsError < ArgumentError; end - - # Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer` - # would send a request to an origin other than the one the flow validated, or has dropped the record of - # the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake, - # and deliberately outside `AuthorizationError`, which discovery treats as "nothing published" - # and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow. - class DestinationMismatchError < ArgumentError; end - - # Faraday middleware registered on the connection `build_http_client` assembles before the customizer - # is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly - # as the flow requested it, which it records on the request environment for `RequestedOriginGuard`. - # The guard covers what happens to a request after that record; middleware inserted ahead of it with - # `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard. - # The record lives on the environment, not in `env.request.context`: that slot belongs to the application, - # which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen - # on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after - # a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with - # the redirected URL. - class RequestedURLStamp - KEY = :mcp_oauth_requested_url - - def initialize(app) - @app = app - end - - def call(env) - env[KEY] ||= env.url.to_s - @app.call(env) - end - end - - # Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added - # middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked - # for is refused before it reaches the adapter, since every destination check ran against the URL as - # written; a same-origin change stays with the server those checks admitted. The record survives - # the `env.dup` that redirect-following middleware performs, and a request that arrives without it is - # refused as well, so a middleware that rebuilds the environment fails closed rather than open. - # The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows - # a redirect itself only within the origin; this flow follows none. - class RequestedOriginGuard - def initialize(app) - @app = app - end - - def call(env) - requested = env[RequestedURLStamp::KEY] - unless requested - raise DestinationMismatchError, <<~MESSAGE - Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \ - the URL the flow asked for; middleware that rebuilds the request environment is refused. - MESSAGE - end - - unless Discovery.same_origin?(env.url.to_s, requested) - raise DestinationMismatchError, <<~MESSAGE - Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \ - #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \ - middleware that follows redirects or rewrites URLs is refused. - MESSAGE - end - - @app.call(env) - end - end - private_constant :RequestedURLStamp, :RequestedOriginGuard - - class << self - # Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can. - # Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`, - # so the same problem reads the same wherever it surfaces. - def token_request_params_problem(params) - return "must be a Hash (got #{params.class})." unless params.is_a?(Hash) - - # Two equal keys are two entries here, which would be sent twice from a provider method - # or silently collapse into one when the constructors copy the Hash. - return "must not compare keys by identity." if params.compare_by_identity? - - params.each do |key, value| - return "keys must be Strings (got #{key.class})." unless key.is_a?(String) - return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String) - return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key) - end - - nil - end - - # Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`, - # then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`), - # then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request - # after the stamp recorded it. Every request on the connection passes through both, so a caller using - # it directly is held to the same origin rule. - # - # Deliberately built without redirect-following middleware. Every destination check in this class runs - # against the URL as written, before the request goes out, so a connection that transparently followed - # a `3xx` would let a server reach a host the checks just refused. The guard turns following at - # the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must - # not enable it. - # - # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes - # the response only while the caller has not claimed that header; assigning it turns `decode_content` off, - # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it - # after the check. - def build_http_client(customizer = nil) - require "faraday" - - Faraday.new do |faraday| - faraday.headers["Accept"] = "application/json" - faraday.use(RequestedURLStamp) - customizer&.call(faraday) - faraday.use(RequestedOriginGuard) - end - end - end - def initialize(provider:, http_client_factory: nil) @provider = provider @http_client_factory = http_client_factory || -> { default_http_client } @@ -199,15 +33,9 @@ module MCP def run!(server_url:, resource_metadata_url: nil, scope: nil) # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very # first thing we contact in the OAuth flow, so it has to clear the same - # Communication Security bar as the OAuth endpoints downstream, and it has to - # point back at the server that issued the challenge. + # Communication Security bar as the OAuth endpoints downstream. if resource_metadata_url ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") - ensure_same_origin!( - resource_metadata_url, - label: "WWW-Authenticate resource_metadata URL", - server_url: server_url, - ) end prm, authorization_server = locate_authorization_server( @@ -221,30 +49,21 @@ module MCP # being redirected to credentials minted for a different audience. resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) - as_metadata = authorization_server_metadata( - authorization_server: authorization_server, - legacy: prm.nil?, - server_url: server_url, - ) + as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) case provider_authorization_flow when :client_credentials - return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url) + return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) when :jwt_bearer - return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url) + return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) end ensure_pkce_supported!(as_metadata) - effective_scope = resolve_scope(scope: scope, prm: prm) - effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) - - # Asked before registering, not after: a refusal must not leave this client registered at an authorization server - # the embedding application has just rejected. - authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) - client_info = ensure_client_registered(as_metadata: as_metadata) + effective_scope = resolve_scope(scope: scope, prm: prm || {}) + effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) pkce = PKCE.generate state = SecureRandom.urlsafe_base64(32) @@ -280,29 +99,27 @@ module MCP resource: resource, ) - save_tokens_issued_by(tokens, as_metadata: as_metadata) + @provider.save_tokens(tokens) :authorized end # Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists # the resulting token. Shares the same discovery and security checks as `run!`; the only difference is # the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request, - # and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3). + # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`. # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:) + def run_client_credentials!(as_metadata:, prm:, resource:, scope:) client_info = client_credentials_client_info ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) form = { "grant_type" => "client_credentials" } effective_scope = resolve_scope(scope: scope, prm: prm) - authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) form["scope"] = effective_scope if effective_scope form["resource"] = resource if resource tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) - save_tokens_issued_by(tokens, as_metadata: as_metadata) - + @provider.save_tokens(tokens) :authorized end @@ -346,18 +163,12 @@ module MCP # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request. # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated. # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 - def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:) + def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:) client_info = @provider.client_information unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`." end - # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant: - # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once - # the host has refused it. - effective_scope = resolve_scope(scope: scope, prm: prm) - authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource) - assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource) if assertion.nil? || assertion.to_s.empty? raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion." @@ -367,11 +178,12 @@ module MCP "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion" => assertion, } + effective_scope = resolve_scope(scope: scope, prm: prm) form["scope"] = effective_scope if effective_scope form["resource"] = resource if resource tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) - save_tokens_issued_by(tokens, as_metadata: as_metadata) + @provider.save_tokens(tokens) :authorized end @@ -380,22 +192,19 @@ module MCP # checks before talking to it. # # Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, - # when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer, # or when the token endpoint refuses the refresh request. # https://www.rfc-editor.org/rfc/rfc6749#section-6 def refresh!(server_url:, resource_metadata_url: nil) refresh_token = read_token("refresh_token") raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token - ensure_refresh_token_issuer_recorded! - stored_client_info = @provider.client_information have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id") # A CIMD-configured provider stores no `client_information` on purpose # (the CIMD URL is re-resolved against the live AS metadata on every flow). # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable. - have_cimd_url = !provider_client_id_metadata_document_url.nil? + have_cimd_url = !@provider.client_id_metadata_document_url.nil? unless have_stored_client_info || have_cimd_url raise AuthorizationError, "Cannot refresh: no client_information in provider storage." @@ -403,11 +212,6 @@ module MCP if resource_metadata_url ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL") - ensure_same_origin!( - resource_metadata_url, - label: "WWW-Authenticate resource_metadata URL", - server_url: server_url, - ) end prm, authorization_server = locate_authorization_server( @@ -417,13 +221,7 @@ module MCP resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource")) - as_metadata = authorization_server_metadata( - authorization_server: authorization_server, - legacy: prm.nil?, - server_url: server_url, - ) - - ensure_token_issuer!(as_metadata: as_metadata) + as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?) client_info = if have_stored_client_info # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, @@ -431,7 +229,7 @@ module MCP ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata) stored_client_info elsif as_metadata["client_id_metadata_document_supported"] == true - { "client_id" => provider_client_id_metadata_document_url } + { "client_id" => @provider.client_id_metadata_document_url } else raise AuthorizationError, "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \ @@ -445,7 +243,7 @@ module MCP resource: resource, ) - save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata) + @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token)) :refreshed end @@ -483,12 +281,7 @@ module MCP # # Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil # and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs. - # Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`, - # a redirect that was not followed, or a body that is not a JSON object) selects the legacy path. - # A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes, - # so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`), - # as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors. - # A body over the response cap is refused outright and never reaches the fallback either. + # Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior. # https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery def locate_authorization_server(server_url:, resource_metadata_url:) prm = begin @@ -496,18 +289,13 @@ module MCP server_url: server_url, resource_metadata_url: resource_metadata_url, ) - rescue MetadataNotPublishedError + rescue AuthorizationError nil end if prm authorization_server = first_authorization_server(prm) ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry") - ensure_routable_destination!( - authorization_server, - label: "PRM `authorization_servers` entry", - server_url: server_url, - ) [prm, authorization_server] else authorization_base = server_origin!(server_url) @@ -518,26 +306,16 @@ module MCP # Fetches and validates the authorization server's RFC 8414 metadata. # - # The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths. - # On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as - # the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs - # accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer` - # would otherwise become the identity tokens and client information are bound to, assertions are minted for, - # and the validator is shown, so a server could claim another authorization server and unlock the credentials - # bound to it. + # On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3). + # On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement, + # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from + # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path). # When even the metadata document is absent, the legacy spec's default endpoints are used. - def authorization_server_metadata(authorization_server:, legacy:, server_url:) + def authorization_server_metadata(authorization_server:, legacy:) metadata = if legacy - fetched = begin + begin fetch_authorization_server_metadata(issuer_url: authorization_server) rescue AuthorizationError - nil - end - - if fetched - ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"]) - fetched - else default_legacy_metadata(authorization_server) end else @@ -546,7 +324,7 @@ module MCP end end - ensure_secure_endpoints!(metadata, server_url: server_url) + ensure_secure_endpoints!(metadata) metadata end @@ -582,14 +360,6 @@ module MCP fetch_metadata_json(urls, label: "authorization server metadata") end - # The legacy authorization base is an origin, which a document may render as `https://host/`; - # both name the same server, and nothing else does. - def ensure_legacy_issuer_matches!(expected:, returned:) - return if returned == "#{expected}/" - - ensure_issuer_matches!(expected: expected, returned: returned) - end - # Reads `authorization_servers` from a PRM document and returns # the first entry, raising `AuthorizationError` for any of the malformed # shapes a non-compliant server could emit (missing field, non-Array @@ -617,63 +387,44 @@ module MCP first end - # Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object; - # the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still - # be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path, - # or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing - # about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises - # `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that - # was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`. - # A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`, - # before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment - # and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against - # a server's access log, and with exception text bounded, since the message lands in every log destination - # the error passes through. + # Walks candidate metadata URLs and returns the parsed JSON body of + # the first 2xx response. Raises `AuthorizationError` for transport + # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`) + # so callers do not have to handle raw Faraday/JSON exceptions. def fetch_metadata_json(urls, label:) - failures = [] - inconclusive = false + last_error = nil urls.each do |url| response = begin http_get(url) rescue Faraday::Error => e - detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) - failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}" - inconclusive = true + last_error = "GET #{url} raised #{e.class}: #{e.message}" next end - unless response.status >= 200 && response.status < 300 - failures << "GET #{reported_url(url)} returned #{response.status}" - inconclusive = true if response.status >= 500 || response.status == 429 - next + if response.status >= 200 && response.status < 300 + parsed = begin + JSON.parse(response_body_string(response)) + rescue JSON::ParserError => e + raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}." + end + + # Even valid JSON can be the wrong shape (a top-level array, + # a bare `null`, a string, ...). The discovery callers index by + # name (`prm["authorization_servers"]`, etc.), so anything that + # is not a Hash would raise `TypeError` / `NoMethodError` + # downstream. Surface that as `AuthorizationError` instead so + # callers see a single, documented error type. + unless parsed.is_a?(Hash) + raise AuthorizationError, + "#{label} from #{url} is not a JSON object (got #{parsed.class})." + end + + return parsed end - parsed = begin - JSON.parse(response_body_string(response)) - rescue JSON::ParserError => e - detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name - failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}" - next - end - - # Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...). - # The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that - # is not a Hash would raise `TypeError` / `NoMethodError` downstream. - unless parsed.is_a?(Hash) - failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})" - next - end - - return parsed - end - - message = "Failed to fetch #{label}: #{failures.join("; ")}." - - if inconclusive - raise MetadataUnreachableError, message - else - raise MetadataNotPublishedError, message + last_error = "GET #{url} returned #{response.status}" end + raise AuthorizationError, "Failed to fetch #{label}: #{last_error}." end def ensure_pkce_supported!(as_metadata) @@ -694,109 +445,13 @@ module MCP "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)." end - # Requires a URL the *server* chose to sit on the origin the *caller* chose. - # - # Protected Resource Metadata describes the MCP server itself, so on a real deployment it is - # published on that server's own origin. Without this check a `WWW-Authenticate` challenge - # can aim the first request of the flow at any host the client can route to: the URL arrives - # from the network, it is fetched before the user approves anything, and the `resource` check that - # runs afterwards cannot un-send the request. - # - # RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than - # the specification. It is enforced unconditionally because no known deployment publishes its PRM - # anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see - # internal hosts that are named rather than addressed. - # https://www.rfc-editor.org/rfc/rfc9728#section-7.7 - def ensure_same_origin!(url, label:, server_url:) - return if Discovery.same_origin?(url, server_url) - - raise AuthorizationError, - "#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \ - "#{sanitized_url(server_url).inspect}; refusing to fetch it." - end - - # Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address, - # which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take. - # - # The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address, - # the whole flow is already inside that network and the authorization server legitimately lives there too. - # That covers `http://localhost` development, the conformance harness (which runs the MCP server and - # the authorization server on two loopback ports), and deployments that never leave a corporate network. - # Only a server reachable on the public internet is barred from steering the client inward. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - # Hands the embedding application the authorization server and the scopes that are about to be requested, - # and abandons the flow when it refuses them. - # - # Both values are chosen by the MCP server: it names its own authorization server in Protected - # Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge. - # Neither the specification nor any MCP SDK binds that choice to the server's own identity, - # and validating that a token was issued for the intended audience is a responsibility the specification - # places on MCP servers rather than on clients. - # A host that knows which providers its user deals with can apply that knowledge here. - # - # The scopes are passed on unchanged whatever the host decides, because the specification requires - # a client to treat the challenged scopes as authoritative for the operation; the choice offered is - # to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before. - # - # Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered - # this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead: - # an authorization server that has changed since the tokens were issued sends the flow back through here, - # where the host sees the new one and decides again. - def authorize_request!(as_metadata:, scope:, server_url:, resource:) - return unless @provider.respond_to?(:authorization_request_validator) - return unless (validator = @provider.authorization_request_validator) - - request = AuthorizationRequest.new( - authorization_server: as_metadata["issuer"], - scopes: scope.to_s.split, - server_url: server_url, - resource: resource, - ).freeze - - return if validator.call(request) - - raise AuthorizationRefusedError, <<~MESSAGE - The authorization request was refused by `authorization_request_validator` \ - (authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}). - MESSAGE - end - - def ensure_routable_destination!(url, label:, server_url:) - return unless private_network_url?(url) - return if private_network_url?(server_url) - - raise AuthorizationError, - "#{label} #{sanitized_url(url).inspect} points into a private network range, " \ - "which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it." - end - - def ensure_secure_endpoints!(as_metadata, server_url:) + def ensure_secure_endpoints!(as_metadata) ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key| endpoint = as_metadata[key] - next unless endpoint - - ensure_secure_url!(endpoint, label: "Authorization server #{key}") - ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url) + ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint end end - # `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse, - # so the rescue here is a backstop rather than a leniency. - def private_network_url?(url) - Discovery.private_network_host?(URI.parse(url.to_s).host) - rescue URI::InvalidURIError - false - end - - # Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes - # when it reports a URL: these values come off the network and can carry credentials that would otherwise land in - # every log destination the error passes through. - def sanitized_url(url) - Discovery.canonicalize_origin_and_path(url) - rescue URI::Error - url.to_s - end - # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be # identical (literal byte-for-byte equality, no normalization) to # the issuer URL the client used to discover that document. This guards @@ -834,7 +489,7 @@ module MCP # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata # and the current `provider.client_id_metadata_document_url`. - cimd_url = provider_client_id_metadata_document_url + cimd_url = @provider.client_id_metadata_document_url if cimd_url && as_metadata["client_id_metadata_document_supported"] == true return { "client_id" => cimd_url } end @@ -931,51 +586,6 @@ module MCP # the error and falls back to the full flow, which discards the stale registration and re-registers. # Credentials without an `"issuer"` binding predate this check and are allowed through; # CIMD `client_id`s are portable. - # Records which authorization server minted these tokens, alongside the tokens themselves. - # SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that - # SEP leaves unbound. - # Stored in the token hash so it travels through any `storage` a caller supplied, without widening - # the storage interface that custom implementations have to satisfy. - def save_tokens_issued_by(tokens, as_metadata:) - issuer = as_metadata["issuer"] - tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer - - @provider.save_tokens(tokens) - end - - # Refuses to present a refresh token to an authorization server other than the one that issued it. - # `refresh!` rediscovers the authorization server from Protected Resource Metadata every time, - # so the server named for a session can differ from the one the stored tokens came from. - # Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is, - # including a Client ID Metadata Document URL, which is portable across authorization servers precisely - # so that re-registration is unnecessary. - # - # Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats - # client information without one; the binding takes effect from their next authorization. - # The caller answers a refusal by running the full authorization flow, which is where the embedding application - # is asked about the new authorization server. - def ensure_token_issuer!(as_metadata:) - stored_issuer = read_token("issuer") - return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"] - - raise AuthorizationError, <<~MESSAGE - Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \ - current #{as_metadata["issuer"].inspect}); re-authorization is required. - MESSAGE - end - - # `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`). - # The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new, - # and a refresh asks no validator, so a token without an `issuer` would be presented to whatever - # authorization server discovery names now. Refusing it sends the transport back through the grant, - # which does ask. - def ensure_refresh_token_issuer_recorded! - return if provider_authorization_flow == :authorization_code - return unless read_token("issuer").nil? - - raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required." - end - def ensure_refreshable_client_information!(client_info, as_metadata:) stored_issuer = client_info_required_value(client_info, "issuer") return if stored_issuer.nil? @@ -1104,8 +714,7 @@ module MCP def resolve_scope(scope:, prm:) return scope if scope && !scope.empty? - # `prm` is nil on the legacy path, where nothing advertises scopes. - supported = prm && prm["scopes_supported"] + supported = prm["scopes_supported"] return supported.join(" ") if supported.is_a?(Array) && !supported.empty? return @provider.scope if @provider.scope && !@provider.scope.empty? @@ -1167,31 +776,6 @@ module MCP @provider.authorization_flow end - # Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them; - # Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method, - # or one returning `nil`, adds nothing. - # A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do. - def provider_token_request_params - return {} unless @provider.respond_to?(:token_request_params) - - params = @provider.token_request_params - return {} if params.nil? - - problem = self.class.token_request_params_problem(params) - raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem - - params - end - - # The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader - # (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves - # every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`. - def provider_client_id_metadata_document_url - return unless @provider.respond_to?(:client_id_metadata_document_url) - - @provider.client_id_metadata_document_url - end - def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) authorization_endpoint = as_metadata["authorization_endpoint"] unless authorization_endpoint @@ -1241,12 +825,9 @@ module MCP post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) end - # Submits a form-encoded token request using the authentication method - # stored in `client_information`. The method determines whether client - # credentials belong in the form body, a Basic header, or a JWT assertion. - # A provider's `token_request_params` go underneath the flow's own parameters, - # which therefore win, and are refused before the request is sent when they name - # a reserved parameter or are not a Hash of Strings. + # Submits a form-encoded request to the token endpoint, applying + # the client authentication method advertised in `client_information` and + # adding `client_id` (and `client_secret` when not using HTTP Basic). def post_to_token_endpoint(as_metadata:, client_info:, form:) client_id = client_info_required_value(client_info, "client_id") unless client_id @@ -1256,15 +837,13 @@ module MCP client_secret = client_info_required_value(client_info, "client_secret") token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") - form = provider_token_request_params.merge(form) - # Apply one client authentication method per request (RFC 6749 Section 2.3). - headers = {} - form = case token_endpoint_auth_method - when "private_key_jwt" - # The assertion identifies the client through its `iss` and `sub` - # claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2). - # Use the issuer already checked by `ensure_issuer_matches!` as the audience. + form = if token_endpoint_auth_method == "private_key_jwt" + # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of + # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046). + # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is + # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity). + # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated. unless @provider.respond_to?(:client_assertion) raise AuthorizationError, "token_endpoint_auth_method is private_key_jwt but the provider does not " \ @@ -1275,24 +854,22 @@ module MCP "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE, "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]), ) - when "client_secret_post" - # Send the client ID and available secret in the form body. - body = form.merge("client_id" => client_id) - body["client_secret"] = client_secret if client_secret - body else - if client_secret && token_endpoint_auth_method != "none" - # Basic is also the fallback when a secret is present but no method - # is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1); - # omit it because some servers treat it alongside Basic as a second - # authentication method and reject the request with `invalid_request`. - headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret) - form + form.merge("client_id" => client_id) + end + + headers = {} + if client_secret + case token_endpoint_auth_method + when "client_secret_post" + form["client_secret"] = client_secret + when "none" + # Public client; no credential. else - # With `none` or no secret, identify the client using `client_id`. - # This is required for unauthenticated authorization-code exchanges - # (RFC 6749 Section 3.2.1). - form.merge("client_id" => client_id) + # RFC 6749 §2.3.1 recommends Basic for confidential clients and + # both Python and TypeScript SDKs default here when + # the authentication method is not explicitly stored. + headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret) end end @@ -1310,7 +887,11 @@ module MCP end if response.status < 200 || response.status >= 300 - raise token_endpoint_error(response) + if token_endpoint_error_code(response) == "invalid_grant" + raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant." + end + + raise AuthorizationError, "Token endpoint returned status #{response.status}." end parsed = begin @@ -1331,41 +912,17 @@ module MCP parsed end - # Surface only RFC 6749 §5.2 diagnostic fields, never the raw response, - # which may contain tokens or other credentials. Classify the original - # code so sanitization cannot turn malformed input into invalid_grant. - def token_endpoint_error(response) - message = "Token endpoint returned status #{response.status}." - error_class = AuthorizationError - parsed = JSON.parse(response_body_string(response)) - parsed = {} unless parsed.is_a?(Hash) + # Extracts the `error` code from an RFC 6749 §5.2 error response body + # when one is parseable. Returns nil on any parse failure or when + # the body is not JSON. + def token_endpoint_error_code(response) + body = response_body_string(response).to_s + return if body.empty? - error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError - error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH) - description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH) - message += " #{[error, description].compact.join(": ")}" if error || description - - error_class.new(message, http_status: response.status, error: error, error_description: description) - rescue StandardError - # Diagnostics must not mask the endpoint failure or change refresh recovery. - error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status) - end - - def bounded_diagnostic(value, limit:) - return unless value.is_a?(String) - - # RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields. - # Replace other characters to keep text received off the network on one log line. - value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip - return if value.empty? - - value.length > limit ? "#{value[0, limit - 3]}..." : value - end - - # A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot - # grow the message without limit. - def reported_url(url) - bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH) + parsed = JSON.parse(body) + parsed["error"] if parsed.is_a?(Hash) + rescue JSON::ParserError + nil end # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be @@ -1381,70 +938,35 @@ module MCP end def http_get(url) - bounded_request do |on_data| - http_client.get(url) do |req| - req.options.on_data = on_data - end - end + http_client.get(url) end def http_post_json(url, body) - bounded_request do |on_data| - http_client.post(url) do |req| - req.headers["Content-Type"] = "application/json" - req.headers["Accept"] = "application/json" - req.options.on_data = on_data - req.body = JSON.generate(body) - end + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/json" + req.headers["Accept"] = "application/json" + req.body = JSON.generate(body) end end def http_post_form(url, form, headers: {}) - bounded_request do |on_data| - http_client.post(url) do |req| - req.headers["Content-Type"] = "application/x-www-form-urlencoded" - req.headers["Accept"] = "application/json" - - headers.each do |key, value| - req.headers[key] = value - end - - req.options.on_data = on_data - req.body = URI.encode_www_form(form) - end + http_client.post(url) do |req| + req.headers["Content-Type"] = "application/x-www-form-urlencoded" + req.headers["Accept"] = "application/json" + headers.each { |key, value| req.headers[key] = value } + req.body = URI.encode_www_form(form) end end - # Issues a request with the response body bounded as it arrives, and returns the status paired with - # that body. An over-cap response is refused rather than truncated: a partial discovery or token document - # cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either, - # since the same server would serve the same body. - def bounded_request - bounded = BoundedBody.new - - response = yield(bounded.on_data) - - bounded.response_for(response) - rescue BoundedBody::TooLargeError => e - raise AuthorizationError, "#{e.message}." - end - def http_client @http_client ||= @http_client_factory.call end - # A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and - # `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on - # `build_http_client`; `bounded_request` caps its responses all the same. def default_http_client - self.class.build_http_client(provider_http_client_customizer) - end - - # `nil` for a provider that predates the hook or leaves it unset. - def provider_http_client_customizer - return unless @provider.respond_to?(:http_client_customizer) - - @provider.http_client_customizer + require "faraday" + Faraday.new do |faraday| + faraday.headers["Accept"] = "application/json" + end end def response_body_string(response) diff --git a/lib/mcp/client/oauth/id_jag_token_exchange.rb b/lib/mcp/client/oauth/id_jag_token_exchange.rb index ea771a9..0e21140 100644 --- a/lib/mcp/client/oauth/id_jag_token_exchange.rb +++ b/lib/mcp/client/oauth/id_jag_token_exchange.rb @@ -34,13 +34,10 @@ module MCP def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil) http_client ||= default_http_client - bounded = BoundedBody.new - response = begin - raw_response = http_client.post(token_endpoint) do |req| + http_client.post(token_endpoint) do |req| req.headers["Content-Type"] = "application/x-www-form-urlencoded" req.headers["Accept"] = "application/json" - req.options.on_data = bounded.on_data req.body = URI.encode_www_form( "grant_type" => GRANT_TYPE, "subject_token" => id_token, @@ -51,10 +48,6 @@ module MCP "client_id" => client_id, ) end - - bounded.response_for(raw_response) - rescue BoundedBody::TooLargeError => e - raise ExchangeError, "#{e.message}." rescue Faraday::Error => e raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}." end @@ -95,12 +88,8 @@ module MCP assertion end - # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`: - # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap - # onto compressed bytes. def default_http_client require "faraday" - Faraday.new do |faraday| faraday.headers["Accept"] = "application/json" end diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 803b5b9..741c6c5 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -48,13 +48,6 @@ module MCP # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword: # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. - # - `token_request_params` - Hash of String keys and values added to every token request this provider makes - # (the authorization code exchange and refresh), for parameters the authorization server requires beyond - # the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` - # raises `Flow::InvalidTokenRequestParamsError`. - # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`. - # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for - # its own requests; see `StorageBackedProvider#http_client_customizer`. class Provider include StorageBackedProvider @@ -93,10 +86,7 @@ module MCP callback_handler:, scope: nil, storage: nil, - client_id_metadata_document_url: nil, - authorization_request_validator: nil, - token_request_params: nil, - http_client_customizer: nil + client_id_metadata_document_url: nil ) unless Discovery.secure_url?(redirect_uri) raise InsecureRedirectURIError, @@ -118,8 +108,6 @@ module MCP "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`." end - http_client_customizer = validated_http_client_customizer(http_client_customizer) - @client_metadata = client_metadata @redirect_uri = redirect_uri @redirect_handler = redirect_handler @@ -127,9 +115,6 @@ module MCP @scope = scope @storage = storage || InMemoryStorage.new @client_id_metadata_document_url = client_id_metadata_document_url - @authorization_request_validator = authorization_request_validator - @token_request_params = frozen_token_request_params(token_request_params) - @http_client_customizer = http_client_customizer end # Identifies the OAuth flow this provider drives. diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb index 087f223..499db6b 100644 --- a/lib/mcp/client/oauth/storage_backed_provider.rb +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -4,37 +4,16 @@ module MCP class Client module OAuth # Shared token/credential persistence for the OAuth provider classes - # (`Provider` for the authorization-code flow, `ClientCredentialsProvider` - # for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow). - # The grants differ in how they authenticate, but all read and write the same two pieces of state - # through a `storage` object: the token response and the client information. This module supplies - # that delegation so the `Flow` orchestrator can treat any provider uniformly. + # (`Provider` for the authorization-code flow and `ClientCredentialsProvider` + # for the client_credentials flow). The two grants differ in how they authenticate, + # but both read and write the same two pieces of state through a `storage` object: + # the token response and the client information. This module supplies that delegation + # so the `Flow` orchestrator can treat any provider uniformly. # # Including classes must set `@storage` to an object responding to `tokens`, # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` # (see `InMemoryStorage`). module StorageBackedProvider - # Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`. - # An MCP server names its own authorization server and the scopes to ask for, so this is where - # an embedding application sees both before the request is made and can decline to proceed. - # `nil` (the default) authorizes whatever the server asked for, which is the behavior every - # MCP SDK has today. - attr_reader :authorization_request_validator - - # Optional Hash of String keys and values added to every token request the provider makes, for parameters - # the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them; - # Auth0's `audience` is the usual one). `nil` (the default) adds nothing. - # Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value - # with `Flow::InvalidTokenRequestParamsError`. - attr_reader :token_request_params - - # Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests, - # after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can - # add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default) - # keeps the default connection. The transport's own connection and customizer block never serve these requests: - # they are bound to the MCP server URL and carry headers meant for that server. - attr_reader :http_client_customizer - def access_token tokens&.dig("access_token") || tokens&.dig(:access_token) end @@ -58,29 +37,6 @@ module MCP def clear_tokens! @storage.save_tokens(nil) end - - private - - # Constructors call this before writing to `storage`, so a rejected provider leaves it untouched. - # The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key, - # or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly - # because `Hash` copies and freezes only keys whose class is exactly `String`. - def frozen_token_request_params(params) - return if params.nil? - - problem = Flow.token_request_params_problem(params) - raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem - - params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze - end - - # Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed - # in place of a callable fails at construction rather than at the first `401`. - def validated_http_client_customizer(customizer) - return customizer if customizer.nil? || customizer.respond_to?(:call) - - raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})." - end end end end diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index 0fbb96e..5bcf651 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -7,9 +7,7 @@ require "timeout" require_relative "../../json_rpc_handler" require_relative "../configuration" require_relative "../methods" -require_relative "../protocol_deprecations" require_relative "../version" -require_relative "modern_envelope" module MCP class Client @@ -28,12 +26,6 @@ module MCP # realistic JSON-RPC frame, including base64-embedded images. MAX_LINE_BYTES = 4 * 1024 * 1024 - # Seconds the `server/discover` probe may wait when no `read_timeout` was configured. - # A compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one - # that silently drops unknown methods would otherwise block `connect(mode: :auto)` forever. - # Matches the C# SDK's `DiscoverProbeTimeout` default. - DEFAULT_DISCOVER_PROBE_TIMEOUT = 5 - attr_reader :command, :args, :env, :server_info def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) @@ -57,9 +49,6 @@ module MCP @started = false @initialized = false @server_info = nil - @modern_protocol_version = nil - @modern_client_info = nil - @modern_capabilities = nil # Serializes writes to `@stdin` so a request line and a notification line emitted from # different threads (e.g. cancellation) cannot interleave on the wire. @write_mutex = Mutex.new @@ -77,57 +66,86 @@ module MCP # # @param client_info [Hash, nil] `{ name:, version: }` identifying the client. # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`. - # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake. - # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version - # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`). + # @param protocol_version [String, nil] Protocol version to offer. Defaults + # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`. # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`. # @return [Hash] The server's `InitializeResult`. # @raise [RequestHandlerError] If the server responds with a JSON-RPC error, # a malformed result, or an unsupported protocol version. - # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs - # the handshake below, `:modern` skips it and probes `server/discover`, - # and `:auto` probes `server/discover` first, falling back to the legacy handshake - # when the server does not serve a mutually supported modern version. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization - def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) - return @server_info if connected? - - # Validated before `start` so a pure argument error never spawns the server process. - MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy + def connect(client_info: nil, protocol_version: nil, capabilities: {}) + return @server_info if @initialized start unless @started client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } + protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION - case mode - when :legacy - connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - when :modern - connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - when :auto - connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - else - raise ArgumentError, "mode must be :legacy, :modern, or :auto" + init_request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: SecureRandom.uuid, + method: MCP::Methods::INITIALIZE, + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + } + + write_message(init_request) + response = read_response(init_request) + + if response.key?("error") + error = response["error"] + raise RequestHandlerError.new( + "Server initialization failed: #{error["message"]}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) end + + unless response["result"].is_a?(Hash) + raise RequestHandlerError.new( + "Server initialization failed: missing result in response", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + @server_info = response["result"] + + negotiated_protocol_version = @server_info["protocolVersion"] + unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) + # Per spec, if the client does not support the server's returned protocol version, + # the client SHOULD disconnect. Roll back the cached `InitializeResult` before + # raising so a retry starts without a stale `server_info`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation + @server_info = nil + raise RequestHandlerError.new( + "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", + { method: MCP::Methods::INITIALIZE }, + error_type: :internal_error, + ) + end + + begin + notification = { + jsonrpc: JsonRpcHandler::Version::V2_0, + method: MCP::Methods::NOTIFICATIONS_INITIALIZED, + } + write_message(notification) + rescue StandardError + @server_info = nil + raise + end + + @initialized = true + @server_info end - # Whether the transport operates in the stateless modern lifecycle (SEP-2575): - # no handshake was performed and every request carries the `_meta` envelope. - def modern? - !@modern_client_info.nil? - end - - # The protocol version in use on this connection, independent of its era: - # negotiated by `initialize` (legacy) or adopted via `server/discover` (modern). - # Returns `nil` before `connect` and after `close`. - def protocol_version - @modern_protocol_version || (@server_info && @server_info["protocolVersion"]) - end - - # Returns true once `connect` has completed the handshake or adopted the modern lifecycle. - # Returns false before the handshake and after `close`. + # Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`. def connected? - @initialized || modern? + @initialized end # Transports may yield once the request line has been written to `@stdin`. @@ -135,16 +153,7 @@ module MCP # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`, # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request. def send_request(request:) - method = request[:method] || request["method"] - if method == MCP::Methods::SERVER_DISCOVER - # `server/discover` (SEP-2575) is sessionless capability discovery that - # works before (or instead of) `connect`. - start unless @started - elsif !connected? - raise "MCP::Client#connect must be called before sending requests." - end - - request = stamp_modern(request) + raise "MCP::Client#connect must be called before sending requests." unless @initialized @write_mutex.synchronize do write_message(request) @@ -157,7 +166,7 @@ module MCP # `notifications/cancelled` for an in-flight request. def send_notification(notification:) start unless @started - connect unless connected? + connect unless @initialized @write_mutex.synchronize { write_message(notification) } nil @@ -219,196 +228,10 @@ module MCP @started = false @initialized = false @server_info = nil - leave_modern_mode end private - def connect_legacy(client_info:, protocol_version:, capabilities:) - protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION - - init_request = { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::INITIALIZE, - params: { - protocolVersion: protocol_version, - capabilities: capabilities, - clientInfo: client_info, - }, - } - - write_message(init_request) - response = read_response(init_request) - - if response.key?("error") - error = response["error"] - raise RequestHandlerError.new( - "Server initialization failed: #{error["message"]}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - unless response["result"].is_a?(Hash) - raise RequestHandlerError.new( - "Server initialization failed: missing result in response", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - @server_info = response["result"] - - negotiated_protocol_version = @server_info["protocolVersion"] - unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) - # Per spec, if the client does not support the server's returned protocol version, - # the client SHOULD disconnect. A modern version is rejected along with unknown ones: - # the handshake settles on a legacy version by definition, and the TypeScript and Python clients refuse - # a modern counter-offer the same way. Roll back the cached `InitializeResult` before raising - # so a retry starts without a stale `server_info`. - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation - @server_info = nil - raise RequestHandlerError.new( - "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", - { method: MCP::Methods::INITIALIZE }, - error_type: :internal_error, - ) - end - - begin - notification = { - jsonrpc: JsonRpcHandler::Version::V2_0, - method: MCP::Methods::NOTIFICATIONS_INITIALIZED, - } - write_message(notification) - rescue StandardError - @server_info = nil - raise - end - - @initialized = true - @server_info - end - - # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version. - # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`. - def connect_modern(client_info:, protocol_version:, capabilities:) - version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION - unless MCP::Configuration.modern_protocol_version?(version) - raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version" - end - - @modern_protocol_version = version - @modern_client_info = client_info - @modern_capabilities = capabilities || {} - - begin - result = with_probe_read_timeout { probe_discover } - rescue StandardError - leave_modern_mode - raise - end - - supported = result["supportedVersions"] - unless supported.is_a?(Array) && supported.include?(version) - leave_modern_mode - raise RequestHandlerError.new( - "Server discovery failed: no mutually supported modern protocol version " \ - "(server supports #{supported.inspect})", - { method: MCP::Methods::SERVER_DISCOVER }, - error_type: :internal_error, - ) - end - - # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks, - # so the warning lives here now that the handshake cannot land on one. - MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1) - - @server_info = result - @server_info - end - - # Probes `server/discover` and adopts the modern lifecycle when the server serves - # a mutually supported modern version; otherwise falls back to the legacy handshake. - # The fallback intentionally covers a successful discovery without a mutual modern - # version as well: during the 2026-07-28 rollout a server may answer discovery while - # only serving legacy versions. - def connect_auto(client_info:, protocol_version:, capabilities:) - modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version) - connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities) - rescue RequestHandlerError - # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it, - # so the probe's failure is the real answer and propagates. - raise if modern_pin - - connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) - end - - def leave_modern_mode - @modern_protocol_version = nil - @modern_client_info = nil - @modern_capabilities = nil - end - - # Bounds the probe read when the caller configured no `read_timeout`, and only for the probe: - # regular requests keep the unbounded default so long-running tools are unaffected. - # The timeout surfaces as a `RequestHandlerError`, which `connect_auto` treats as legacy evidence - # and falls back on. - def with_probe_read_timeout - return yield if @read_timeout - - @read_timeout = DEFAULT_DISCOVER_PROBE_TIMEOUT - begin - yield - ensure - @read_timeout = nil - end - end - - def probe_discover - request = { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: SecureRandom.uuid, - method: MCP::Methods::SERVER_DISCOVER, - } - - @write_mutex.synchronize { write_message(stamp_modern(request)) } - response = read_response(request) - - if response.key?("error") - error = response["error"] - raise RequestHandlerError.new( - "Server discovery failed: #{error["message"]}", - { method: MCP::Methods::SERVER_DISCOVER }, - error_type: :internal_error, - ) - end - - result = response["result"] - unless result.is_a?(Hash) - raise RequestHandlerError.new( - "Server discovery failed: missing result in response", - { method: MCP::Methods::SERVER_DISCOVER }, - error_type: :internal_error, - ) - end - - result - end - - # Modern requests (never notifications, whose `_meta` has no envelope) carry the SEP-2575 triple. - def stamp_modern(request) - return request unless modern? && (request[:id] || request["id"]) - - ModernEnvelope.stamp( - request, - protocol_version: @modern_protocol_version, - client_info: @modern_client_info, - capabilities: @modern_capabilities, - ) - end - def write_message(message) ensure_running! json = JSON.generate(message) @@ -436,18 +259,6 @@ module MCP parsed = JSON.parse(line.strip) - # A frame carrying `method` is a request or notification, never the awaited response, - # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id - # may legitimately collide with the awaited one. Pings are answered; - # other server-to-client requests over stdio stay unsupported as documented, - # and notifications carry no id. - if parsed.is_a?(Hash) && parsed.key?("method") - # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at - # schema validation, so a ping carrying one is skipped rather than echoed back. - answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) - next - end - # A JSON-RPC message is an object; skip a non-object frame (array or scalar) # the same way as a frame without an id. next unless parsed.is_a?(Hash) && parsed.key?("id") @@ -463,22 +274,6 @@ module MCP ) end - # The ping receiver "MUST respond promptly with an empty response". Answered inline from - # the read loop, so a keepalive cannot stall unanswered while a response is awaited; - # between requests nothing reads stdout, and a queued ping is answered on the next read. - def answer_ping(parsed) - @write_mutex.synchronize do - write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} }) - end - rescue RequestHandlerError - # Best effort: a pong cannot be delivered over a broken stdin, and the failure must not - # surface as an error of the unrelated request whose response the loop is reading. - end - - def json_rpc_id?(id) - id.is_a?(String) || id.is_a?(Numeric) - end - def ensure_running! return if @wait_thread.alive? diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb index bfefda8..b348d4d 100644 --- a/lib/mcp/client/tool.rb +++ b/lib/mcp/client/tool.rb @@ -3,14 +3,13 @@ module MCP class Client class Tool - attr_reader :name, :description, :input_schema, :output_schema, :annotations + attr_reader :name, :description, :input_schema, :output_schema - def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil) + def initialize(name:, description:, input_schema:, output_schema: nil) @name = name @description = description @input_schema = input_schema @output_schema = output_schema - @annotations = annotations end end end diff --git a/lib/mcp/configuration.rb b/lib/mcp/configuration.rb index 4d4b78e..3603e6f 100644 --- a/lib/mcp/configuration.rb +++ b/lib/mcp/configuration.rb @@ -2,49 +2,12 @@ module MCP class Configuration - LATEST_STABLE_PROTOCOL_VERSION = "2026-07-28" - ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION = "2026-07-28" + LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25" SUPPORTED_STABLE_PROTOCOL_VERSIONS = [ - LATEST_STABLE_PROTOCOL_VERSION, "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", + LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", ].freeze DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26" - # Protocol versions of the stateless "modern" lifecycle introduced by the MCP 2026-07-28 spec release (SEP-2575), - # where each request carries its own version in `_meta` and is validated against this list independently, - # with no handshake. These are reachable only through `server/discover` and the per-request envelope. - # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 - LATEST_MODERN_PROTOCOL_VERSION = "2026-07-28" - SUPPORTED_MODERN_PROTOCOL_VERSIONS = [LATEST_MODERN_PROTOCOL_VERSION].freeze - - # Protocol versions reachable through the legacy `initialize` handshake, derived so the era partition - # (handshake = stable minus modern) cannot drift when a new revision lands. - # Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish - # a session via `initialize` (2025-11-25 and earlier), and modern versions carry the version on every request - # in `_meta` with no handshake at all. The handshake therefore never negotiates a modern version: - # a client asking `initialize` for one is counter-offered - # `LATEST_HANDSHAKE_PROTOCOL_VERSION`, matching the TypeScript and Python SDKs. - SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS = (SUPPORTED_STABLE_PROTOCOL_VERSIONS - SUPPORTED_MODERN_PROTOCOL_VERSIONS).freeze - LATEST_HANDSHAKE_PROTOCOL_VERSION = SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.first - - class << self - def modern_protocol_version?(version) - SUPPORTED_MODERN_PROTOCOL_VERSIONS.include?(version) - end - - def handshake_protocol_version?(version) - SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(version) - end - - # The one statement of the client-side handshake contract, shared by every transport: - # a modern version cannot ride the legacy `initialize` handshake. - def reject_modern_handshake_version!(version) - return unless version && modern_protocol_version?(version) - - raise ArgumentError, "protocol version #{version.inspect} cannot be negotiated through the legacy " \ - "`initialize` handshake; use `mode: :modern` (or `:auto`) instead" - end - end - attr_writer :exception_reporter, :around_request # @deprecated Use {#around_request=} instead. `instrumentation_callback` @@ -54,7 +17,7 @@ module MCP attr_writer :instrumentation_callback def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil, - validate_tool_call_arguments: true, validate_tool_call_results: false, instrument_server_context: false) + validate_tool_call_arguments: true, validate_tool_call_results: false) @exception_reporter = exception_reporter @around_request = around_request @instrumentation_callback = instrumentation_callback @@ -64,11 +27,9 @@ module MCP end validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) validate_value_of_validate_tool_call_results!(validate_tool_call_results) - validate_value_of_instrument_server_context!(instrument_server_context) @validate_tool_call_arguments = validate_tool_call_arguments @validate_tool_call_results = validate_tool_call_results - @instrument_server_context = instrument_server_context end def protocol_version=(protocol_version) @@ -83,28 +44,14 @@ module MCP @validate_tool_call_arguments = validate_tool_call_arguments end - # Opt in to exposing the user-defined `server_context` in the - # `around_request` / `instrumentation_callback` data hash. Off by default: - # the hash is application-supplied and may hold values a tracing backend - # should not receive, so surfacing it has to be a deliberate choice. - def instrument_server_context=(instrument_server_context) - validate_value_of_instrument_server_context!(instrument_server_context) - - @instrument_server_context = instrument_server_context - end - def validate_tool_call_results=(validate_tool_call_results) validate_value_of_validate_tool_call_results!(validate_tool_call_results) @validate_tool_call_results = validate_tool_call_results end - # The pin scopes the `initialize` handshake, so an unset pin reads as the version that handshake - # settles on by default. Reading the newest version of any era here would hand back a value - # the writer rejects, and no caller wants the modern revision: a modern connection carries - # its version on every request instead of consulting configuration. def protocol_version - @protocol_version || LATEST_HANDSHAKE_PROTOCOL_VERSION + @protocol_version || LATEST_STABLE_PROTOCOL_VERSION end def protocol_version? @@ -144,10 +91,6 @@ module MCP attr_reader :validate_tool_call_arguments attr_reader :validate_tool_call_results - def instrument_server_context? - !!@instrument_server_context - end - def validate_tool_call_arguments? !!@validate_tool_call_arguments end @@ -193,30 +136,16 @@ module MCP protocol_version: protocol_version, validate_tool_call_arguments: validate_tool_call_arguments, validate_tool_call_results: validate_tool_call_results, - instrument_server_context: other.instrument_server_context?, ) end private - # A pin scopes the `initialize` handshake, so only handshake versions are accepted: - # a modern version has no handshake to pin (its version rides every request in `_meta`), - # and accepting one here would configure nothing. Failing at construction beats - # a setting that silently does not apply. def validate_protocol_version!(protocol_version) - return if SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(protocol_version) - - # A version this SDK serves, rejected only for where it was set, deserves the reason and - # the alternative rather than a list it is missing from: this is the error an upgrade from - # a release that accepted the value lands on, so it doubles as the migration note. - if self.class.modern_protocol_version?(protocol_version) - raise ArgumentError, "protocol_version #{protocol_version.inspect} is a modern protocol version and cannot be pinned here: " \ - "the pin scopes the `initialize` handshake, which never negotiates a modern version. " \ - "Modern clients carry their version on every request and need no pin; remove the setting." + unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) + message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}" + raise ArgumentError, message end - - raise ArgumentError, "protocol_version must be #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[0...-1].join(", ")}, " \ - "or #{SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS[-1]}" end def validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments) @@ -231,12 +160,6 @@ module MCP end end - def validate_value_of_instrument_server_context!(instrument_server_context) - unless instrument_server_context.is_a?(TrueClass) || instrument_server_context.is_a?(FalseClass) - raise ArgumentError, "instrument_server_context must be a boolean" - end - end - def default_exception_reporter @default_exception_reporter ||= ->(exception, server_context) {} end diff --git a/lib/mcp/elicitation.rb b/lib/mcp/elicitation.rb deleted file mode 100644 index 26186c7..0000000 --- a/lib/mcp/elicitation.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module MCP - # Builders for elicitation `requestedSchema` definitions per MCP 2025-11-25. - # Each builder returns an instance whose `to_h` produces the JSON-Schema-shaped Hash - # a server passes as a property value in `create_form_elicitation(requested_schema:)`. - module Elicitation - autoload :EnumSchema, "mcp/elicitation/enum_schema" - end -end diff --git a/lib/mcp/elicitation/enum_schema.rb b/lib/mcp/elicitation/enum_schema.rb deleted file mode 100644 index 191a20f..0000000 --- a/lib/mcp/elicitation/enum_schema.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -module MCP - module Elicitation - # Builds the four enum schema variants defined by MCP 2025-11-25 (SEP-1330) plus the legacy `enumNames` form - # retained for backward compatibility. - # - # Each class method returns an `EnumSchema` instance; call `to_h` to get the property-value Hash and - # pass it through `requested_schema` to `ServerSession#create_form_elicitation`. - class EnumSchema - class << self - # Single-select with plain string values: `{ type: "string", enum: [...] }`. - def untitled_single_select(values:, default: nil, title: nil, description: nil) - validate_values!(values) - - new( - type: "string", - extras: { enum: values }, - default: default, - title: title, - description: description, - ) - end - - # Single-select with display titles per option: `{ type: "string", oneOf: [{ const, title }, ...] }`. - # `options` is an Array of `{ value:, title: }` hashes. - def titled_single_select(options:, default: nil, title: nil, description: nil) - validate_titled_options!(options) - - new( - type: "string", - extras: { oneOf: options.map { |o| { const: o[:value], title: o[:title] } } }, - default: default, - title: title, - description: description, - ) - end - - # Multi-select with plain string values: `{ type: "array", items: { type: "string", enum: [...] } }`. - def untitled_multi_select(values:, default: nil, title: nil, description: nil) - validate_values!(values) - - new( - type: "array", - extras: { items: { type: "string", enum: values } }, - default: default, - title: title, - description: description, - ) - end - - # Multi-select with display titles per option: `{ type: "array", items: { anyOf: [{ const, title }, ...] } }`. - def titled_multi_select(options:, default: nil, title: nil, description: nil) - validate_titled_options!(options) - - items_anyof = options.map { |o| { const: o[:value], title: o[:title] } } - new( - type: "array", - extras: { items: { anyOf: items_anyof } }, - default: default, - title: title, - description: description, - ) - end - - # Legacy single-select retained for backward compatibility with clients implementing - # the pre-SEP-1330 form: `{ enum, enumNames }`. - def legacy_titled(values:, value_titles:, default: nil, title: nil, description: nil) - validate_values!(values) - unless value_titles.is_a?(Array) && value_titles.length == values.length - raise ArgumentError, "value_titles must be an Array of the same length as values" - end - - new( - type: "string", - extras: { enum: values, enumNames: value_titles }, - default: default, - title: title, - description: description, - ) - end - - private - - def validate_values!(values) - unless values.is_a?(Array) && !values.empty? - raise ArgumentError, "values must be a non-empty Array" - end - end - - def validate_titled_options!(options) - unless options.is_a?(Array) && !options.empty? - raise ArgumentError, "options must be a non-empty Array of {value:, title:} hashes" - end - - options.each do |option| - unless option.is_a?(Hash) && option.key?(:value) && option.key?(:title) - raise ArgumentError, "each option must be a Hash with :value and :title keys" - end - end - end - end - - def initialize(type:, extras:, default: nil, title: nil, description: nil) - @type = type - @extras = extras - @default = default - @title = title - @description = description - end - - def to_h - hash = { type: @type }.merge(@extras) - hash[:title] = @title if @title - hash[:description] = @description if @description - hash[:default] = @default unless @default.nil? - hash - end - end - end -end diff --git a/lib/mcp/error_codes.rb b/lib/mcp/error_codes.rb index 603a7a9..09bb6fd 100644 --- a/lib/mcp/error_codes.rb +++ b/lib/mcp/error_codes.rb @@ -3,24 +3,18 @@ module MCP # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`. # - # All three constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): + # Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): + # `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not + # support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY` + # rejects a request that requires a client capability the request did not declare + # (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise + # these codes itself yet. # - # - `HEADER_MISMATCH` rejects an HTTP request whose headers do not match the corresponding body values, - # or whose required headers are missing or malformed (no `error.data`). It is reserved for - # the Streamable HTTP transport, since headers do not exist on stdio. - # - `MISSING_REQUIRED_CLIENT_CAPABILITY` rejects a request that requires a client capability - # the request did not declare (`error.data: { requiredCapabilities: {...} }`). - # Raised via `Server::MissingRequiredClientCapabilityError`. - # - `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server - # does not support (`error.data: { supported: [...], requested: "..." }`). Raised via - # `Server::UnsupportedProtocolVersionError`. - # - # The values come from the spec's MCP-specific error code block, which is allocated sequentially from `-32020` - # toward `-32099`. + # The values come from the spec's MCP-specific error code block, which is allocated sequentially from + # `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here. # # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 module ErrorCodes - HEADER_MISMATCH = -32020 MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 UNSUPPORTED_PROTOCOL_VERSION = -32022 end diff --git a/lib/mcp/icon.rb b/lib/mcp/icon.rb index 56ac6ad..3397c33 100644 --- a/lib/mcp/icon.rb +++ b/lib/mcp/icon.rb @@ -1,35 +1,13 @@ # frozen_string_literal: true module MCP - # An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type: - # https://modelcontextprotocol.io/specification/2026-07-28/basic#icons - # - # Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs, - # so an icon that would serialize into something a client rejects fails here instead. What a value means - # is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String` - # is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String` - # (the specification expects `WxH` or `"any"`). class Icon SUPPORTED_THEMES = ["light", "dark"].freeze attr_reader :mime_type, :sizes, :src, :theme - def initialize(mime_type: nil, sizes: nil, src:, theme: nil) - unless src.is_a?(String) && !src.empty? - raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})." - end - - unless mime_type.nil? || mime_type.is_a?(String) - raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})." - end - - if (problem = sizes_problem(sizes)) - raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})." - end - - unless theme.nil? || SUPPORTED_THEMES.include?(theme) - raise ArgumentError, 'The value of theme must specify "light" or "dark".' - end + def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil) + raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme) @mime_type = mime_type @sizes = sizes @@ -40,16 +18,5 @@ module MCP def to_h { mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact end - - private - - def sizes_problem(sizes) - return if sizes.nil? - return "got #{sizes.class}" unless sizes.is_a?(Array) - - index = sizes.index { |size| !size.is_a?(String) } - - "got #{sizes[index].class} inside the Array" if index - end end end diff --git a/lib/mcp/instrumentation.rb b/lib/mcp/instrumentation.rb index 5f1142f..9a1f434 100644 --- a/lib/mcp/instrumentation.rb +++ b/lib/mcp/instrumentation.rb @@ -7,12 +7,6 @@ module MCP begin @instrumentation_data = {} add_instrumentation_data(method: method) - # `self.` is required: the `server_context:` keyword above shadows the - # reader, and the value we want here is the user-defined hash passed to - # `Server.new`, not the per-call reporter context. - if configuration.instrument_server_context? && respond_to?(:server_context) - add_instrumentation_data(server_context: self.server_context) - end result = configuration.around_request.call(@instrumentation_data, &block) diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 60af708..d256ab1 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -7,10 +7,6 @@ module MCP LOGGING_SET_LEVEL = "logging/setLevel" # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575). SERVER_DISCOVER = "server/discover" - # Long-lived notification subscription stream (MCP 2026-07-28, SEP-2575), - # replacing the legacy HTTP GET listening stream. Served at the transport layer - # (Streamable HTTP modern path); transports without streaming support answer `-32601`. - SUBSCRIPTIONS_LISTEN = "subscriptions/listen" PROMPTS_GET = "prompts/get" PROMPTS_LIST = "prompts/list" @@ -25,20 +21,6 @@ module MCP TOOLS_CALL = "tools/call" TOOLS_LIST = "tools/list" - # RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575): - # `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`, - # `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource - # subscription pair by the connectionless model, which leaves nothing to keep alive - # or subscribe on. A modern-era request naming one of these answers with `-32601` - # Method not found (HTTP 404 on Streamable HTTP). - MODERN_REMOVED_METHODS = [ - INITIALIZE, - PING, - LOGGING_SET_LEVEL, - RESOURCES_SUBSCRIBE, - RESOURCES_UNSUBSCRIBE, - ].freeze - ROOTS_LIST = "roots/list" SAMPLING_CREATE_MESSAGE = "sampling/createMessage" ELICITATION_CREATE = "elicitation/create" @@ -54,9 +36,6 @@ module MCP NOTIFICATIONS_PROGRESS = "notifications/progress" NOTIFICATIONS_CANCELLED = "notifications/cancelled" NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete" - # First message on a `subscriptions/listen` stream (SEP-2575): reports the subset - # of requested notification types the server agreed to honor. - NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED = "notifications/subscriptions/acknowledged" class MissingRequiredCapabilityError < StandardError attr_reader :method diff --git a/lib/mcp/prompt.rb b/lib/mcp/prompt.rb index c2ed2db..598a3d0 100644 --- a/lib/mcp/prompt.rb +++ b/lib/mcp/prompt.rb @@ -111,15 +111,8 @@ module MCP missing = required_args - args.keys return if missing.empty? - # The explicit `error_code` maps a missing prompt argument to Invalid Params (-32602) rather - # than the default Internal Error (-32603); a missing required argument is client input, not a - # server fault. `error_type: :missing_required_arguments` keeps the descriptive message and - # instrumentation label. Mirrors `MCP::Server::ResourceNotFoundError`. raise MCP::Server::RequestHandlerError.new( - "Missing required arguments: #{missing.join(", ")}", - nil, - error_type: :missing_required_arguments, - error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments ) end diff --git a/lib/mcp/protocol_deprecations.rb b/lib/mcp/protocol_deprecations.rb deleted file mode 100644 index f79e727..0000000 --- a/lib/mcp/protocol_deprecations.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require_relative "configuration" - -module MCP - # Warning texts for the features SEP-2577 deprecates at 2026-07-28. - # - # Only the client emits these, from the modern connect, where the capabilities it declares are - # the ones being deprecated. No server-side trigger remains: the `initialize` handshake never lands on - # a deprecating revision and `Configuration` rejects a modern pin, so nothing on that side can reach - # a version these apply to. `LOGGING_MESSAGE` in particular has no caller left - # (a modern client declares no logging capability, and `notify_log_message` on that wire is - # the SEP-2575 sanctioned delivery path rather than a deprecated call). It stays public and - # callable for embedders, and because deleting it would be a breaking change for no gain. - module ProtocolDeprecations - extend self - - ROOTS_MESSAGE = - "MCP Roots (`roots/list` and `notifications/roots/list_changed`) is deprecated as of protocol version " \ - "2026-07-28 (SEP-2577). Use tool parameters, resource URIs, server configuration, or environment " \ - "variables instead." - SAMPLING_MESSAGE = - "MCP Sampling (`sampling/createMessage`) is deprecated as of protocol version 2026-07-28 (SEP-2577). " \ - "Use direct LLM provider APIs instead." - LOGGING_MESSAGE = - "MCP Logging (`logging/setLevel` and `notifications/message`) is deprecated as of protocol version " \ - "2026-07-28 (SEP-2577). Use stderr or OpenTelemetry instead." - - MESSAGES = { - roots: ROOTS_MESSAGE, - sampling: SAMPLING_MESSAGE, - logging: LOGGING_MESSAGE, - }.freeze - - def deprecated_roots_sampling_logging?(protocol_version) - return false unless protocol_version - - protocol_version >= Configuration::ROOTS_SAMPLING_LOGGING_DEPRECATED_PROTOCOL_VERSION - end - - def warn_for(feature, protocol_version:, uplevel: 1) - return unless deprecated_roots_sampling_logging?(protocol_version) - - Kernel.warn(MESSAGES.fetch(feature), uplevel: uplevel) - end - - def warn_for_client_capabilities(capabilities, protocol_version:, uplevel: 1) - return unless deprecated_roots_sampling_logging?(protocol_version) - return unless capabilities - - warn_for(:roots, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :roots) - warn_for(:sampling, protocol_version: protocol_version, uplevel: uplevel) if capability?(capabilities, :sampling) - end - - private - - def capability?(capabilities, key) - capabilities.key?(key) || capabilities.key?(key.to_s) - end - end -end diff --git a/lib/mcp/request_envelope.rb b/lib/mcp/request_envelope.rb deleted file mode 100644 index 3f1b19a..0000000 --- a/lib/mcp/request_envelope.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -module MCP - # The per-request `_meta` envelope of the stateless "modern" lifecycle (MCP 2026-07-28, SEP-2575). - # The modern lifecycle has no `initialize` handshake: every request identifies its protocol version - # and client capabilities through reserved `_meta` keys (plus an optional client identity), - # and the server validates each request independently. Servers MUST NOT infer capabilities from - # prior requests, which is why the envelope is a per-request value object rather than session state. - # - # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 - class RequestEnvelope - PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion" - CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo" - CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities" - - # Optional per-request log level, replacing the `logging/setLevel` RPC in the modern lifecycle. - # Deprecated as of 2026-07-28 (SEP-2577) but still part of the wire format. - LOG_LEVEL_META_KEY = "io.modelcontextprotocol/logLevel" - # Notification-side reserved key (SEP-2575): correlates a notification delivered on - # a `subscriptions/listen` stream (and the stream's closing result) with the JSON-RPC id of - # the `subscriptions/listen` request that opened it. Not part of the request envelope triple. - SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" - - # Result-side counterpart of the request envelope: the server's identity rides in - # the result's `_meta` as an optional stamp, not as a top-level field, since the SEP was - # finalized (spec PR modelcontextprotocol/modelcontextprotocol#3002). A server MAY omit it. - SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" - - # `clientInfo` is deliberately absent: it became optional after the SEP was finalized - # (spec PR modelcontextprotocol/modelcontextprotocol#3002), so servers MUST accept - # envelopes without it. The TypeScript and Python SDKs validate the same required pair. - REQUIRED_META_KEYS = [ - PROTOCOL_VERSION_META_KEY, - CLIENT_CAPABILITIES_META_KEY, - ].freeze - - class << self - # A request claims the modern lifecycle when its `_meta` carries `io.modelcontextprotocol/protocolVersion`, - # matching the TypeScript SDK's envelope claim and the Python SDK's `_has_modern_envelope`. - # Classification is deliberately looser than validation: a claimed-but-malformed envelope is - # rejected by {parse!} with `-32602` instead of silently flowing through the legacy path, - # while `_meta` without the claim key (`progressToken`, trace context) stays legacy. - def modern?(params) - meta = extract_meta(params) - return false unless meta.is_a?(Hash) - - !read(meta, PROTOCOL_VERSION_META_KEY).nil? - end - - # Parses and validates the envelope: `protocolVersion` and `clientCapabilities` are required, - # `clientInfo` is optional. A missing or mistyped field is Invalid params (`-32602`) naming - # the offending keys, the code and shape the spec mandates and the reference SDKs emit. - # `request` is only used to enrich the raised error; callers dispatching notifications can omit it. - def parse!(params, request: nil) - meta = extract_meta(params) - meta = {} unless meta.is_a?(Hash) - - protocol_version = read(meta, PROTOCOL_VERSION_META_KEY) - client_info = read(meta, CLIENT_INFO_META_KEY) - client_capabilities = read(meta, CLIENT_CAPABILITIES_META_KEY) - - invalid_keys = [] - invalid_keys << PROTOCOL_VERSION_META_KEY unless protocol_version.is_a?(String) - invalid_keys << CLIENT_CAPABILITIES_META_KEY unless client_capabilities.is_a?(Hash) - invalid_keys << CLIENT_INFO_META_KEY unless client_info.nil? || client_info.is_a?(Hash) - - unless invalid_keys.empty? - raise Server::RequestHandlerError.new( - "Invalid params: missing or invalid `#{invalid_keys.join("`, `")}` in `_meta`", - request, - error_type: :invalid_params, - error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, - ) - end - - unless Configuration.modern_protocol_version?(protocol_version) - raise Server::UnsupportedProtocolVersionError.new(protocol_version, request) - end - - new( - protocol_version: protocol_version, - client_info: client_info, - client_capabilities: client_capabilities, - log_level: read(meta, LOG_LEVEL_META_KEY), - ) - end - - private - - # `Server#handle` accepts hashes parsed with either symbol or string keys, so read both forms - # (the same tolerance as `Server#handle_cancelled_notification`). - def extract_meta(params) - return unless params.is_a?(Hash) - - meta = params[:_meta] - meta.nil? ? params["_meta"] : meta - end - - def read(meta, key) - value = meta[key.to_sym] - value.nil? ? meta[key] : value - end - end - - # `client_info` is `nil` when the client chose not to identify itself, which is legal: - # it is self-reported data and MUST NOT drive behavior or security decisions anyway. - attr_reader :protocol_version, :client_info, :client_capabilities, :log_level - - def initialize(protocol_version:, client_capabilities:, client_info: nil, log_level: nil) - @protocol_version = protocol_version - @client_info = client_info - @client_capabilities = client_capabilities - @log_level = log_level - freeze - end - end -end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 051a892..0f77af3 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "securerandom" - require_relative "../json_rpc_handler" require_relative "cancellation" require_relative "cancelled_error" @@ -9,13 +7,9 @@ require_relative "instrumentation" require_relative "methods" require_relative "logging_message_notification" require_relative "progress" -require_relative "protocol_deprecations" require_relative "server_context" require_relative "server/capabilities" -require_relative "server/input_required_result" require_relative "server/pagination" -require_relative "server/pending_response" -require_relative "server/request_state_security" require_relative "server/transports" module MCP @@ -65,43 +59,6 @@ module MCP end end - # Raised when a request carries a protocol version the server does not support under the stateless lifecycle of - # MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }` - # so the client can select a mutually supported version and retry. - # - # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 - class UnsupportedProtocolVersionError < RequestHandlerError - # No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash - # into keywords and fail with "unknown keywords". - def initialize(requested, request = nil) - super( - "Unsupported protocol version", - request, - error_type: :unsupported_protocol_version, - error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, - error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" }, - ) - end - end - - # Raised when processing a request requires a client capability the request did not declare in `_meta` - # (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities - # the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }` - # listing the missing capabilities. - # - # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 - class MissingRequiredClientCapabilityError < RequestHandlerError - def initialize(required_capabilities, request = nil) - super( - "Missing required client capability", - request, - error_type: :missing_required_client_capability, - error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, - error_data: { requiredCapabilities: required_capabilities }, - ) - end - end - # Raised when a requested resource URI does not exist. Per SEP-2164, # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) # with the requested URI in the error `data` member. Raise this from @@ -128,27 +85,6 @@ module MCP end end - # Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout. - # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources, - # and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this. - # These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them. - # - # Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic - # internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not - # spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for - # this condition, so a client that already recognizes it there reads the same meaning here. - # - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts - class RequestTimeoutError < RequestHandlerError - attr_reader :request_id, :timeout - - def initialize(message, request_id:, timeout:) - super(message, nil, error_type: :request_timeout, error_code: -32001) - @request_id = request_id - @timeout = timeout - end - end - class MethodAlreadyDefinedError < StandardError attr_reader :method_name @@ -169,20 +105,8 @@ module MCP # Allowed values for the SEP-2549 `cacheScope` cache hint. CACHE_SCOPES = ["public", "private"].freeze - # Methods whose results are cacheable per SEP-2549. - # On the modern wire (2026-07-28) the `ttlMs`/`cacheScope` hints are REQUIRED on these results, - # so unset hints get the spec defaults there; on stable protocol versions emission stays opt-in - # via `apply_cache_metadata`. - CACHEABLE_RESULT_METHODS = [ - Methods::TOOLS_LIST, - Methods::PROMPTS_LIST, - Methods::RESOURCES_LIST, - Methods::RESOURCES_TEMPLATES_LIST, - Methods::RESOURCES_READ, - ].freeze - attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification - attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security + attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope def initialize( description: nil, @@ -202,8 +126,6 @@ module MCP page_size: nil, ttl_ms: nil, cache_scope: nil, - request_state_security: nil, - input_required_legacy_shim: true, transport: nil ) @description = description @@ -219,21 +141,12 @@ module MCP @resources = resources @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) - @resources_list_handler = nil @server_context = server_context self.page_size = page_size self.ttl_ms = ttl_ms self.cache_scope = cache_scope - @request_state_security = request_state_security - - # Dual-era authoring (SEP-2322): on the legacy wire, an `input_required` result is fulfilled - # through real server-to-client requests and the handler re-runs, so handlers written - # in the 2026 style serve both eras. `false` restores the strict rejection of `input_required` - # on legacy requests. Matches the TypeScript SDK's default-on legacy shim. - @input_required_legacy_shim = input_required_legacy_shim @configuration = MCP.configuration.merge(configuration) @client = nil - @client_protocol_version = nil validate! @@ -427,22 +340,6 @@ module MCP @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block end - # Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as - # the authenticated principal or granted scope. The block returns the resource collection to serve; - # the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources, - # so the block returns only the array, not the paginated result. - # A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set, - # the constructor-provided `resources` array is served unchanged. - # - # The block is invoked once per page, so it must return a stable ordering across the pages of one logical query; - # the cursor is a positional offset into the returned collection. - # - # @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared. - # @yieldreturn [Array] The resources to paginate. - def resources_list_handler(&block) - @resources_list_handler = block - end - # Sets a custom handler for `resources/read` requests. # The block receives the parsed request params and should return resource # contents. The return value is set as the `contents` field of the response. @@ -463,25 +360,19 @@ module MCP end # Sets a custom handler for `resources/subscribe` requests. - # The block receives the parsed request params. The response is an empty result, except that - # a `_meta` hash the block returns is passed through - the spec defines no other member for this result, - # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data - # under `_meta`. + # The block receives the parsed request params. The return value is + # ignored; the response is always an empty result `{}` per the MCP specification. # # @yield [params] The request params containing `:uri`. - # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result. def resources_subscribe_handler(&block) @handlers[Methods::RESOURCES_SUBSCRIBE] = block end # Sets a custom handler for `resources/unsubscribe` requests. - # The block receives the parsed request params. The response is an empty result, except that - # a `_meta` hash the block returns is passed through - the spec defines no other member for this result, - # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data - # under `_meta`. + # The block receives the parsed request params. The return value is + # ignored; the response is always an empty result `{}` per the MCP specification. # # @yield [params] The request params containing `:uri`. - # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result. def resources_unsubscribe_handler(&block) @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block end @@ -619,47 +510,12 @@ module MCP return end - # SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found - # there regardless of the server's declared capabilities: in this era the method does not exist - # at all, which is why the check precedes `ensure_capability!`. - if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session) - raise RequestHandlerError.new( - "Method not found: #{method} is not part of the modern lifecycle (SEP-2575)", - request, - error_type: :method_not_found, - error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, - ) - end - - begin - Methods.ensure_capability!(method, capabilities) - rescue Methods::MissingRequiredCapabilityError => e - # Re-raise through RequestHandlerError, the one channel whose message is - # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError` - # no longer echoes exception messages into the error `data` member (CWE-209). - raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e) - end + Methods.ensure_capability!(method, capabilities) # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2), # so do not track it in the in-flight registry. - cancellation = nil - if related_request_id && method != Methods::INITIALIZE && session - cancellation = session.register_in_flight(related_request_id) - - # The spec puts the uniqueness obligation on the sender - "The request ID MUST NOT have been previously used by - # the requestor within the same session" - and says nothing about what a receiver does with a duplicate. - # Answering one is the only option that stays correct: the id routes request-scoped messages back to - # the request that caused them, and the transport's rule is that those messages "SHOULD relate to - # the originating client request", which a second live request under the same id makes impossible to honor - # for either of them. Refused the same way a duplicate `initialize` is, and for the same reason: - # so that a repeated id cannot silently displace state negotiated by the first one. - if cancellation.nil? - raise RequestHandlerError.new( - "Invalid Request: request id #{related_request_id.inspect} is already in flight", - request, - error_type: :invalid_request, - ) - end + cancellation = if related_request_id && method != Methods::INITIALIZE + session&.register_in_flight(related_request_id) end ->(params) { @@ -669,42 +525,25 @@ module MCP server_context: { request: request }, exception_already_reported: ->(e) { reported_exception.equal?(e) }, ) do - envelope = lift_request_envelope(params, method: method, session: session) - - # The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle: - # it authorizes `notifications/message` for this request only (SEP-2575). Without it, - # `ServerSession#notify_log_message` stays silent on modern-era sessions. An unrecognized level - # reads as absent, so delivery stays off (the safe direction), matching the Python SDK. - if envelope&.log_level && session.respond_to?(:configure_logging) - request_logging = LoggingMessageNotification.new(level: envelope.log_level) - session.configure_logging(request_logging) if request_logging.valid_level? - end - - params = unseal_request_state(params, method: method) if @request_state_security - result = case method when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) - - # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints. - contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) + build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) - handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) - - subscription_result(handler_result) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) + {} when Methods::TOOLS_CALL - call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) when Methods::PROMPTS_GET - get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) when Methods::COMPLETION_COMPLETE - complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation) when Methods::LOGGING_SET_LEVEL configure_logging_level(params, session: session) else - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) end client = session&.client || @client add_instrumentation_data(client: client) if client @@ -714,43 +553,6 @@ module MCP next JsonRpcHandler::NO_RESPONSE end - # Runs after the cancellation check so a cancelled request stays suppressed - # instead of turning into a gate error response. - if result.is_a?(InputRequiredResult) - result = if envelope.nil? && @input_required_legacy_shim && session - run_legacy_input_required_shim( - result, - method: method, - params: params, - session: session, - related_request_id: related_request_id, - cancellation: cancellation, - ) - else - serialize_input_required_result(result, envelope: envelope, request: params, method: method) - end - end - - # SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns; - # a missing value is only tolerated FROM earlier protocol versions. Results that already carry - # a discriminator keep it; everything else on the modern wire is the standard shape, - # `"complete"`. Legacy results stay unstamped: pre-2026 clients do not know the field. - if envelope && result.is_a?(Hash) && !result.key?(:resultType) - result = result.merge(resultType: ResultType::COMPLETE) - end - - # SEP-2549 makes the `ttlMs`/`cacheScope` hints REQUIRED on cacheable results at 2026-07-28, - # so unconfigured servers get `ttlMs: 0` (do not cache) and `cacheScope: "private"`: - # the spec names no default scope, and `"private"` is the side that cannot leak - # a user-dependent result through a shared cache, matching the TypeScript SDK's default - # and `server/discover`. Values already in the result win. - # Only complete results are cacheable: an SEP-2322 `input_required` round trip must not be stamped - # (the stamp above guarantees `resultType` is present on every modern Hash result by this point). - if envelope && result.is_a?(Hash) && CACHEABLE_RESULT_METHODS.include?(method) && - result[:resultType] == ResultType::COMPLETE && !(result.key?(:ttlMs) && result.key?(:cacheScope)) - result = { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) - end - result rescue CancelledError => e add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) @@ -767,267 +569,11 @@ module MCP reported_exception = wrapped raise wrapped ensure - # `cancellation` is non-nil exactly when this request claimed the id above, so this also keeps `initialize` - # (which never registers) from evicting an in-flight registration under a reused id when the duplicate-`initialize` - # refusal raises out of the handler. - session&.unregister_in_flight(related_request_id, cancellation: cancellation) if related_request_id && cancellation + session&.unregister_in_flight(related_request_id) if related_request_id end } end - # Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it. - # A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is - # the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds, - # which would otherwise let the first request of a connection reach a method the modern lifecycle does not have. - # The Python SDK gates the same way, on the envelope of each request rather than on connection state that - # is only settled afterwards. - # - # A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there - # with the lifecycle violation, which names the cause better than Method not found. - def modern_request?(request, session) - era = session.respond_to?(:era) ? session.era : nil - return true if era == :modern - return false unless era.nil? - - RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil) - end - - # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries - # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. - # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is - # pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules: - # on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake) - # and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as - # an invalid request because a connection can never change eras. - def lift_request_envelope(params, method:, session:) - return if Methods.notification?(method) - - era = session.respond_to?(:era) ? session.era : nil - - # Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities - # before any negotiation. Under the modern era every request carries the envelope, discovery included: - # the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575). - return if method == Methods::SERVER_DISCOVER && era != :modern - - if RequestEnvelope.modern?(params) - if era == :legacy - raise RequestHandlerError.new( - "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", - params, - error_type: :invalid_request, - ) - end - - RequestEnvelope.parse!(params, request: params) - elsif era == :modern - # A claim-less request on a modern session is a malformed envelope, not a malformed request: - # the spec maps missing required envelope fields to Invalid params (`-32602`), - # and the reference SDKs answer it naming the missing keys. - raise RequestHandlerError.new( - "Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`", - params, - error_type: :invalid_params, - error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, - ) - end - end - - # Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for - # whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle, - # so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as - # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on - # (or embed requests for) capabilities the client did not declare, and reports every missing capability at - # once so the client sees the full set. - def serialize_input_required_result(result, envelope:, request:, method:) - if envelope.nil? - raise RequestHandlerError.new( - "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)", - request, - error_type: :internal_error, - ) - end - - missing = result.missing_client_capabilities(envelope.client_capabilities) - raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty? - - add_instrumentation_data(input_required: true) - serialized = result.to_h - - if @request_state_security && serialized[:requestState] - serialized = serialized.merge(requestState: @request_state_security.seal( - serialized[:requestState], - method: method, - target: mrtr_target(request), - arguments_digest: mrtr_arguments_digest(request), - )) - end - - serialized - end - - # Methods whose results may be `input_required` and whose retried requests carry - # `inputResponses`/`requestState` (SEP-2322). - MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze - - # Fulfilment rounds the legacy shim runs before giving up, matching the TypeScript SDK's legacy shim default (`maxRounds: 8`). - LEGACY_INPUT_REQUIRED_MAX_ROUNDS = 8 - - # Dual-era authoring shim (SEP-2322): a handler on the legacy wire returned an `input_required` result, - # which pre-2026 clients cannot understand, so the server fulfills it in place of the client's driver. - # Every entry of `inputRequests` is sent as the equivalent real server-to-client request (associated with - # the originating request per SEP-2260), the answers are collected under the same keys, and the handler - # re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay - # contract the modern client driver follows. The `requestState` round-trips in-process as the raw value - # the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply. - def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:) - rounds = 0 - - loop do - missing = result.missing_client_capabilities(session.client_capabilities) - unless missing.empty? - # The explicit `error_code` keeps the descriptive message in the JSON-RPC error response - # (the `ResourceNotFoundError` pattern). `-32021` is a 2026-07-28 code, so the legacy wire - # gets a plain internal error. - raise RequestHandlerError.new( - "input_required requires client capabilities the client did not declare: #{missing.to_json}", - params, - error_type: :internal_error, - error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, - ) - end - - responses = (result.input_requests || {}).each_with_object({}) do |(key, entry), collected| - collected[key] = session.fulfill_input_request( - entry[:method], - legacy_leg_params(entry), - related_request_id: related_request_id, - ) - end - - retry_params = params.reject { |key, _| [:inputResponses, :requestState].include?(key.to_sym) } - retry_params[:inputResponses] = responses unless responses.empty? - retry_params[:requestState] = result.request_state if result.request_state - - result = redispatch_mrtr_method( - method, - retry_params, - session: session, - related_request_id: related_request_id, - cancellation: cancellation, - ) - return result unless result.is_a?(InputRequiredResult) - - rounds += 1 - next if rounds < LEGACY_INPUT_REQUIRED_MAX_ROUNDS - - raise RequestHandlerError.new( - "Handler still returned `input_required` after #{LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds (SEP-2322)", - params, - error_type: :internal_error, - error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, - ) - end - end - - # The params an embedded entry sends on its legacy leg. Entries are forwarded verbatim with - # one exception: the 2025-11-25 wire requires `elicitationId` on URL-mode elicitation requests, - # a field the 2026-07-28 in-band shape dropped (correlation rides `requestState` there), - # so a missing one is synthesized for the leg, matching the TypeScript SDK's shim. - def legacy_leg_params(entry) - params = entry[:params] - return params unless entry[:method] == Methods::ELICITATION_CREATE - return params if params.nil? || (params[:mode] || params["mode"]) != "url" - return params if params.key?(:elicitationId) || params.key?("elicitationId") - - params.merge(elicitationId: SecureRandom.uuid) - end - - # Re-runs the handler of one of the three MRTR-capable methods for - # the legacy shim. Legacy wire, so no envelope is threaded. - def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:) - case method - when Methods::TOOLS_CALL - call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) - when Methods::PROMPTS_GET - get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) - when Methods::RESOURCES_READ - contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) - contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) - else - raise RequestHandlerError.new( - "input_required results are only supported for #{MRTR_METHODS.join(", ")}", - params, - error_type: :internal_error, - ) - end - end - - # Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch, - # so handlers always read the state they wrote. A tampered, expired, or cross-request token is - # rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior. - def unseal_request_state(params, method:) - return params unless MRTR_METHODS.include?(method) - return params unless params.is_a?(Hash) - - sealed = params[:requestState] || params["requestState"] - return params unless sealed - - plaintext = @request_state_security.unseal( - sealed, - method: method, - target: mrtr_target(params), - arguments_digest: mrtr_arguments_digest(params), - ) - key = params.key?("requestState") ? "requestState" : :requestState - params.merge(key => plaintext) - rescue RequestStateSecurity::InvalidStateError => e - raise RequestHandlerError.new( - "Invalid or expired requestState", - params, - error_type: :invalid_params, - error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, - original_error: e, - ) - end - - def mrtr_target(params) - return "" unless params.is_a?(Hash) - - params[:name] || params["name"] || params[:uri] || params["uri"] || "" - end - - # Digest of the originating arguments, binding a sealed state to retries of - # the same call with the same inputs. Keys are stringified and sorted recursively - # so symbol/string parses of identical JSON digest identically. - def mrtr_arguments_digest(params) - arguments = params.is_a?(Hash) ? params[:arguments] || params["arguments"] : nil - OpenSSL::Digest::SHA256.hexdigest(canonical_json(arguments || {})) - end - - def canonical_json(value) - case value - when Hash - pairs = value.map { |key, nested| [key.to_s, nested] }.sort_by(&:first) - "{#{pairs.map { |key, nested| "#{key.to_json}:#{canonical_json(nested)}" }.join(",")}}" - when Array - "[#{value.map { |element| canonical_json(element) }.join(",")}]" - else - value.to_json - end - end - - # Extracts the SEP-2322 retry fields a client sends when re-issuing a request: - # `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`. - # They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries. - def mrtr_retry_fields(params) - return { input_responses: nil, request_state: nil } unless params.is_a?(Hash) - - { - input_responses: params[:inputResponses] || params["inputResponses"], - request_state: params[:requestState] || params["requestState"], - } - end - def handle_cancelled_notification(params, session: nil) return unless session return unless params.is_a?(Hash) @@ -1077,19 +623,10 @@ module MCP end protocol_version = params[:protocolVersion] - # Per the SEP-2575 era model, `initialize` negotiates legacy protocol versions only: a modern version is - # defined by carrying its own version on every request in `_meta`, with no handshake, - # so asking `initialize` for one (or for a version this server does not know) is answered with - # a counter-offer instead of an echo, matching the TypeScript and Python SDKs. Modern versions - # stay reachable through `server/discover` and the per-request envelope. The counter-offer is - # a configured `protocol_version` pin when one is set (`Configuration` only accepts handshake versions there), - # and the latest handshake version otherwise. - negotiated_version = if Configuration.handshake_protocol_version?(protocol_version) + negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version) protocol_version - elsif configuration.protocol_version? - configuration.protocol_version else - Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION + configuration.protocol_version end info = server_info.reject do |property| @@ -1103,11 +640,7 @@ module MCP response_instructions = nil end - if session - session.mark_initialized!(protocol_version: negotiated_version) - else - @client_protocol_version = negotiated_version - end + session&.mark_initialized! { protocolVersion: negotiated_version, @@ -1123,18 +656,6 @@ module MCP end end - # The `resources/subscribe` and `resources/unsubscribe` result is an empty object except for the optional `_meta` - # every result may carry: the TypeScript SDK validates it against `EmptyResultSchema.strict()`, - # which rejects any other member, so only `_meta` is passed through from the handler. A handler that returns - # anything else keeps the empty `{}` result it had before, so returning a subscription identifier or - # other advisory data means nesting it under `_meta`. - def subscription_result(handler_result) - return {} unless handler_result.is_a?(Hash) - - meta = handler_result[:_meta] || handler_result["_meta"] - meta.is_a?(Hash) ? { _meta: meta } : {} - end - def validate_initialize_params!(params) unless params.is_a?(Hash) raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params) @@ -1154,47 +675,20 @@ module MCP end end - # Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery. + # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery. # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark # the session initialized, and responds regardless of capability declarations or initialization state, - # so clients can probe a server before (or instead of) `initialize`. - # - # `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: - # legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope` - # cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results), - # so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only) - # fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides - # in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized - # spec (PR #3002), unfiltered because discovery happens before version negotiation. + # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered + # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints + # are not included here yet. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 def discover(_request) { - supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, - capabilities: discover_capabilities, + supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, + capabilities: capabilities, + serverInfo: server_info, instructions: instructions, - _meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info }, - }.compact.merge( - ttlMs: ttl_ms || 0, - cacheScope: cache_scope || "private", - # `server/discover` is exempt from the `_meta` envelope, so the central modern-result stamping does not see it; - # `DiscoverResult` is still a 2026-07-28 result and carries the REQUIRED `resultType` directly. - resultType: ResultType::COMPLETE, - ) - end - - # Capabilities as advertised by `server/discover`. In the modern lifecycle, `listChanged` and `subscribe` flags - # promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC - # (e.g. stdio), matching the Python SDK's era-aware capability derivation. - def discover_capabilities - return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen? - - capabilities.each_with_object({}) do |(name, value), stripped| - stripped[name] = if value.is_a?(Hash) - value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) } - else - value - end - end + }.compact end def configure_logging_level(request, session: nil) @@ -1219,7 +713,7 @@ module MCP apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact) end - def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) tool_name = request[:name] tool = tools[tool_name] @@ -1251,38 +745,18 @@ module MCP progress_token = request.dig(:_meta, :progressToken) - response = call_tool_with_args( - tool, - arguments, - server_context_with_meta(request), - progress_token: progress_token, - session: session, - related_request_id: related_request_id, - cancellation: cancellation, - envelope: envelope, - retry_fields: mrtr_retry_fields(request), + result = call_tool_with_args( + tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation ) - # An SEP-2322 `input_required` result is not a tool result: output schema - # validation would run against a `nil` `structuredContent` and the structured - # content fallback does not apply. The dispatch lambda serializes it. - return response if response.is_a?(InputRequiredResult) - - result = response.to_h validate_tool_call_result!(tool, result) - serialize_structured_content_fallback( - result, - content_provided: response.respond_to?(:content_provided?) && response.content_provided?, - ) + serialize_structured_content_fallback(result) rescue RequestHandlerError, CancelledError # CancelledError is intentionally not wrapped so `handle_request` can turn it into # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. raise rescue => e - # `e.message` is deliberately not included: it can carry internals (class, method - # and host names) that must not reach untrusted clients (CWE-209). The original - # exception still reaches `configuration.exception_reporter` via `original_error`. raise RequestHandlerError.new( - "Internal error calling tool #{tool_name}", + "Internal error calling tool #{tool_name}: #{e.message}", request, error_type: :internal_error, original_error: e, @@ -1295,21 +769,12 @@ module MCP apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact) end - def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt add_instrumentation_data(error: :prompt_not_found) - # The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than - # the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and - # `completion/complete` siblings for the same not-found condition, while `error_type: - # :prompt_not_found` keeps the descriptive message and instrumentation label. - raise RequestHandlerError.new( - "Prompt not found #{prompt_name}", - request, - error_type: :prompt_not_found, - error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, - ) + raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found) end add_instrumentation_data(prompt_name: prompt_name) @@ -1322,34 +787,17 @@ module MCP session: session, related_request_id: related_request_id, cancellation: cancellation, - envelope: envelope, ) call_prompt_template_with_args(prompt, prompt_args, server_context) end - def list_resources(request, server_context: nil) - resources = if @resources_list_handler - invoke_resources_list_handler(request, server_context) - else - @resources - end - - page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) + def list_resources(request) + page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h) apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact) end - # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in - # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies). - def invoke_resources_list_handler(request, server_context) - if handler_declares_server_context?(@resources_list_handler) - @resources_list_handler.call(request, server_context: server_context) - else - @resources_list_handler.call(request) - end - end - # Default `resources/read` handler: routes to class-based resources and resource templates. # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered, # unknown URIs keep the historical no-op `[]` response instead of raising. @@ -1423,19 +871,18 @@ module MCP end # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added - # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, - # in which case the missing one is filled with `ttlMs: 0` (do not cache) or `cacheScope: "private"`, - # the side that cannot leak a user-dependent result through a shared cache (the TypeScript SDK's default). + # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in + # which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`). # Values already in the result win, enabling per-result overrides. # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549 def apply_cache_metadata(result) explicit = result.key?(:ttlMs) || result.key?(:cacheScope) return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit - { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "private" }.merge(result) + { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result) end - def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def complete(params, session: nil, related_request_id: nil, cancellation: nil) validate_completion_params!(params) result = dispatch_optional_context_handler( @@ -1444,7 +891,6 @@ module MCP session: session, related_request_id: related_request_id, cancellation: cancellation, - envelope: envelope, ) normalize_completion_result(result) @@ -1453,14 +899,13 @@ module MCP # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`, # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or # `server_context.raise_if_cancelled!`. - def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil) dispatch_optional_context_handler( @handlers[Methods::RESOURCES_READ], request, session: session, related_request_id: related_request_id, cancellation: cancellation, - envelope: envelope, ) end @@ -1468,7 +913,7 @@ module MCP # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`. # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:` # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing. - def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil) return handler.call(params) unless handler_declares_server_context?(handler) server_context = build_server_context( @@ -1476,7 +921,6 @@ module MCP session: session, related_request_id: related_request_id, cancellation: cancellation, - envelope: envelope, ) handler.call(params, server_context: server_context) end @@ -1501,20 +945,16 @@ module MCP # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers # (progress, cancellation, nested server-to-client requests). - def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil) + def build_server_context(request:, session:, related_request_id:, cancellation:) meta_source = request.is_a?(Hash) ? request : {} progress_token = meta_source.dig(:_meta, :progressToken) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) - retry_fields = mrtr_retry_fields(meta_source) ServerContext.new( server_context_with_meta(meta_source), progress: progress, notification_target: session, related_request_id: related_request_id, cancellation: cancellation, - envelope: envelope, - input_responses: retry_fields[:input_responses], - request_state: retry_fields[:request_state], ) end @@ -1546,18 +986,14 @@ module MCP # Per SEP-2106, `structuredContent` may be any JSON value, not only an object. # Clients on older protocol versions may only read `content`, - # so when a tool returns non-object structured content without explicitly - # providing `content`, mirror the value into serialized JSON text. - def serialize_structured_content_fallback(result, content_provided: false) + # so when a tool returns non-object structured content without providing + # any content blocks, mirror the value into `content` as serialized JSON text. + def serialize_structured_content_fallback(result) structured = result[:structuredContent] return result if structured.nil? || structured.is_a?(Hash) - return result if content_provided return result unless result[:content].nil? || result[:content].empty? - serialized = JSON.generate(structured) - return result if JSON.parse(serialized).is_a?(Hash) - - result.merge(content: [{ type: "text", text: serialized }]) + result.merge(content: [{ type: "text", text: JSON.generate(structured) }]) end # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. @@ -1574,11 +1010,11 @@ module MCP end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil) # 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/server/tools.md ("Tool argument keys"). + # See docs/building-servers.md ("Tool argument keys"). args = arguments&.transform_keys(&:to_sym) || {} if accepts_server_context?(tool.method(:call)) @@ -1589,24 +1025,19 @@ module MCP notification_target: session, related_request_id: related_request_id, cancellation: cancellation, - envelope: envelope, - input_responses: retry_fields&.fetch(:input_responses, nil), - request_state: retry_fields&.fetch(:request_state, nil), ) - tool.call(**args, server_context: server_context) + tool.call(**args, server_context: server_context).to_h else - tool.call(**args) + tool.call(**args).to_h end end def call_prompt_template_with_args(prompt, args, server_context) - raw_result = if accepts_server_context?(prompt.method(:template)) - prompt.template(args, server_context: server_context) + if accepts_server_context?(prompt.method(:template)) + prompt.template(args, server_context: server_context).to_h else - prompt.template(args) + prompt.template(args).to_h end - - raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h end def server_context_with_meta(request) diff --git a/lib/mcp/server/input_required_result.rb b/lib/mcp/server/input_required_result.rb deleted file mode 100644 index 16242e7..0000000 --- a/lib/mcp/server/input_required_result.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -require_relative "../methods" -require_relative "../result_type" - -module MCP - class Server - # A multi round-trip `input_required` result (SEP-2322, MCP 2026-07-28). - # Handlers for `tools/call`, `prompts/get`, and `resources/read` may return one instead of - # their normal result to ask the client for additional input: `input_requests` maps server-assigned keys - # to embedded request shapes (`elicitation/create`, `sampling/createMessage`, or `roots/list`), - # and `request_state` is an opaque continuation string the client echoes back byte-exactly when it retries - # the original request with `inputResponses` under the same keys. - # - # The server holds no memory between rounds: handlers re-run from the start on every retry and read - # the answers via `server_context.input_responses` / `server_context.request_state` - # (deterministic replay, matching the Python SDK). At least one of the two fields must be present; - # a `request_state`-only result is the load-shedding form ("retry later"). - # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 - class InputRequiredResult - EMBEDDABLE_METHODS = [ - Methods::ELICITATION_CREATE, - Methods::SAMPLING_CREATE_MESSAGE, - Methods::ROOTS_LIST, - ].freeze - - attr_reader :input_requests, :request_state - - def initialize(input_requests: nil, request_state: nil) - if (input_requests.nil? || input_requests.empty?) && request_state.nil? - raise ArgumentError, "at least one of input_requests or request_state is required" - end - unless request_state.nil? || request_state.is_a?(String) - raise ArgumentError, "request_state must be a String" - end - - @input_requests = normalize_input_requests(input_requests) - @request_state = request_state - freeze - end - - def to_h - serialized_requests = @input_requests&.transform_values do |entry| - { method: entry[:method], params: entry[:params] }.compact - end - - { - resultType: ResultType::INPUT_REQUIRED, - inputRequests: serialized_requests, - requestState: @request_state, - }.compact - end - - # The client capabilities required to fulfill every embedded request, merged into one nested hash. - # The mapping matches the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`: - # `elicitation/create` with `mode: "url"` requires `elicitation.url`, any other `elicitation/create` - # requires `elicitation.form`, `sampling/createMessage` with `tools`/`toolChoice` requires `sampling.tools` - # (plain sampling otherwise), and `roots/list` requires `roots`. - def required_client_capabilities - (@input_requests || {}).values.reduce({}) do |merged, entry| - deep_merge(merged, entry_capability(entry)) - end - end - - # The subset of {#required_client_capabilities} the request did not declare. - # Per SEP-2575, servers MUST NOT rely on capabilities the client has not declared, - # so a non-empty return means the result must not be sent (`-32021`). - def missing_client_capabilities(declared) - missing = missing_subtree(required_client_capabilities, declared) - prune_implied_form_elicitation(missing, declared) - end - - private - - def normalize_input_requests(input_requests) - return if input_requests.nil? - - raise ArgumentError, "input_requests must be a Hash" unless input_requests.is_a?(Hash) - - normalized = input_requests.each_with_object({}) do |(key, entry), result| - raise ArgumentError, "input_requests entries must be Hashes" unless entry.is_a?(Hash) - - method = entry[:method] || entry["method"] - unless EMBEDDABLE_METHODS.include?(method) - raise ArgumentError, - "input_requests entry #{key.inspect} must have a method of " \ - "#{EMBEDDABLE_METHODS.join(", ")} (got #{method.inspect})" - end - - params = entry[:params] || entry["params"] - raise ArgumentError, "input_requests entry params must be a Hash" unless params.nil? || params.is_a?(Hash) - - result[key.to_s] = { method: method, params: params }.compact.freeze - end - - normalized.freeze - end - - def entry_capability(entry) - params = entry[:params] - - case entry[:method] - when Methods::ELICITATION_CREATE - mode = params && (params[:mode] || params["mode"]) - mode == "url" ? { elicitation: { url: {} } } : { elicitation: { form: {} } } - when Methods::SAMPLING_CREATE_MESSAGE - with_tools = params && (params.key?(:tools) || params.key?("tools") || - params.key?(:toolChoice) || params.key?("toolChoice")) - with_tools ? { sampling: { tools: {} } } : { sampling: {} } - when Methods::ROOTS_LIST - { roots: {} } - end - end - - # Walks `required` and keeps only the branches absent from `declared` - # (symbol/string tolerant on the declared side). - def missing_subtree(required, declared) - required.each_with_object({}) do |(name, nested), missing| - declared_value = read_key(declared, name) - - if declared_value.nil? - missing[name] = nested - elsif nested.is_a?(Hash) && !nested.empty? - nested_missing = missing_subtree(nested, declared_value) - missing[name] = nested_missing unless nested_missing.empty? - end - end - end - - # 2025 back-compat implication shared with the TypeScript and Python SDKs: - # a bare `elicitation: {}` declaration implies form elicitation support, while - # an explicit url-only declaration does not. - def prune_implied_form_elicitation(missing, declared) - elicitation_missing = missing[:elicitation] - return missing unless elicitation_missing.is_a?(Hash) && elicitation_missing.key?(:form) - - declared_elicitation = read_key(declared, :elicitation) - return missing unless declared_elicitation.is_a?(Hash) - return missing unless read_key(declared_elicitation, :url).nil? - - pruned = elicitation_missing.reject { |key, _| key == :form } - pruned.empty? ? missing.reject { |key, _| key == :elicitation } : missing.merge(elicitation: pruned) - end - - def read_key(hash, key) - return unless hash.is_a?(Hash) - - value = hash[key.to_sym] - value.nil? ? hash[key.to_s] : value - end - - def deep_merge(left, right) - left.merge(right) do |_key, left_value, right_value| - if left_value.is_a?(Hash) && right_value.is_a?(Hash) - deep_merge(left_value, right_value) - else - right_value - end - end - end - end - end -end diff --git a/lib/mcp/server/pending_response.rb b/lib/mcp/server/pending_response.rb deleted file mode 100644 index a82ae07..0000000 --- a/lib/mcp/server/pending_response.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -module MCP - class Server - # A one-shot, timeout-aware handoff between the thread awaiting a server-to-client response - # and whichever thread resolves it (the client's response, a cancellation, or session teardown). - # - # `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later, and this gem supports 2.7, - # so the wait is expressed with a `ConditionVariable`. The `push`/`pop` names mirror the `Queue` - # this replaces, keeping the resolving call sites unchanged. - # - # First writer wins: a second `push` is ignored, so a cancellation that races a real response - # cannot overwrite it. `pop` returns the pushed value, or the `on_timeout` result when - # the deadline passes with nothing pushed. - class PendingResponse - def initialize - @mutex = Mutex.new - @condition = ConditionVariable.new - @delivered = false - @value = nil - end - - # Resolves the wait. Ignored when a value was already delivered. - def push(value) - @mutex.synchronize do - next if @delivered - - @delivered = true - @value = value - @condition.broadcast - end - end - - # Blocks until a value is pushed or `timeout` seconds elapse, and yields to the caller - # on expiry so it can decide what a timeout means. `ConditionVariable#wait` can return spuriously, - # so the deadline is re-checked against the monotonic clock. - # - # The expiry block runs after the lock is released: it typically cancels the request, - # which resolves this same object, and Ruby's `Mutex` is not reentrant. - def pop(timeout:) - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout - expired = false - - value = @mutex.synchronize do - until @delivered - remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) - if remaining <= 0 - expired = true - break - end - - @condition.wait(@mutex, remaining) - end - - @value - end - - expired ? yield : value - end - end - end -end diff --git a/lib/mcp/server/request_state_security.rb b/lib/mcp/server/request_state_security.rb deleted file mode 100644 index bc96a85..0000000 --- a/lib/mcp/server/request_state_security.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -require "json" -require "openssl" - -module MCP - class Server - # Opt-in protection for the SEP-2322 `requestState` echo. The opaque continuation string leaves the server, - # sits in the client's hands, and comes back as client-controlled input, so it must be treated like - # any other untrusted data. Sealing encrypts the state with AES-256-GCM (clients cannot read it) and binds - # a claims envelope that unsealing verifies fail-closed: - # - # - `exp`: a TTL window (re-sealed each round) - # - `m` / `t`: the originating method and target (tool/prompt name or resource URI) - # - `a`: a digest of the originating arguments, so the state only resumes the same call with the same inputs - # - `aud`: an optional audience, so tokens cannot cross servers sharing a key - # - # Pass an instance via `Server.new(request_state_security:)` and the seal/unseal happens transparently; - # handlers keep reading plaintext through `server_context.request_state`. Without it, the state crosses - # the wire exactly as the handler wrote it (the author's responsibility, matching the Python SDK's low-level Server). - # The key must be shared across workers in multi-process - # - # deployments; a per-process random key makes retries that land on another worker fail with an invalid-state error, - # forcing clients to restart the flow. - # - # The token format is `v1.`, with the version prefix bound as GCM associated data, - # following the Python SDK's `AESGCMRequestStateCodec`. - class RequestStateSecurity - class InvalidStateError < StandardError; end - - VERSION_PREFIX = "v1." - KEY_BYTES = 32 - IV_BYTES = 12 - TAG_BYTES = 16 - DEFAULT_TTL = 300 - - def initialize(key:, ttl: DEFAULT_TTL, audience: nil) - unless key.is_a?(String) && key.bytesize == KEY_BYTES - raise ArgumentError, "key must be a #{KEY_BYTES}-byte String" - end - unless ttl.is_a?(Numeric) && ttl.positive? - raise ArgumentError, "ttl must be a positive number of seconds" - end - - @key = key.dup.force_encoding(Encoding::BINARY).freeze - @ttl = ttl - @audience = audience - end - - # Seals a plaintext state into an opaque token bound to the originating request. - def seal(state, method:, target:, arguments_digest:) - claims = { - v: 1, - exp: Time.now.to_i + @ttl, - m: method, - t: target, - a: arguments_digest, - aud: @audience, - s: state, - }.compact - - cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt - cipher.key = @key - iv = cipher.random_iv - cipher.auth_data = VERSION_PREFIX - ciphertext = cipher.update(JSON.generate(claims)) + cipher.final - - VERSION_PREFIX + base64url_encode(iv + ciphertext + cipher.auth_tag) - end - - # Unseals a client-echoed token and verifies every claim, failing closed with - # `InvalidStateError` on tampering, expiry, or a claims mismatch. - def unseal(sealed, method:, target:, arguments_digest:) - unless sealed.is_a?(String) && sealed.start_with?(VERSION_PREFIX) - raise InvalidStateError, "malformed token" - end - - blob = base64url_decode(sealed.delete_prefix(VERSION_PREFIX)) - raise InvalidStateError, "malformed token" if blob.bytesize < IV_BYTES + TAG_BYTES - - iv = blob.byteslice(0, IV_BYTES) - tag = blob.byteslice(-TAG_BYTES, TAG_BYTES) - ciphertext = blob.byteslice(IV_BYTES, blob.bytesize - IV_BYTES - TAG_BYTES) - - cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt - cipher.key = @key - cipher.iv = iv - cipher.auth_tag = tag - cipher.auth_data = VERSION_PREFIX - plaintext = begin - cipher.update(ciphertext) + cipher.final - rescue OpenSSL::Cipher::CipherError - raise InvalidStateError, "authentication failed" - end - - claims = begin - JSON.parse(plaintext, symbolize_names: true) - rescue JSON::ParserError - raise InvalidStateError, "malformed claims" - end - - verify!(claims, method: method, target: target, arguments_digest: arguments_digest) - claims[:s] - end - - private - - def verify!(claims, method:, target:, arguments_digest:) - raise InvalidStateError, "unsupported version" unless claims[:v] == 1 - raise InvalidStateError, "expired" unless claims[:exp].is_a?(Integer) && Time.now.to_i <= claims[:exp] - raise InvalidStateError, "method mismatch" unless claims[:m] == method - raise InvalidStateError, "target mismatch" unless claims[:t] == target - raise InvalidStateError, "arguments mismatch" unless claims[:a] == arguments_digest - raise InvalidStateError, "audience mismatch" unless claims[:aud] == @audience - raise InvalidStateError, "missing state" unless claims[:s].is_a?(String) - end - - def base64url_encode(data) - [data].pack("m0").tr("+/", "-_").delete("=") - end - - def base64url_decode(encoded) - padded = encoded.tr("-_", "+/") - padded += "=" * ((4 - padded.length % 4) % 4) - padded.unpack1("m0") - rescue ArgumentError - raise InvalidStateError, "malformed token" - end - end - end -end diff --git a/lib/mcp/server/transports/stdio_transport.rb b/lib/mcp/server/transports/stdio_transport.rb index 8c75812..ec2bd6d 100644 --- a/lib/mcp/server/transports/stdio_transport.rb +++ b/lib/mcp/server/transports/stdio_transport.rb @@ -49,9 +49,7 @@ module MCP end break if line.nil? - line = line.strip - parsed = parse_line(line) - response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line) + response = @session.handle_json(line.strip) send_response(response) if response end rescue Interrupt @@ -92,12 +90,6 @@ module MCP # cancellation has very limited value here regardless; servers that need cancellation propagation for nested # server-to-client requests should use `StreamableHTTPTransport`. def send_request(method, params = nil) - # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; - # multi round-trip `input_required` results (SEP-2322) replace them. - if @session && @session.era == :modern - raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." - end - request_id = generate_request_id request = { jsonrpc: "2.0", id: request_id, method: method } request[:params] = params if params @@ -124,7 +116,7 @@ module MCP return parsed[:result] else - response = @session ? dispatch_with_era(parsed) : @server.handle(parsed) + response = @session ? @session.handle(parsed) : @server.handle(parsed) send_response(response) if response end end @@ -151,35 +143,6 @@ module MCP line end - - # Parses a frame once so era classification can inspect its method and `_meta`. - # Returns `nil` for frames that are not JSON objects; those fall back to - # `ServerSession#handle_json` so protocol-level error responses stay identical. - def parse_line(line) - parsed = JSON.parse(line, symbolize_names: true) - parsed.is_a?(Hash) ? parsed : nil - rescue JSON::ParserError - nil - end - - # Serves one frame under the dual-era model (SEP-2575): the first era-distinctive message to succeed locks - # the connection era. A successful `initialize` locks `:legacy` inside `Server#init`; a successful `server/discover` - # or a successful request carrying the full modern `_meta` triple locks `:modern`. Era-violating frames - # (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock) - # are rejected in-band by `Server#lift_request_envelope`. - def dispatch_with_era(parsed) - response = @session.handle(parsed) - lock_modern_era_on_success(parsed, response) - response - end - - def lock_modern_era_on_success(parsed, response) - return if @session.era - return if !response.is_a?(Hash) || response.key?(:error) - return if parsed[:method] != Methods::SERVER_DISCOVER && !RequestEnvelope.modern?(parsed[:params]) - - @session.lock_era!(:modern) - end end end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index b406d6d..3c87dc9 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "json" -require_relative "../../result_type" require_relative "../../transport" # This file is autoloaded only when `StreamableHTTPTransport` is referenced, @@ -19,16 +18,10 @@ module MCP class StreamableHTTPTransport < Transport class InvalidJsonError < StandardError; end - # `x-accel-buffering: no` tells reverse proxies (nginx and friends) not to buffer the response, - # which the spec asks of every SSE stream: a buffering proxy holds events back instead of - # delivering them as they are written, and on a long-lived `subscriptions/listen` stream that - # also swallows the keepalive frames a dropped peer would otherwise be detected by. - # The TypeScript and Python SDKs send it on their SSE responses for the same reason. SSE_HEADERS = { "content-type" => "text/event-stream", "cache-control" => "no-cache", "connection" => "keep-alive", - "x-accel-buffering" => "no", }.freeze # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE, @@ -42,31 +35,10 @@ module MCP DEFAULT_SESSION_IDLE_TIMEOUT = 1800 DEFAULT_MAX_SESSIONS = 10_000 - # Cap on concurrent `subscriptions/listen` streams (SEP-2575). Each stream holds an open SSE connection - # for its lifetime, so without a bound an unauthenticated client can retain unbounded connections, - # like the session-flood case `DEFAULT_MAX_SESSIONS` guards. A listen request past the cap is rejected with HTTP 503; - # pass `max_listen_subscriptions: nil` to opt out. - DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000 - # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry). UNSET_IDLE_TIMEOUT = Object.new.freeze private_constant :UNSET_IDLE_TIMEOUT - # Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`). - # The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust - # the sender's resources; without one, a client that opens a session and simply never replies parks - # a worker thread for good. - # - # Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds - # for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default - # is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation - # prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds - # nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler - # that legitimately waits longer passes `timeout:`. - # - # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts - DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600 - # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole # body into memory and parses it, so without a cap a single unauthenticated POST # can allocate gigabytes and OOM the worker. 4 MiB comfortably @@ -79,20 +51,6 @@ module MCP # the stack or amplify parse cost (complements the byte cap). MAX_JSON_NESTING = 64 - # Cap on the notifications buffered for one modern request (SEP-2575). The sink holds them - # in memory until the handler returns, so a handler that emits notifications in proportion to - # client-supplied input would otherwise let one request grow memory without bound. - # Notifications past the cap are not delivered and the notify helpers report `false`, - # the same non-delivery degradation they have on every other undeliverable path. - MAX_MODERN_REQUEST_NOTIFICATIONS = 1_000 - - # Interval in seconds between SSE keepalive comment frames on a `subscriptions/listen` stream. - # Without them a silently dropped connection holds its slot until the next fan-out write fails, - # so on a quiet server a dead peer would occupy a `max_listen_subscriptions` slot indefinitely. - # The periodic write detects the dead peer and frees the slot. Matches the TypeScript SDK's - # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream. - DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15 - # Creates a Streamable HTTP transport that can be mounted as a Rack app. # # @param server [MCP::Server] the server whose requests this transport dispatches. @@ -122,22 +80,6 @@ module MCP # ownership is not enforced. # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger # requests are rejected with HTTP 413. Defaults to 4 MiB. - # @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen` - # streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables - # the cap. - # @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames - # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer - # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable - # when an upstream proxy already keeps the stream alive. - # @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream. - # A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern, - # which builds a fresh transport per request and renders the body) passes `false`: - # the method then answers 404 with JSON-RPC `-32601` like any unimplemented method, - # and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags, - # keeping the advertisement and the actual behavior in agreement. Defaults to `true`. - # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its - # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`. - # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`. def initialize( server, stateless: false, @@ -148,11 +90,7 @@ module MCP allowed_hosts: nil, dns_rebinding_protection: true, session_request_validator: nil, - max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, - max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS, - listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, - serve_subscriptions_listen: true, - server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @@ -169,16 +107,6 @@ module MCP @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} - # Maps a `subscriptions/listen` request id to - # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575). - # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes, - # which is a follow-up. - @listen_subscriptions = {} - - # Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits; - # `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575). - @modern_request_sinks = {} - # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default, # which does not apply to stateless mode since it retains no sessions. @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT) @@ -208,25 +136,6 @@ module MCP @max_request_bytes = max_request_bytes - if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0) - raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil" - end - - @max_listen_subscriptions = max_listen_subscriptions - - if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0) - raise ArgumentError, "listen_keepalive_interval must be a positive number or nil" - end - - @listen_keepalive_interval = listen_keepalive_interval - @serve_subscriptions_listen = serve_subscriptions_listen - - unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive? - raise ArgumentError, "server_to_client_request_timeout must be a positive number" - end - - @server_to_client_request_timeout = server_to_client_request_timeout - start_reaper_thread if @session_idle_timeout end @@ -240,82 +149,15 @@ module MCP # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze - # JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575). - NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze - - # Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on - # a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI - # against `resourceSubscriptions` instead. - LISTEN_FILTER_FIELDS = { - Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged, - Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged, - Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged, - }.freeze - - # JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404 - # (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors, - # stays 200, matching the Python SDK's status ladder. - MODERN_BAD_REQUEST_CODES = [ - ErrorCodes::HEADER_MISMATCH, - ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, - ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, - JsonRpcHandler::ErrorCode::PARSE_ERROR, - JsonRpcHandler::ErrorCode::INVALID_REQUEST, - JsonRpcHandler::ErrorCode::INVALID_PARAMS, - ].freeze - # Rack app interface. This transport can be mounted as a Rack app. def call(env) handle_request(Rack::Request.new(env)) end - # Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575). - # Gates both the route (a refusing transport answers the method as unimplemented) and - # the `listChanged`/`subscribe` capability flags `Server#discover` advertises, - # so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword. - def serves_subscriptions_listen? - @serve_subscriptions_listen - end - def handle_request(request) rebinding_error = validate_dns_rebinding(request) return rebinding_error if rebinding_error - # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside - # every supported list routes to the sessionless modern path, so an unknown future version receives - # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error. - # Requests without the header, or with a stable-only version, take the existing paths untouched. - # An empty header value is malformed rather than a version claim, so it stays on the legacy path - # and fails legacy header validation as before. - # - # A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it, - # and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it): - # an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream, - # and DELETE termination keep working), and a session-less POST whose body is `initialize` is - # the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic - # (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape). - header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] - if header_version && !header_version.empty? && !stable_only_version?(header_version) - unless MCP::Configuration.modern_protocol_version?(header_version) - return handle_modern(request, header_version) - end - - if extract_session_id(request).nil? - return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST" - - # The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here, - # bounded, and hands the string to whichever path serves the request. - body_string = read_bounded_body(request) - return payload_too_large_response if body_string.nil? - - unless legacy_handshake_body?(body_string) - return handle_modern(request, header_version, body_string: body_string) - end - - return handle_post(request, body_string: body_string) - end - end - case request.env["REQUEST_METHOD"] when "POST" handle_post(request) @@ -332,8 +174,6 @@ module MCP @reaper_thread&.kill @reaper_thread = nil - teardown_listen_subscriptions - removed_sessions = @mutex.synchronize do @sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) } end @@ -345,11 +185,10 @@ module MCP end def send_notification(method, params = nil, session_id: nil, related_request_id: nil) - # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below: - # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too. - # Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions, - # and a sink capturing the notification for its own response stream must not hide it from other subscriptions. - deliver_to_listen_subscriptions(method, params) + # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising + # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from + # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. + return false if @stateless notification = { jsonrpc: "2.0", @@ -357,24 +196,6 @@ module MCP } notification[:params] = params if params - # A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers - # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response. - # Checked before the stateless guard, since modern requests are served in stateless deployments too. - # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as - # non-delivery (`false`), never handed to the legacy paths below. - sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] } - if sink - return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS - - sink << notification - return true - end - - # Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising - # so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from - # a tool handler) degrade gracefully rather than spamming the exception reporter on every call. - return false if @stateless - if session_id deliver_targeted_notification(notification, session_id, related_request_id) else @@ -461,13 +282,8 @@ module MCP @mutex.synchronize do session = @sessions[session_id] - if related_request_id - # Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it, - # which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped - # failure never reaches the session teardown below. - registered = session&.dig(:post_request_streams, related_request_id) - session[:post_request_streams].delete(related_request_id) if registered.equal?(stream) - + if related_request_id && session&.dig(:post_request_streams, related_request_id) + session[:post_request_streams].delete(related_request_id) streams_to_close << stream else cleanup_and_collect_stream(session_id, streams_to_close) @@ -483,28 +299,14 @@ module MCP end end - # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until - # the client responds. + # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and + # blocks until the client responds. # - # Uses a `PendingResponse` for cross-thread synchronization: this method registers one, - # sends the request via SSE stream, then waits on it. When the client POSTs a response, - # `handle_response` matches it by `request_id` and resolves the pending response, - # unblocking this thread. A cancellation and session teardown resolve it the same way. - # - # The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`), - # so a client that never answers cannot park the calling thread for good. On expiry the peer is - # sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised. - def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) - # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests; - # multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached - # the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`, - # but that is incidental: without the rule stated here a modern handler that reaches for elicitation - # or sampling is told "Session not found: ", which points at everything except the actual reason. - # `StdioTransport#send_request` refuses the same way. - if server_session&.era == :modern - raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)." - end - + # Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`, + # sends the request via SSE stream, then blocks on `queue.pop`. + # When the client POSTs a response, `handle_response` matches it by `request_id` + # and pushes the result onto the queue, unblocking this thread. + def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil) if @stateless raise "Stateless mode does not support server-to-client requests." end @@ -518,8 +320,7 @@ module MCP end request_id = generate_request_id - pending_response = PendingResponse.new - wait_timeout = timeout || @server_to_client_request_timeout + queue = Queue.new cancel_hook = nil request = { jsonrpc: "2.0", id: request_id, method: method } @@ -532,7 +333,7 @@ module MCP raise "Session not found: #{session_id}." end - @pending_responses[request_id] = { queue: pending_response, session_id: session_id } + @pending_responses[request_id] = { queue: queue, session_id: session_id } active_stream(session, related_request_id: related_request_id) end @@ -568,27 +369,7 @@ module MCP end end - response = pending_response.pop(timeout: wait_timeout) do - # Expiry cancels as well as stops waiting, so a client that answers late does not act on - # a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here: - # the modern lifecycle forbids server-to-client requests outright, and its sessionless requests - # never register the session this method looks up. Those revisions ask the sender to "issue - # a cancellation notification for that request and stop waiting", letting either side send one. - # (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown - # governs the era that has no such requests to cancel.) Both reference SDKs send this same - # courtesy cancel on timeout. - server_session&.send_peer_cancellation( - nested_request_id: request_id, - related_request_id: related_request_id, - reason: "Timed out after #{wait_timeout} seconds", - ) - - raise RequestTimeoutError.new( - "#{method} request timed out after #{wait_timeout} seconds", - request_id: request_id, - timeout: wait_timeout, - ) - end + response = queue.pop if response.is_a?(Hash) && response.key?(:error) raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}" @@ -676,510 +457,7 @@ module MCP stream.flush end - # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575): - # a single POST/JSON exchange with no session. The modern path never consults - # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts - # an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`) - # and DELETE (session termination) have no modern meaning. - def handle_modern(request, header_version, body_string: nil) - return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST" - - accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE) - return accept_error if accept_error - - content_type_error = validate_content_type(request) - return content_type_error if content_type_error - - if body_string.nil? - body_string = read_bounded_body(request) - return payload_too_large_response if body_string.nil? - end - - begin - body = parse_request_body(body_string) - rescue InvalidJsonError - return invalid_json_response - end - - unless body.is_a?(Hash) - return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") - end - - # The version check precedes everything else that depends on request content, - # so a client probing with an unknown future version always receives the `-32022` signal - # (with the supported list to select from) rather than an incidental error. - unless MCP::Configuration.modern_protocol_version?(header_version) - return json_rpc_error_response( - status: 400, - code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, - message: "Unsupported protocol version", - data: { - supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, - requested: header_version, - }, - id: body[:id], - ) - end - - if extract_session_id(request) - return json_rpc_error_response( - status: 400, - code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, - message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle", - id: body[:id], - ) - end - - mismatch_error = validate_modern_headers(request, body, header_version) - return mismatch_error if mismatch_error - - # `subscriptions/listen` is a long-lived notification stream served at the transport layer; - # it never dispatches through `Server#handle`. A transport constructed with - # `serve_subscriptions_listen: false` skips the interception, so the method falls through - # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot - # serve an open SSE stream needs, instead of a `Proc` body it can never call. - if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen? - return handle_subscriptions_listen(body) - end - - session = modern_session - notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] } - begin - response = @server.handle(body, session: session) - ensure - @mutex.synchronize { @modern_request_sinks.delete(session.session_id) } - end - - # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path. - return handle_accepted if response.nil? - - # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of - # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder. - # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving - # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count. - # Live streaming can follow without changing the wire shape. - if notifications.empty? - [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]] - else - frames = notifications + [response] - sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join - [200, SSE_HEADERS.dup, [sse_body]] - end - rescue StandardError => e - MCP.configuration.exception_reporter.call(e, { request: body_string }) - json_rpc_error_response( - status: 500, - code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, - message: "Internal server error", - ) - end - - # Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header - # MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match - # the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks - # converge on enforcement. - def validate_modern_headers(request, body, header_version) - params = body[:params] - meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil - if meta_version && meta_version != header_version - return header_mismatch_response( - "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'", - body[:id], - ) - end - - # `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods: - # the spec lists a missing required standard header among the validation failures that MUST be rejected, - # and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare": - # the headers exist so intermediaries can route without parsing bodies, and a client that omits them - # defeats that contract silently. - method_header = request.env["HTTP_MCP_METHOD"] - if method_header.to_s.empty? - return header_mismatch_response( - "Mcp-Method header is required on the modern path", - body[:id], - ) - end - if method_header != body[:method] - return header_mismatch_response( - "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'", - body[:id], - ) - end - - if NAME_BEARING_METHODS.include?(body[:method]) - body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil - name_header = request.env["HTTP_MCP_NAME"] - if body_name - if name_header.to_s.empty? - return header_mismatch_response( - "Mcp-Name header is required for `#{body[:method]}`", - body[:id], - ) - end - - decoded_name = decode_header_value(name_header) - if decoded_name != body_name - return header_mismatch_response( - "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'", - body[:id], - ) - end - end - end - - nil - end - - # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is - # `notifications/subscriptions/acknowledged` with the subset of requested notification types - # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId` - # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` - # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every - # `listen_keepalive_interval` seconds so a dropped connection frees its slot. - def handle_subscriptions_listen(body) - request_id = body[:id] - params = body[:params] - - # A listen frame without an id could never receive stream teardown correlation. - unless request_id - return invalid_request_response("Invalid Request: subscriptions/listen requires an id") - end - - begin - if RequestEnvelope.modern?(params) - RequestEnvelope.parse!(params, request: params) - else - return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope") - end - rescue Server::RequestHandlerError => e - return json_rpc_error_response( - status: 400, - code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST, - message: e.message, - data: e.error_data, - id: request_id, - ) - end - - filter = params[:notifications] - unless filter.is_a?(Hash) - return json_rpc_error_response( - status: 400, - code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, - message: "Invalid params: subscriptions/listen requires a `notifications` filter object", - id: request_id, - ) - end - - # Best-effort cap check before committing to the SSE response; the registration inside - # `listen_sse_body` re-checks atomically for the race between two concurrent listens - # crossing the cap together. - if listen_subscriptions_full? - return too_many_listen_subscriptions_response(request_id) - end - - [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))] - end - - def listen_subscriptions_full? - return false unless @max_listen_subscriptions - - @mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions } - end - - def too_many_listen_subscriptions_response(request_id) - json_rpc_error_response( - status: 503, - code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, - message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached", - id: request_id, - ) - end - - # The Rack streaming body of a `subscriptions/listen` response. It responds to `call` - # and deliberately not to `each`, so Rack keeps classifying it as a streaming body; - # `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)` - # in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix. - class ListenStreamBody - def initialize(&block) - @block = block - end - - def call(stream) - @block.call(stream) - end - - def first - raise <<~MESSAGE - subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \ - A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \ - so the method is answered as unimplemented instead. - See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns. - MESSAGE - end - end - private_constant :ListenStreamBody - - # The body registers the stream and returns, leaving the response open like - # the legacy GET stream (`create_sse_body`). - # - # Registration and activation are split on purpose: the entry is inserted inactive - # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock, - # and only then does the entry become eligible for delivery. A concurrent notification between - # the insert and the acknowledgement write skips the inactive entry, - # enforcing the SEP-2575 rule that no notification precedes the acknowledgement. - def listen_sse_body(request_id, honored) - ListenStreamBody.new do |stream| - rejected = false - @mutex.synchronize do - if @listen_subscriptions.key?(request_id) || - (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions) - rejected = true - else - @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new } - end - end - - if rejected - close_stream_safely(stream) - else - acknowledgement = { - jsonrpc: "2.0", - method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED, - params: { - notifications: honored, - _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, - }, - } - - begin - send_to_stream(stream, acknowledgement) - activate_listen_subscription(request_id) - start_listen_keepalive_thread(request_id) - rescue *STREAM_WRITE_ERRORS - remove_listen_subscription(request_id) - close_stream_safely(stream) - end - end - end - end - - # Marks a listen subscription eligible for delivery once its acknowledgement write has completed. - # The entry may already be gone when the transport closed concurrently. - def activate_listen_subscription(request_id) - @mutex.synchronize do - subscription = @listen_subscriptions[request_id] - subscription[:active] = true if subscription - end - end - - # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped - # connection is detected and its slot freed, rather than held until the next fan-out write. - # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame) - # cannot corrupt an interleaved notification's JSON. - def start_listen_keepalive_thread(request_id) - return unless @listen_keepalive_interval - - Thread.new do - while listen_subscription_active?(request_id) - sleep(@listen_keepalive_interval) - send_listen_keepalive_ping(request_id) - end - rescue *STREAM_WRITE_ERRORS - # The peer went away; the ensure frees the slot. A dropped listen stream is the normal - # way this loop ends, so it is not reported. - rescue StandardError => e - MCP.configuration.exception_reporter.call(e, { subscription_id: request_id }) - ensure - stream = @mutex.synchronize do - subscription = @listen_subscriptions.delete(request_id) - subscription && subscription[:stream] - end - close_stream_safely(stream) if stream - end - end - - def listen_subscription_active?(request_id) - @mutex.synchronize { @listen_subscriptions.key?(request_id) } - end - - # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block - # every other subscription on `@mutex`. A write error propagates to end the keepalive loop. - def send_listen_keepalive_ping(request_id) - stream = @mutex.synchronize do - subscription = @listen_subscriptions[request_id] - subscription && subscription[:stream] - end - return unless stream - - send_ping_to_stream(stream) - end - - # Per SEP-2575, the server MUST NOT send notification types the client has not requested, - # and the acknowledgement only includes types the server actually supports - # (derived from its declared capabilities). - def honored_filter(filter) - capabilities = @server.capabilities - honored = {} - honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged) - honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged) - honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged) - - subscriptions = filter[:resourceSubscriptions] - if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty? - honored[:resourceSubscriptions] = subscriptions - end - - honored - end - - # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive - # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation - # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough. - def capability_flag?(capabilities, name, flag) - value = capabilities[name] || capabilities[name.to_s] - return false unless value.is_a?(Hash) - - !!(value[flag] || value[flag.to_s]) - end - - # Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it, - # stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter - # (not the requested one) enforces the MUST NOT-send-unrequested-types rule. - def deliver_to_listen_subscriptions(method, params) - field = LISTEN_FILTER_FIELDS[method] - return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED - - # The matching snapshot is taken under `@mutex`, but stream writes happen outside it: - # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths. - matched = @mutex.synchronize do - @listen_subscriptions.filter_map do |request_id, subscription| - # An inactive entry has not finished writing its acknowledgement yet; - # delivering to it would put a notification ahead of the acknowledgement. - next unless subscription[:active] - - hit = if field - subscription[:filter][field] - else - uris = subscription[:filter][:resourceSubscriptions] - uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil - uris.is_a?(Array) && uris.include?(uri) - end - - [request_id, subscription] if hit - end - end - - matched.each do |request_id, subscription| - meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id } - notification_params = (params || {}).merge(_meta: meta) - notification = { jsonrpc: "2.0", method: method, params: notification_params } - - begin - # The per-stream write mutex orders this write against a concurrent graceful teardown: - # once teardown has marked the entry closed and written its `SubscriptionsListenResult`, - # a delivery that snapshotted the entry before the registry was cleared skips it instead - # of writing after the final message. - subscription[:write_mutex].synchronize do - next if subscription[:closed] - - send_to_stream(subscription[:stream], notification) - end - rescue *STREAM_WRITE_ERRORS => e - MCP.configuration.exception_reporter.call( - e, - { subscription_id: request_id, error: "Failed to send notification" }, - ) - remove_listen_subscription(request_id) - close_stream_safely(subscription[:stream]) - end - end - end - - def remove_listen_subscription(request_id) - @mutex.synchronize { @listen_subscriptions.delete(request_id) } - end - - # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response - # before the stream closes. - def teardown_listen_subscriptions - removed = @mutex.synchronize do - subscriptions = @listen_subscriptions.dup - @listen_subscriptions.clear - subscriptions - end - - removed.each do |request_id, subscription| - # Marking the entry closed and writing the result under the stream's write mutex orders - # this against in-flight deliveries: each one either lands before the result or observes - # `closed` and skips, keeping the graceful result the stream's final message. - subscription[:write_mutex].synchronize do - subscription[:closed] = true - - begin - send_to_stream(subscription[:stream], { - jsonrpc: "2.0", - id: request_id, - result: { - # `SubscriptionsListenResult` is served at the transport layer and never - # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is - # stamped at its construction site. - resultType: ResultType::COMPLETE, - _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }, - }, - }) - rescue *STREAM_WRITE_ERRORS - nil - end - end - close_stream_safely(subscription[:stream]) - end - end - - def header_mismatch_response(message, id) - json_rpc_error_response( - status: 400, - code: ErrorCodes::HEADER_MISMATCH, - message: "Header mismatch: #{message}", - id: id, - ) - end - - # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64??=` decodes to - # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport - # requires faraday, which servers do not depend on. - def decode_header_value(value) - match = value.match(/\A=\?base64\?(.*)\?=\z/m) - return value unless match - - match[1].unpack1("m0").force_encoding(Encoding::UTF_8) - rescue ArgumentError - value - end - - # Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to - # the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing - # keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions - # via the `session_id.nil?` branch. - def modern_session - ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern) - end - - def modern_http_status(response) - error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil - if error_code.nil? - 200 - elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND - 404 - elsif MODERN_BAD_REQUEST_CODES.include?(error_code) - 400 - else - 200 - end - end - - def handle_post(request, body_string: nil) + def handle_post(request) required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE accept_error = validate_accept_header(request, required_types) return accept_error if accept_error @@ -1187,10 +465,8 @@ module MCP content_type_error = validate_content_type(request) return content_type_error if content_type_error - if body_string.nil? - body_string = read_bounded_body(request) - return payload_too_large_response if body_string.nil? - end + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? session_id = extract_session_id(request) @@ -1207,28 +483,6 @@ module MCP return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") end - # Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs, - # so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only. - # Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session - # already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras - # (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version, - # which violates the header/body match requirement and would fall through the legacy path via - # the header default; reject that as a header mismatch. - if RequestEnvelope.modern?(body[:params]) - header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"] - if header_version && MCP::Configuration.modern_protocol_version?(header_version) - return invalid_request_response( - "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`", - request_id: body[:id], - ) - end - - return header_mismatch_response( - "MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope", - body[:id], - ) - end - # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. # This matches the TypeScript and Python SDKs. @@ -1494,28 +748,6 @@ module MCP body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER end - # A version with no modern meaning, whose header can only accompany legacy traffic. - # A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it, - # but requests of an established legacy session may stamp it - so that value needs further disambiguation. - def stable_only_version?(version) - MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) && - !MCP::Configuration.modern_protocol_version?(version) - end - - # Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body - # WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying - # the envelope comes from a modern client naming a method its lifecycle removed, so it stays - # on the modern path and answers with -32601/404 (SEP-2575). - # Unparsable or non-object bodies go to the modern path, whose error responses cover them. - def legacy_handshake_body?(body_string) - body = parse_request_body(body_string) - return false unless initialize_request?(body) - - !RequestEnvelope.modern?(body[:params]) - rescue InvalidJsonError - false - end - def validate_protocol_version_header(request) header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) @@ -1528,10 +760,8 @@ module MCP ) end - def json_rpc_error_response(status:, code:, message:, data: nil, id: nil) - error = { code: code, message: message } - error[:data] = data if data - body = { jsonrpc: "2.0", id: id, error: error } + def json_rpc_error_response(status:, code:, message:) + body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } [status, { "content-type" => "application/json" }, [body.to_json]] end @@ -1675,14 +905,6 @@ module MCP end end - # `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below - # registers this request's stream under that id first, so without this check the colliding request - # would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then - # clear the entry the original request still needs. - if related_request_id && server_session&.in_flight?(related_request_id) - return request_id_conflict_response - end - if session_id && !@stateless && !@enable_json_response handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id) else @@ -1706,11 +928,7 @@ module MCP session = @sessions[session_id] if session && related_request_id session[:post_request_streams] ||= {} - - # Claim the id only while it is free. `handle_regular_request` already refused the colliding request, - # so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps - # its messages going where they belong. - session[:post_request_streams][related_request_id] ||= stream + session[:post_request_streams][related_request_id] = stream end end @@ -1722,11 +940,7 @@ module MCP if related_request_id @mutex.synchronize do session = @sessions[session_id] - # Only retire our own registration: a request that never claimed the id, or one whose claim has - # already been replaced, must not unregister the stream that owns it. - registered = session&.dig(:post_request_streams, related_request_id) - - session[:post_request_streams].delete(related_request_id) if registered.equal?(stream) + session[:post_request_streams]&.delete(related_request_id) if session end end @@ -1945,16 +1159,6 @@ module MCP ) end - # The POST counterpart of the GET conflict above. A request id already in flight cannot be given - # a stream of its own, because the id is what routes request-scoped messages back. - def request_id_conflict_response - json_rpc_error_response( - status: 409, - code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, - message: "Conflict: Request id is already in flight for this session", - ) - end - def setup_sse_stream(session_id) body = create_sse_body(session_id) diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index 33b2075..dc5b20a 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -4,35 +4,12 @@ module MCP class ServerContext attr_reader :cancellation - # The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern; - # `nil` on legacy requests. - attr_reader :envelope - - # SEP-2322 multi round-trip retry fields, present when the client re-issued the request after - # an `input_required` result: `input_responses` maps the keys of the earlier `inputRequests` to - # the client's answers, and `request_state` is the opaque continuation string echoed back byte-exactly. - # Both are `nil` on a first-round request. Only handlers that opt in to `server_context:` can read them - # (the same access model as the envelope readers). - attr_reader :input_responses, :request_state - - def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, - input_responses: nil, request_state: nil) + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil) @context = context @progress = progress @notification_target = notification_target @related_request_id = related_request_id @cancellation = cancellation - @envelope = envelope - @input_responses = input_responses - @request_state = request_state - end - - # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys. - def input_response(key) - return unless @input_responses.is_a?(Hash) - - value = @input_responses[key.to_sym] - value.nil? ? @input_responses[key.to_s] : value end def cancelled? @@ -43,52 +20,6 @@ module MCP @cancellation&.raise_if_cancelled! end - # Whether the current request follows the stateless modern lifecycle (SEP-2575). - def modern? - !@envelope.nil? - end - - # Client identity for the current request. Modern requests carry it in the `_meta` envelope; - # legacy sessions fall back to the state stored by `initialize`. The envelope always wins - # because servers MUST NOT infer identity from prior requests. - def client_info - return @envelope.client_info if @envelope - - @notification_target.client if @notification_target.respond_to?(:client) - end - - # Client capabilities for the current request, with the same envelope-first resolution as {#client_info}. - def client_capabilities - return @envelope.client_capabilities if @envelope - - @notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities) - end - - # The protocol version the current request was made with. `nil` on legacy requests, - # where the version is a session-level negotiation result rather than per-request data. - def protocol_version - @envelope&.protocol_version - end - - # Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys, - # e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError` - # (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared. - def require_client_capability!(*path) - raise ArgumentError, "at least one capability key is required" if path.empty? - - declared = client_capabilities - value = path.reduce(declared) do |acc, key| - break unless acc.is_a?(Hash) - - symbol_value = acc[key.to_sym] - symbol_value.nil? ? acc[key.to_s] : symbol_value - end - return unless value.nil? - - required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } } - raise Server::MissingRequiredClientCapabilityError, required - end - # Reports progress for the current tool operation. # The notification is automatically scoped to the originating session. # @@ -110,14 +41,6 @@ module MCP def notify_log_message(data:, level:, logger: nil) return unless @notification_target - # Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`, - # the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops - # the message the same way. Session- or server-level gating still applies downstream on delegation. - if @envelope - threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level) - return unless threshold&.valid_level? && threshold.should_notify?(level) - end - @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) end @@ -138,9 +61,9 @@ module MCP # `notifications/roots/list_changed`) is deprecated as of MCP protocol # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. - def list_roots(timeout: nil) + def list_roots if @notification_target.respond_to?(:list_roots) - @notification_target.list_roots(related_request_id: @related_request_id, **timeout_kwarg(timeout)) + @notification_target.list_roots(related_request_id: @related_request_id) else raise NoMethodError, "undefined method 'list_roots' for #{self}" end @@ -160,9 +83,9 @@ module MCP # end # # @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping - def ping(timeout: nil) + def ping if @notification_target.respond_to?(:ping) - @notification_target.ping(related_request_id: @related_request_id, **timeout_kwarg(timeout)) + @notification_target.ping(related_request_id: @related_request_id) else raise NoMethodError, "undefined method 'ping' for #{self}" end @@ -245,15 +168,5 @@ module MCP def respond_to_missing?(name, include_private = false) @context.respond_to?(name) || super end - - private - - # An omitted `timeout:` is not forwarded at all, so the delegated call keeps the shape it had - # before per-request timeouts existed. A notification target that predates the keyword - # (a custom object standing in for a session) keeps working until a caller actually asks for a timeout, - # and the transport applies its own default in that case. - def timeout_kwarg(timeout) - timeout.nil? ? {} : { timeout: timeout } - end end end diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index cfee2ab..754c1d0 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -7,26 +7,15 @@ module MCP # Holds per-connection state for a single client session. # Created by the transport layer; delegates request handling to the shared `Server`. class ServerSession - ERAS = [:legacy, :modern].freeze - - attr_reader :session_id, :client, :logging_message_notification, :protocol_version - - # Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds, - # then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions - # with `era: :modern` up front. - attr_reader :era - - def initialize(server:, transport:, session_id: nil, era: nil) - validate_era!(era) if era + attr_reader :session_id, :client, :logging_message_notification + def initialize(server:, transport:, session_id: nil) @server = server @transport = transport @session_id = session_id - @era = era @client = nil @client_capabilities = nil @logging_message_notification = nil - @protocol_version = nil @in_flight = {} @in_flight_mutex = Mutex.new @initialized = false @@ -40,59 +29,23 @@ module MCP # Called by `Server#init` after a successful `initialize` response, so subsequent # `initialize` requests on the same session can be rejected per MCP spec # (the initialization phase MUST be the first interaction). - def mark_initialized!(protocol_version: nil) + def mark_initialized! @initialized = true - @protocol_version = protocol_version - # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575), - # so it also locks the connection era. - @era ||= :legacy end - # One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises, - # because a connection can never change eras. - def lock_era!(era) - validate_era!(era) - return if @era == era - raise "Session era already locked to #{@era}" if @era - - @era = era - end - - # Registers a `Cancellation` token for an in-flight request, or returns `nil` when `request_id` is already in flight. - # The request id is the only key that routes request-scoped notifications, server-to-client requests, - # and `notifications/cancelled` back to the request that caused them, so a second live request under the same id - # has no destination of its own. Rather than let the newcomer displace the registration, report the collision - # and leave the first request intact; the caller turns that into an Invalid Request. + # Registers a `Cancellation` token for an in-flight request. def register_in_flight(request_id) return if request_id.nil? cancellation = Cancellation.new(request_id: request_id) - registered = @in_flight_mutex.synchronize do - next false if @in_flight.key?(request_id) - - @in_flight[request_id] = cancellation - true - end - - registered ? cancellation : nil + @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation } + cancellation end - # Removes an in-flight registration. Passing the `Cancellation` that `register_in_flight` returned removes - # the entry only while it is still that one, so a request can never evict a registration it does not own. - def unregister_in_flight(request_id, cancellation: nil) + def unregister_in_flight(request_id) return if request_id.nil? - @in_flight_mutex.synchronize do - next if cancellation && !@in_flight[request_id].equal?(cancellation) - - @in_flight.delete(request_id) - end - end - - # Whether `request_id` is currently in flight, so a transport can refuse a colliding request - # before registering any state of its own for it. - def in_flight?(request_id) - !lookup_in_flight(request_id).nil? + @in_flight_mutex.synchronize { @in_flight.delete(request_id) } end def lookup_in_flight(request_id) @@ -153,19 +106,19 @@ module MCP # `notifications/roots/list_changed`) is deprecated as of MCP protocol # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs, # server configuration, or environment variables instead. - def list_roots(related_request_id: nil, timeout: nil) + def list_roots(related_request_id: nil) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:roots) raise "Client does not support roots." end - send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout) + send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id) end # Sends a `ping` request scoped to this session. - def ping(related_request_id: nil, timeout: nil) - result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout) + def ping(related_request_id: nil) + result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id) raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash) result @@ -179,16 +132,11 @@ module MCP # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider # APIs instead. - def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs) + def create_sampling_message(related_request_id: nil, **kwargs) warn_unassociated_request(__method__, related_request_id) params = @server.build_sampling_params(client_capabilities, **kwargs) - send_to_transport_request( - Methods::SAMPLING_CREATE_MESSAGE, - params, - related_request_id: related_request_id, - timeout: timeout, - ) + send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id) end # Sends an `elicitation/create` request (form mode) scoped to this session. @@ -196,7 +144,7 @@ module MCP # Per SEP-2260, the request must be associated with an originating client # request; prefer `server_context.create_form_elicitation` inside a handler, # which stamps the association automatically. - def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil) + def create_form_elicitation(message:, requested_schema:, related_request_id: nil) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:elicitation) @@ -205,12 +153,7 @@ module MCP end params = { mode: "form", message: message, requestedSchema: requested_schema } - send_to_transport_request( - Methods::ELICITATION_CREATE, - params, - related_request_id: related_request_id, - timeout: timeout, - ) + send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) end # Sends an `elicitation/create` request (URL mode) scoped to this session. @@ -218,7 +161,7 @@ module MCP # Per SEP-2260, the request must be associated with an originating client # request; prefer `server_context.create_url_elicitation` inside a handler, # which stamps the association automatically. - def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil) + def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil) warn_unassociated_request(__method__, related_request_id) unless client_capabilities&.dig(:elicitation, :url) @@ -227,21 +170,7 @@ module MCP end params = { mode: "url", message: message, url: url, elicitationId: elicitation_id } - send_to_transport_request( - Methods::ELICITATION_CREATE, - params, - related_request_id: related_request_id, - timeout: timeout, - ) - end - - # Sends an embedded SEP-2322 `inputRequests` entry as a real server-to-client request on the legacy wire, - # for the server's dual-era fulfilment shim. - # The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent - # standalone request - and stays associated with the originating client request per SEP-2260. - # Returns the client's result. - def fulfill_input_request(method, params, related_request_id:) - send_to_transport_request(method, params, related_request_id: related_request_id) + send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id) end # Sends `notifications/cancelled` to the peer for a nested server-to-client request @@ -294,15 +223,7 @@ module MCP # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577). # Use stderr or OpenTelemetry instead. def notify_log_message(data:, level:, logger: nil, related_request_id: nil) - # In the modern lifecycle, log delivery is authorized per request through the `_meta` envelope's `logLevel` member - # (applied via `configure_logging`); without it no `notifications/message` is sent, and the server-wide level - # does not apply (SEP-2575). - effective_logging = if @era == :modern - @logging_message_notification - else - @logging_message_notification || @server.logging_message_notification - end - + effective_logging = @logging_message_notification || @server.logging_message_notification return unless effective_logging&.should_notify?(level) params = { "data" => data, "level" => level } @@ -315,10 +236,6 @@ module MCP private - def validate_era!(era) - raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era) - end - # Forwards `send_notification` to the transport with only the kwargs the transport's method signature # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)` # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:` @@ -338,7 +255,7 @@ module MCP # `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing. # When `related_request_id` names an in-flight request, its `Cancellation` token is looked up # so that cancelling the parent also cancels this nested server-to-client request. - def send_to_transport_request(method, params, related_request_id: nil, timeout: nil) + def send_to_transport_request(method, params, related_request_id: nil) parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil kwargs = { @@ -346,7 +263,6 @@ module MCP related_request_id: related_request_id, parent_cancellation: parent_cancellation, server_session: self, - timeout: timeout, }.compact forward_to_transport(@transport.method(:send_request), method, params, kwargs) diff --git a/lib/mcp/tool.rb b/lib/mcp/tool.rb index 1b76b80..37c5d92 100644 --- a/lib/mcp/tool.rb +++ b/lib/mcp/tool.rb @@ -149,7 +149,7 @@ module MCP raise ArgumentError, "Tool names should be between 1 and 128 characters in length (inclusive)." end - unless tool_name.match?(/\A[A-Za-z\d_\-.]+\z/) + unless tool_name.match?(/\A[A-Za-z\d_\-\.]+\z/) raise ArgumentError, <<~MESSAGE Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.). MESSAGE diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index 9e4b2ec..2532ea8 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -7,24 +7,18 @@ module MCP attr_reader :content, :structured_content, :meta - def initialize(content = NOT_GIVEN, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) + def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) if deprecated_error != NOT_GIVEN warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1) error = deprecated_error end - content_given = !content.equal?(NOT_GIVEN) - @content_provided = content_given && !content.nil? - @content = content_given ? (content || []) : [] + @content = content || [] @error = error @structured_content = structured_content @meta = meta end - def content_provided? - @content_provided - end - def error? !!@error end diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index c48e3c9..9f94c60 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -50,13 +50,6 @@ module MCP raise NotImplementedError, "Subclasses must implement send_request" end - # Whether the transport serves the `subscriptions/listen` notification stream (MCP 2026-07-28, SEP-2575). - # `Server#discover` strips the `listChanged`/`subscribe` capability flags when the transport cannot deliver - # those notifications in the modern lifecycle. - def serves_subscriptions_listen? - false - end - private def generate_request_id diff --git a/lib/mcp/version.rb b/lib/mcp/version.rb index 2d0dff8..14e71dc 100644 --- a/lib/mcp/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MCP - VERSION = "1.5.1" + VERSION = "1.0.0" end diff --git a/test/initialize_params_test_helper.rb b/test/initialize_params_test_helper.rb index ccee933..6e6d6b2 100644 --- a/test/initialize_params_test_helper.rb +++ b/test/initialize_params_test_helper.rb @@ -2,12 +2,10 @@ module InitializeParamsTestHelper # `initialize` params satisfying the fields required by the MCP schema - # (protocolVersion, capabilities, and clientInfo). Offers the latest handshake version - # so helper-built sessions run the version they request; the counter-offer for - # modern versions has its own dedicated tests. + # (protocolVersion, capabilities, and clientInfo). def initialize_params(**overrides) { - protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "test-client", version: "1.0.0" }, }.merge(overrides) diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 07db0ec..e823e42 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -310,6 +310,7 @@ describe JsonRpcHandler do assert_rpc_error expected_error: { code: -32603, message: "Internal error", + data: "Something bad happened", } assert_equal 1, @response[:id] assert_nil @response[:result] @@ -451,6 +452,7 @@ describe JsonRpcHandler do assert_rpc_error expected_error: { code: -32603, message: "Internal error", + data: "Something bad happened", } end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 0b9960a..85f93fa 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -11,8 +11,6 @@ require "mcp/client" module MCP class Client class HTTPTest < Minitest::Test - include DeprecationWarningTestHelper - def test_raises_load_error_when_faraday_not_available client = HTTP.new(url: url) @@ -765,125 +763,6 @@ module MCP assert_equal({ "content" => [] }, response["result"]) end - def test_send_request_releases_the_calling_thread_on_an_excessive_reconnection_delay - # A server priming a stream, closing it, and asking for a day-long `retry:` used to park - # the calling thread for that long. The default budget now stops the resume without sleeping. - stub_reconnection_with_retry(86_400_000) - client.expects(:sleep).never - - error = assert_raises(MCP::Client::RequestHandlerError) do - client.send_request(request: reconnection_request) - end - - assert_includes error.message, "reconnection budget" - end - - def test_send_request_raises_a_server_reconnection_delay_of_zero_to_the_minimum - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/call", - params: { name: "test_reconnection", arguments: {} }, - } - - stub_request(:post, url).with( - body: request.to_json, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: "id: event-1\nretry: 0\ndata:\n\n", - ) - - get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \ - "\n\n" - stub_request(:get, url).with( - headers: { "Last-Event-ID" => "event-1" }, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: get_body, - ) - - client.expects(:sleep).with(HTTP::MIN_RECONNECTION_DELAY_MS / 1000.0) - - response = client.send_request(request: request) - - assert_equal({ "content" => [] }, response["result"]) - end - - def test_send_request_honors_a_reconnection_delay_that_fits_the_budget - # Nothing is shortened while the server's `retry:` fits: the client waits it out and resumes. - custom_client = HTTP.new(url: url, max_reconnection_wait: 60) - - stub_reconnection_with_retry(30_000) - custom_client.expects(:sleep).with(30.0) - - response = custom_client.send_request(request: reconnection_request) - - assert_equal({ "content" => [] }, response["result"]) - end - - def test_send_request_gives_up_rather_than_reconnect_before_the_server_asked - # A delay past the budget is never shortened; the client stops trying to resume instead, so - # the calling thread is released immediately rather than after the server's chosen interval. - custom_client = HTTP.new(url: url, max_reconnection_wait: 10) - - stub_reconnection_with_retry(86_400_000) - custom_client.expects(:sleep).never - - error = assert_raises(MCP::Client::RequestHandlerError) do - custom_client.send_request(request: reconnection_request) - end - - assert_includes error.message, "would exceed the 10 second reconnection budget" - end - - def test_send_request_falls_back_to_the_default_delay_for_an_unusable_retry_value - # The SSE parser only accepts a run of digits, so a negative or non-numeric `retry:` never reaches - # the delay calculation as a value; both arrive as "the server sent none". - ["-5000", "abc", "1e6", "500ms"].each do |value| - WebMock.reset! - fresh_client = HTTP.new(url: url) - - stub_reconnection_with_retry(value) - fresh_client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0) - - response = fresh_client.send_request(request: reconnection_request) - - assert_equal({ "content" => [] }, response["result"]) - end - end - - def test_listener_applies_the_delay_floor_to_a_zero_retry_value - # The listening stream reconnects indefinitely after a graceful close, so a `retry: 0` would spin - # without the floor. The 500s that follow let the listener reach its failure cap and stop. - stub_initialize - stub_notification - stub_request(:delete, url).to_return(status: 200) - stub_request(:get, url).to_return( - { status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "id: e1\nretry: 0\ndata:\n\n" }, - { status: 500 }, - { status: 500 }, - ) - - client.expects(:sleep).with(HTTP::MIN_RECONNECTION_DELAY_MS / 1000.0).at_least_once - client.connect - client.on_server_request("elicitation/create") { { action: "decline" } } - listener = client.instance_variable_get(:@listener_thread) - - wait_until { !listener.alive? } - ensure - client.close - end - - def test_raises_argument_error_when_max_reconnection_wait_is_not_positive - [0, -1, "60", nil].each do |value| - error = assert_raises(ArgumentError) { HTTP.new(url: url, max_reconnection_wait: value) } - - assert_equal("max_reconnection_wait must be a positive number", error.message) - end - end - def test_send_request_raises_after_reconnection_attempts_are_exhausted request = { jsonrpc: "2.0", @@ -940,127 +819,6 @@ module MCP assert_equal({ "result" => { "tools" => [] } }, response) end - def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_only - # json 3.0 accepts parser options as keywords only, and Faraday's json response middleware - # passes them as a positional Hash, which Ruby 3 no longer converts. The stand-in parser has - # the json 3.0 signature, so the body must reach `JSON.parse` through the client's own call. - stubs = Faraday::Adapter::Test::Stubs.new do |stub| - stub.post("/") do - [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] - end - end - client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } - parse = JSON.method(:parse) - keyword_options_only_parse = ->(source, **options) { parse.call(source, **options) } - - response = JSON.stub(:parse, keyword_options_only_parse) do - client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) - end - - assert_equal({ "result" => { "tools" => [] } }, response) - end - - def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers - # SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header` - # declarations, and the following `tools/call` mirrors the annotated arguments into - # `Mcp-Param-*` headers. - call_headers = nil - client = mcp_param_test_client( - tools: [mcp_param_annotated_tool], - on_call: ->(headers) { call_headers = headers }, - ) - client.connect(mode: :modern) - - client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) - client.send_request(request: { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { - name: "test_custom_headers", - arguments: { region: "us-west1", priority: 42, non_ascii_val: "Hello, 世界", null_val: nil }, - }, - }) - - assert_equal("us-west1", call_headers["Mcp-Param-Region"]) - assert_equal("42", call_headers["Mcp-Param-Priority"]) - assert_equal("=?base64?#{["Hello, 世界"].pack("m0")}?=", call_headers["Mcp-Param-NonAsciiVal"]) - refute(call_headers.key?("Mcp-Param-NullVal"), "a null argument must omit its header") - end - - def test_send_request_mirrors_nothing_for_a_tool_with_an_invalid_x_mcp_header_declaration - # An invalid tool definition (here a case-insensitive duplicate) mirrors nothing rather than - # emitting a partial or malformed header set. - invalid_tool = { - name: "invalid_duplicate", - inputSchema: { - type: "object", - properties: { - one: { type: "string", "x-mcp-header": "Region" }, - two: { type: "string", "x-mcp-header": "REGION" }, - }, - }, - } - call_headers = nil - client = mcp_param_test_client(tools: [invalid_tool], on_call: ->(headers) { call_headers = headers }) - client.connect(mode: :modern) - - client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) - client.send_request(request: { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "invalid_duplicate", arguments: { one: "a", two: "b" } }, - }) - - refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) - assert_equal("tools/call", call_headers["Mcp-Method"]) - end - - def test_send_request_mirrors_nothing_on_a_legacy_connection - # The custom headers exist on the modern lifecycle only (SEP-2243), matching - # the TypeScript and Python SDKs: without modern adoption nothing is learned or mirrored. - call_headers = nil - client = mcp_param_test_client( - tools: [mcp_param_annotated_tool], - on_call: ->(headers) { call_headers = headers }, - ) - - client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) - client.send_request(request: { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, - }) - - refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) - assert_equal("tools/call", call_headers["Mcp-Method"]) - end - - def test_send_request_prunes_declarations_dropped_by_a_complete_listing - # A complete (uncursored, `nextCursor`-less) listing is the full tool universe, - # so declarations of unlisted tools are stale and stop mirroring. - call_headers = nil - listings = [[mcp_param_annotated_tool], []] - client = mcp_param_test_client( - tools: -> { listings.shift || [] }, - on_call: ->(headers) { call_headers = headers }, - ) - client.connect(mode: :modern) - - client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) - client.send_request(request: { jsonrpc: "2.0", id: 2, method: "tools/list" }) - client.send_request(request: { - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, - }) - - refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") }) - end - def test_send_request_parses_sse_response_when_adapter_does_not_stream sse_body = "event: message\n" \ 'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \ @@ -1323,131 +1081,6 @@ module MCP assert_equal(100, received_params["maxTokens"]) end - def test_send_request_answers_a_server_ping_with_an_empty_result - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/call", - params: { name: "some_tool", arguments: {} }, - } - - server_ping = { jsonrpc: "2.0", id: 7, method: "ping" } - tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } - sse_body = <<~BODY - event: message - data: #{server_ping.to_json} - - event: message - data: #{tool_result.to_json} - - BODY - - stub_request(:post, url).with( - body: request.to_json, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: sse_body, - ) - - pong = { jsonrpc: "2.0", id: 7, result: {} } - pong_stub = stub_request(:post, url).with( - body: pong.to_json, - ).to_return(status: 202, body: "") - - response = client.send_request(request: request) - - assert_equal({ "content" => [] }, response["result"]) - assert_requested(pong_stub) - end - - def test_a_registered_ping_handler_overrides_the_default_pong - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/call", - params: { name: "some_tool", arguments: {} }, - } - - server_ping = { jsonrpc: "2.0", id: 8, method: "ping" } - tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } - sse_body = <<~BODY - event: message - data: #{server_ping.to_json} - - event: message - data: #{tool_result.to_json} - - BODY - - stub_request(:post, url).with( - body: request.to_json, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: sse_body, - ) - - expected_response = { jsonrpc: "2.0", id: 8, result: { custom: "pong" } } - response_stub = stub_request(:post, url).with( - body: expected_response.to_json, - ).to_return(status: 202, body: "") - - client.on_server_request("ping") { { custom: "pong" } } - - response = client.send_request(request: request) - - assert_equal({ "content" => [] }, response["result"]) - assert_requested(response_stub) - end - - def test_a_ping_with_a_malformed_id_is_answered_with_method_not_found - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/call", - params: { name: "some_tool", arguments: {} }, - } - - # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames at schema - # validation, so the default pong does not fire and the frame falls through. - server_ping = { jsonrpc: "2.0", id: { x: 1 }, method: "ping" } - tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } - sse_body = <<~BODY - event: message - data: #{server_ping.to_json} - - event: message - data: #{tool_result.to_json} - - BODY - - stub_request(:post, url).with( - body: request.to_json, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: sse_body, - ) - - expected_error = { - jsonrpc: "2.0", - id: { x: 1 }, - error: { - code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, - message: "Method not found: ping", - }, - } - error_stub = stub_request(:post, url).with( - body: expected_error.to_json, - ).to_return(status: 202, body: "") - - response = client.send_request(request: request) - - assert_equal({ "content" => [] }, response["result"]) - assert_requested(error_stub) - end - def test_send_request_answers_unregistered_server_request_with_method_not_found request = { jsonrpc: "2.0", @@ -1775,52 +1408,6 @@ module MCP client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) end - def test_adopts_a_counter_offered_protocol_version_from_the_handshake - # A server may answer `initialize` with a version other than the one offered, and everything - # after the handshake speaks the answer rather than the offer. This server counter-offers - # a handshake version to clients asking `initialize` for a modern one, which only resolves - # anything because clients behave this way. - counter_offered = "2025-06-18" - refute_equal( - counter_offered, - MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, - "the counter-offer has to differ from the offer for this to test anything", - ) - - offered = nil - stub_request(:post, url).with do |req| - body = JSON.parse(req.body) - offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" - body["method"] == "initialize" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, - body: { result: { protocolVersion: counter_offered } }.to_json, - ) - stub_notification - - client.connect - - assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) - assert_equal(counter_offered, client.protocol_version) - - # Scoped to the body as well: `notifications/initialized` already went out under - # the adopted version, and a header-only stub would count that too. - header_stub = stub_request(:post, url).with( - headers: { "MCP-Protocol-Version" => counter_offered }, - ) do |req| - JSON.parse(req.body)["method"] == "tools/list" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, - ) - - client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) - - assert_requested(header_stub) - end - def test_does_not_send_protocol_version_header_before_initialize stub_request(:post, url) .with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") } @@ -2075,14 +1662,14 @@ module MCP .with do |req| body = JSON.parse(req.body) body["method"] == "initialize" && - body["params"]["protocolVersion"] == MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION && + body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION && body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } && body["params"]["capabilities"] == {} end .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json, + body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json, ) client.connect @@ -2118,74 +1705,6 @@ module MCP assert_requested(notification_stub) end - def test_connect_offers_the_latest_handshake_version_by_default - # The handshake negotiates legacy versions only (SEP-2575 era model); modern versions are - # selected via `mode: :modern`/`:auto`, so the default offer is the latest handshake version. - offered = nil - init_stub = stub_request(:post, url).with do |req| - body = JSON.parse(req.body) - offered = body.dig("params", "protocolVersion") if body["method"] == "initialize" - body["method"] == "initialize" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, - ) - stub_notification - - client.connect - - assert_requested(init_stub) - assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) - end - - def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result - # A server answering `initialize` with a modern version is refused like an unknown one: - # the handshake settles on a legacy version by definition, and the TypeScript and Python clients - # refuse a modern counter-offer the same way. - init_stub = stub_request(:post, url) - .with { |req| JSON.parse(req.body)["method"] == "initialize" } - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2026-07-28" } }.to_json, - ) - - error = assert_raises(RequestHandlerError) do - client.connect - end - - assert_includes(error.message, "2026-07-28") - assert_requested(init_stub) - end - - def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake - error = assert_raises(ArgumentError) do - client.connect(protocol_version: "2026-07-28", mode: :legacy) - end - - assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") - end - - def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older - notification_stub = stub_notification - - init_stub = stub_request(:post, url) - .with { |req| JSON.parse(req.body)["method"] == "initialize" } - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, - ) - - assert_no_deprecation_warning do - client.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) - end - - assert_requested(init_stub) - assert_requested(notification_stub) - end - def test_connect_is_idempotent init_stub = stub_initialize notification_stub = stub_notification @@ -2285,140 +1804,6 @@ module MCP refute_predicate(client, :connected?) end - def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle - discover_stub = stub_discover - - result = client.connect(mode: :modern) - - assert_requested(discover_stub) - assert_predicate(client, :connected?) - assert_predicate(client, :modern?) - assert_equal(["2026-07-28"], result["supportedVersions"]) - assert_equal(result, client.server_info) - assert_equal("2026-07-28", client.protocol_version) - end - - def test_modern_requests_carry_the_envelope_and_matching_header - stub_discover - client.connect( - mode: :modern, - client_info: { name: "my-app", version: "9.9" }, - capabilities: { elicitation: {} }, - ) - - request_stub = stub_request(:post, url).with do |req| - body = JSON.parse(req.body) - meta = body.dig("params", "_meta") - body["method"] == "tools/list" && - req.headers["Mcp-Protocol-Version"] == "2026-07-28" && - meta == { - "io.modelcontextprotocol/protocolVersion" => "2026-07-28", - "io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" }, - "io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} }, - } - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, - ) - - client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - - assert_requested(request_stub) - end - - def test_connect_modern_fails_without_a_mutual_modern_version - stub_request(:post, url).with do |req| - JSON.parse(req.body)["method"] == "server/discover" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, - ) - - error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) } - - assert_includes(error.message, "no mutually supported modern protocol version") - refute_predicate(client, :connected?) - refute_predicate(client, :modern?) - end - - def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors - stub_request(:post, url).with do |req| - JSON.parse(req.body)["method"] == "server/discover" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { error: { code: -32601, message: "Method not found" } }.to_json, - ) - init_stub = stub_initialize - notification_stub = stub_notification - - result = client.connect(mode: :auto) - - assert_requested(init_stub) - assert_requested(notification_stub) - refute_predicate(client, :modern?) - assert_predicate(client, :connected?) - assert_equal("2025-11-25", result["protocolVersion"]) - end - - def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version - # Rollout tolerance: a server may answer discovery while only serving legacy versions; - # auto mode falls back to the legacy handshake. - stub_request(:post, url).with do |req| - JSON.parse(req.body)["method"] == "server/discover" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, - ) - init_stub = stub_initialize - stub_notification - - client.connect(mode: :auto) - - assert_requested(init_stub) - refute_predicate(client, :modern?) - assert_predicate(client, :connected?) - end - - def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode - assert_raises(ArgumentError) { client.connect(mode: :bogus) } - assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") } - end - - def test_connect_auto_propagates_the_discovery_failure_for_an_explicitly_modern_version - # An explicitly requested modern version is never downgraded by the fallback: - # the legacy handshake cannot negotiate it, so the probe's failure is the real answer. - stub_request(:post, url).with do |req| - JSON.parse(req.body)["method"] == "server/discover" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, - ) - - error = assert_raises(RequestHandlerError) do - client.connect(mode: :auto, protocol_version: "2026-07-28") - end - - assert_includes(error.message, "no mutually supported modern protocol version") - refute_predicate(client, :connected?) - end - - def test_connect_modern_warns_for_deprecated_capabilities - # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every - # modern connection speaks. - stub_discover - - assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do - client.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) - end - - assert_predicate(client, :modern?) - end - def test_reconnect_after_close stub_initialize stub_notification @@ -2524,24 +1909,6 @@ module MCP .to_return(status: 202, body: "") end - def stub_discover - stub_request(:post, url).with do |req| - JSON.parse(req.body)["method"] == "server/discover" - end.to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: { - result: { - supportedVersions: ["2026-07-28"], - capabilities: { tools: {} }, - _meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } }, - ttlMs: 0, - cacheScope: "private", - }, - }.to_json, - ) - end - def stub_request(method, url) WebMock.stub_request(method, url) end @@ -2560,44 +1927,6 @@ module MCP WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive? end - # Builds an `HTTP` client over the Faraday test adapter for the SEP-2243 mirroring tests: - # the stub serves `server/discover` (so `connect(mode: :modern)` works), answers `tools/list` - # with `tools` (an Array, or a Proc returning the listing per request), and captures - # the request headers of any other POST via `on_call`. - def mcp_param_test_client(tools:, on_call:) - stubs = Faraday::Adapter::Test::Stubs.new do |stub| - stub.post("/") do |env| - case JSON.parse(env.request_body)["method"] - when "server/discover" - discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" } - [200, { "Content-Type" => "application/json" }, { result: discover }.to_json] - when "tools/list" - listing = tools.respond_to?(:call) ? tools.call : tools - [200, { "Content-Type" => "application/json" }, { result: { tools: listing } }.to_json] - else - on_call.call(env.request_headers) - [200, { "Content-Type" => "application/json" }, { result: { content: [] } }.to_json] - end - end - end - HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } - end - - def mcp_param_annotated_tool - { - name: "test_custom_headers", - inputSchema: { - type: "object", - properties: { - region: { type: "string", "x-mcp-header": "Region" }, - priority: { type: "integer", "x-mcp-header": "Priority" }, - non_ascii_val: { type: "string", "x-mcp-header": "NonAsciiVal" }, - null_val: { type: "string", "x-mcp-header": "NullVal" }, - }, - }, - } - end - def url "http://example.com" end @@ -2605,35 +1934,6 @@ module MCP def client @client ||= HTTP.new(url: url) end - - # The SEP-1699 reconnection exchange: a `tools/call` whose SSE stream carries a priming event - # and the given `retry:` before closing, and a GET that replays the result for `Last-Event-ID`. - def reconnection_request - { - jsonrpc: "2.0", - id: "test_id", - method: "tools/call", - params: { name: "test_reconnection", arguments: {} }, - } - end - - def stub_reconnection_with_retry(retry_ms) - stub_request(:post, url).with( - body: reconnection_request.to_json, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: "id: event-1\nretry: #{retry_ms}\ndata:\n\n", - ) - - stub_request(:get, url).with( - headers: { "Last-Event-ID" => "event-1" }, - ).to_return( - status: 200, - headers: { "Content-Type" => "text/event-stream" }, - body: %(data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}\n\n), - ) - end end end end diff --git a/test/mcp/client/mcp_param_headers_test.rb b/test/mcp/client/mcp_param_headers_test.rb deleted file mode 100644 index 639a0ab..0000000 --- a/test/mcp/client/mcp_param_headers_test.rb +++ /dev/null @@ -1,208 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "mcp/client/mcp_param_headers" - -module MCP - class Client - class McpParamHeadersTest < Minitest::Test - def test_scan_collects_declarations_from_properties - scan = McpParamHeaders.scan({ - "type" => "object", - "properties" => { - "region" => { "type" => "string", "x-mcp-header" => "Region" }, - "priority" => { "type" => "integer", "x-mcp-header" => "Priority" }, - "verbose" => { "type" => "boolean", "x-mcp-header" => "Verbose" }, - "plain" => { "type" => "string" }, - }, - }) - - assert(scan[:valid]) - assert_equal( - [ - { path: ["region"], header_name: "Region", type: "string" }, - { path: ["priority"], header_name: "Priority", type: "integer" }, - { path: ["verbose"], header_name: "Verbose", type: "boolean" }, - ], - scan[:declarations], - ) - end - - def test_scan_collects_nested_declarations_and_symbol_keys - scan = McpParamHeaders.scan({ - type: "object", - properties: { - options: { - type: "object", - properties: { - region: { type: "string", "x-mcp-header": "Region" }, - }, - }, - }, - }) - - assert(scan[:valid]) - assert_equal([{ path: ["options", "region"], header_name: "Region", type: "string" }], scan[:declarations]) - end - - def test_scan_accepts_number_typed_declarations - scan = McpParamHeaders.scan({ - "type" => "object", - "properties" => { "float_val" => { "type" => "number", "x-mcp-header" => "FloatVal" } }, - }) - - assert(scan[:valid]) - assert_equal("number", scan[:declarations].first[:type]) - end - - def test_scan_rejects_an_annotation_at_the_schema_root - scan = McpParamHeaders.scan({ "type" => "object", "x-mcp-header" => "Root" }) - - refute(scan[:valid]) - assert_includes(scan[:reason], "statically reachable") - end - - def test_scan_rejects_annotations_under_non_reachable_keywords - ["oneOf", "items", "$defs"].each do |keyword| - nested = { "type" => "string", "x-mcp-header" => "Hidden" } - sub = keyword == "$defs" ? { "entry" => nested } : [nested] - scan = McpParamHeaders.scan({ "type" => "object", keyword => sub }) - - refute(scan[:valid], "expected the annotation under #{keyword} to invalidate the schema") - end - end - - def test_scan_rejects_empty_and_non_string_annotations - ["", 42].each do |annotation| - scan = McpParamHeaders.scan({ - "type" => "object", - "properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } }, - }) - - refute(scan[:valid]) - assert_includes(scan[:reason], "non-empty string") - end - end - - def test_scan_rejects_non_token_annotation_names - ["has space", "colon:name", "日本語", "line\nbreak"].each do |annotation| - scan = McpParamHeaders.scan({ - "type" => "object", - "properties" => { "value" => { "type" => "string", "x-mcp-header" => annotation } }, - }) - - refute(scan[:valid], "expected #{annotation.inspect} to be rejected") - assert_includes(scan[:reason], "RFC 9110") - end - end - - def test_scan_rejects_non_primitive_declaring_properties - [{ "type" => "object" }, { "type" => "array" }, {}].each do |extra| - scan = McpParamHeaders.scan({ - "type" => "object", - "properties" => { "value" => extra.merge("x-mcp-header" => "Value") }, - }) - - refute(scan[:valid]) - assert_includes(scan[:reason], "primitive-typed") - end - end - - def test_scan_rejects_case_insensitively_duplicated_names - scan = McpParamHeaders.scan({ - "type" => "object", - "properties" => { - "one" => { "type" => "string", "x-mcp-header" => "Region" }, - "two" => { "type" => "string", "x-mcp-header" => "REGION" }, - }, - }) - - refute(scan[:valid]) - assert_includes(scan[:reason], "case-insensitively unique") - end - - def test_primitive_to_string_conversions - assert_equal("us-west1", McpParamHeaders.primitive_to_string("us-west1")) - assert_equal("true", McpParamHeaders.primitive_to_string(true)) - assert_equal("false", McpParamHeaders.primitive_to_string(false)) - assert_equal("42", McpParamHeaders.primitive_to_string(42)) - assert_equal("42", McpParamHeaders.primitive_to_string(42.0)) - assert_equal("3.14159", McpParamHeaders.primitive_to_string(3.14159)) - assert_nil(McpParamHeaders.primitive_to_string(2**53 + 1)) - assert_nil(McpParamHeaders.primitive_to_string(Float::INFINITY)) - assert_nil(McpParamHeaders.primitive_to_string({ "nested" => true })) - end - - def test_encode_value_passes_safe_ascii_through - assert_equal("us-west1", McpParamHeaders.encode_value("us-west1")) - assert_equal("SELECT * FROM users", McpParamHeaders.encode_value("SELECT * FROM users")) - end - - def test_encode_value_wraps_unsafe_values_in_the_base64_sentinel - { - "" => "", - "Hello, 世界" => "Hello, 世界", - " padded " => " padded ", - "\tindented" => "\tindented", - "line1\nline2" => "line1\nline2", - "line1\r\nline2" => "line1\r\nline2", - "=?base64?Zm9v?=" => "=?base64?Zm9v?=", - }.each do |raw, decoded| - encoded = McpParamHeaders.encode_value(raw) - - assert_match(/\A=\?base64\?.*\?=\z/, encoded, "expected #{raw.inspect} to be wrapped") - payload = encoded.delete_prefix("=?base64?").delete_suffix("?=") - assert_equal(decoded, payload.unpack1("m0").force_encoding(Encoding::UTF_8)) - end - end - - def test_build_mirrors_declared_arguments_into_prefixed_headers - declarations = [ - { path: ["region"], header_name: "Region", type: "string" }, - { path: ["priority"], header_name: "Priority", type: "integer" }, - { path: ["method_val"], header_name: "Method", type: "string" }, - ] - - headers = McpParamHeaders.build(declarations, { "region" => "us-west1", priority: 42, "method_val" => "test-method" }) - - assert_equal( - { - "Mcp-Param-Region" => "us-west1", - "Mcp-Param-Priority" => "42", - "Mcp-Param-Method" => "test-method", - }, - headers, - ) - end - - def test_build_omits_null_absent_and_non_primitive_values - declarations = [ - { path: ["null_val"], header_name: "NullVal", type: "string" }, - { path: ["absent"], header_name: "Absent", type: "string" }, - { path: ["object_val"], header_name: "ObjectVal", type: "string" }, - ] - - headers = McpParamHeaders.build(declarations, { "null_val" => nil, "object_val" => { "a" => 1 } }) - - assert_empty(headers) - end - - def test_build_reads_nested_paths - declarations = [{ path: ["options", "region"], header_name: "Region", type: "string" }] - - headers = McpParamHeaders.build(declarations, { "options" => { "region" => "us-east1" } }) - - assert_equal({ "Mcp-Param-Region" => "us-east1" }, headers) - end - - def test_build_omits_values_that_cannot_be_represented_as_utf_8 - declarations = [{ path: ["blob"], header_name: "Blob", type: "string" }] - binary = (+"\xff\xfe").force_encoding(Encoding::ASCII_8BIT) - - headers = McpParamHeaders.build(declarations, { "blob" => binary }) - - assert_empty(headers) - end - end - end -end diff --git a/test/mcp/client/oauth/bounded_body_test.rb b/test/mcp/client/oauth/bounded_body_test.rb deleted file mode 100644 index aeaf118..0000000 --- a/test/mcp/client/oauth/bounded_body_test.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "faraday" -require "socket" -require "stringio" -require "zlib" -require "mcp/client/oauth" - -module MCP - class Client - module OAuth - class BoundedBodyTest < Minitest::Test - def test_on_data_rejects_chunks_that_together_exceed_the_limit - bounded = bounded_body(max_bytes: 64) - - error = assert_raises(too_large_error) do - 4.times do - bounded.on_data.call("a" * 32, 32, nil) - end - end - - assert_includes(error.message, "exceeds 64 bytes") - end - - def test_on_data_accepts_a_body_that_stops_at_the_limit - bounded = bounded_body(max_bytes: 64) - - 2.times do - bounded.on_data.call("a" * 32, 32, nil) - end - - assert_equal("a" * 64, bounded.response_for(stub_response(body: "")).body) - end - - def test_response_for_rejects_an_over_limit_body_when_the_adapter_did_not_stream - bounded = bounded_body(max_bytes: 64) - - error = assert_raises(too_large_error) do - bounded.response_for(stub_response(body: "a" * 65)) - end - - assert_includes(error.message, "exceeds 64 bytes") - end - - def test_response_for_falls_back_to_the_response_body_when_the_adapter_did_not_stream - bounded = bounded_body(max_bytes: 64) - - assert_equal("a" * 64, bounded.response_for(stub_response(body: "a" * 64)).body) - end - - def test_response_for_coerces_a_non_string_body - bounded = bounded_body(max_bytes: 64) - - assert_equal("", bounded.response_for(stub_response(body: nil)).body) - end - - def test_response_for_carries_the_status_through - bounded = bounded_body(max_bytes: 64) - - assert_equal(404, bounded.response_for(stub_response(status: 404, body: "")).status) - end - - # Served over a real socket rather than WebMock: WebMock hands back the stubbed body verbatim - # whatever its `Content-Encoding`, so the adapter's inflater never runs and a stubbed version - # of this test would assert the opposite of what it looks like. - def test_the_cap_counts_decompressed_bytes - bounded = bounded_body(max_bytes: 1024 * 1024) - compressed = gzip("a" * (5 * 1024 * 1024)) - - assert_operator(compressed.bytesize, :<, 1024 * 1024, "the compressed body must fit under the cap") - - serving_gzip(compressed) do |url| - assert_raises(too_large_error) do - Faraday.new.get(url) do |req| - req.options.on_data = bounded.on_data - req.options.timeout = 5 - end - end - end - end - - private - - def gzip(content) - io = StringIO.new - - writer = Zlib::GzipWriter.new(io) - writer.write(content) - writer.close - - io.string - end - - def serving_gzip(body) - # Other test files load `webmock/minitest`, which blocks real connections process-wide. - # This is the one place that needs the adapter's own request path. - WebMock.disable! if defined?(WebMock) - - server = TCPServer.new("127.0.0.1", 0) - thread = Thread.new do - socket = server.accept - - loop do - line = socket.gets - - break if line.nil? || line == "\r\n" - end - - socket.write( - "HTTP/1.1 200 OK\r\n" \ - "Content-Type: application/json\r\n" \ - "Content-Encoding: gzip\r\n" \ - "Content-Length: #{body.bytesize}\r\n" \ - "Connection: close\r\n\r\n", - ) - socket.write(body) - - socket.close - rescue IOError, SystemCallError - nil - end - - # Scoped to start after both are assigned, so the cleanup below never runs against `nil` - # and a failure to open the socket surfaces as itself rather than as a `NoMethodError`. - begin - yield("http://127.0.0.1:#{server.addr[1]}/") - ensure - thread.kill - server.close - end - ensure - WebMock.enable! if defined?(WebMock) - end - - def bounded_body(max_bytes:) - OAuth.const_get(:BoundedBody).new(max_bytes: max_bytes) - end - - def too_large_error - OAuth.const_get(:BoundedBody)::TooLargeError - end - - def stub_response(body:, status: 200) - Struct.new(:status, :body).new(status, body) - end - end - end - end -end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb index 6c679e3..c112aba 100644 --- a/test/mcp/client/oauth/client_credentials_provider_test.rb +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -7,23 +7,6 @@ module MCP class Client module OAuth class ClientCredentialsProviderTest < Minitest::Test - # Every parameter the flow sets on a token request, spelled out rather than read from - # `Flow::RESERVED_TOKEN_REQUEST_PARAMS`, so a key dropped from that constant fails here. - RESERVED_TOKEN_REQUEST_PARAMS = [ - "grant_type", - "client_id", - "client_secret", - "client_assertion", - "client_assertion_type", - "scope", - "resource", - "code", - "code_verifier", - "redirect_uri", - "refresh_token", - "assertion", - ].freeze - def test_initialize_stores_credentials_as_client_information provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") @@ -49,25 +32,6 @@ module MCP assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"]) end - def test_initialize_keeps_the_http_client_customizer - customizer = ->(_faraday) {} - provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", http_client_customizer: customizer) - - assert_same(customizer, provider.http_client_customizer) - assert_nil(ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret").http_client_customizer) - end - - def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials - storage = InMemoryStorage.new - - error = assert_raises(ArgumentError) do - ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", storage: storage, http_client_customizer: "recorder") - end - - assert_equal("http_client_customizer must respond to call (got String).", error.message) - assert_nil(storage.client_information) - end - def test_initialize_rejects_missing_client_id ["", " ", nil].each do |value| assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do @@ -196,90 +160,6 @@ module MCP assert_equal("cc-client", claims["sub"]) assert_equal("https://auth.example.com", claims["aud"]) end - - def test_token_request_params_is_nil_by_default - provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") - - assert_nil(provider.token_request_params) - end - - def test_initialize_keeps_a_frozen_copy_of_token_request_params - params = { "audience" => +"https://api.example.com" } - provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params) - - params["audience"] << "/changed" - params["organization"] = "org_123" - - assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) - assert_predicate(provider.token_request_params, :frozen?) - assert_predicate(provider.token_request_params["audience"], :frozen?) - end - - def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself - RESERVED_TOKEN_REQUEST_PARAMS.each do |key| - error = assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{key.inspect}") do - ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" }) - end - - assert_includes(error.message, key.inspect) - end - end - - def test_initialize_rejects_token_request_params_that_are_not_a_hash_of_strings - ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }].each do |params| - assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{params.inspect}") do - ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params) - end - end - end - - def test_initialize_rejects_token_request_params_that_compare_keys_by_identity - # Two equal keys are two entries in such a Hash and would silently collapse into one when copied. - error = assert_raises(Flow::InvalidTokenRequestParamsError) do - ClientCredentialsProvider.new( - client_id: "cc-client", - client_secret: "cc-secret", - token_request_params: { "audience" => "x" }.compare_by_identity, - ) - end - - assert_match(/identity/, error.message) - end - - def test_initialize_copies_keys_that_hash_would_share_with_the_caller - # `Hash` copies and freezes only keys whose class is exactly `String`; a subclass key would otherwise - # stay shared with the caller, and a later `replace` would turn it into a reserved name. - key = Class.new(String).new("audience") - provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" }) - - key.replace("grant_type") - - assert_equal({ "audience" => "x" }, provider.token_request_params) - refute_same(key, provider.token_request_params.keys.first) - assert_predicate(provider.token_request_params.keys.first, :frozen?) - end - - def test_initialize_keeps_empty_token_request_params_as_a_frozen_empty_hash - provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: {}) - - assert_equal({}, provider.token_request_params) - assert_predicate(provider.token_request_params, :frozen?) - end - - def test_initialize_writes_no_client_information_when_token_request_params_are_rejected - storage = InMemoryStorage.new - - assert_raises(Flow::InvalidTokenRequestParamsError) do - ClientCredentialsProvider.new( - client_id: "cc-client", - client_secret: "cc-secret", - storage: storage, - token_request_params: { "grant_type" => "password" }, - ) - end - - assert_nil(storage.client_information) - end end end end diff --git a/test/mcp/client/oauth/cross_app_access_provider_test.rb b/test/mcp/client/oauth/cross_app_access_provider_test.rb index ed49b47..deb7e4b 100644 --- a/test/mcp/client/oauth/cross_app_access_provider_test.rb +++ b/test/mcp/client/oauth/cross_app_access_provider_test.rb @@ -49,36 +49,6 @@ module MCP ) end - def test_initialize_keeps_the_http_client_customizer - customizer = ->(_faraday) {} - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag" }, - http_client_customizer: customizer, - ) - - assert_same(customizer, provider.http_client_customizer) - assert_nil(build_provider.http_client_customizer) - end - - def test_initialize_rejects_a_non_callable_http_client_customizer_before_writing_credentials - storage = InMemoryStorage.new - - error = assert_raises(ArgumentError) do - CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag" }, - storage: storage, - http_client_customizer: "recorder", - ) - end - - assert_equal("http_client_customizer must respond to call (got String).", error.message) - assert_nil(storage.client_information) - end - def test_initialize_rejects_missing_client_id assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do CrossAppAccessProvider.new( @@ -118,42 +88,6 @@ module MCP provider.clear_tokens! assert_nil(provider.tokens) end - - def test_token_request_params_is_nil_by_default - assert_nil(build_provider.token_request_params) - end - - def test_initialize_keeps_a_frozen_copy_of_token_request_params - params = { "audience" => +"https://api.example.com" } - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag" }, - token_request_params: params, - ) - - params["audience"] << "/changed" - - assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) - assert_predicate(provider.token_request_params, :frozen?) - end - - def test_initialize_writes_no_client_information_when_token_request_params_are_rejected - storage = InMemoryStorage.new - - error = assert_raises(Flow::InvalidTokenRequestParamsError) do - CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag" }, - storage: storage, - token_request_params: { "assertion" => "x" }, - ) - end - - assert_includes(error.message, '"assertion"') - assert_nil(storage.client_information) - end end end end diff --git a/test/mcp/client/oauth/discovery_test.rb b/test/mcp/client/oauth/discovery_test.rb index d58c7f2..4b29184 100644 --- a/test/mcp/client/oauth/discovery_test.rb +++ b/test/mcp/client/oauth/discovery_test.rb @@ -259,19 +259,6 @@ module MCP ) end - def test_redact_url_drops_credentials_query_and_fragment_but_keeps_the_spelling - # Reported next to a failed request, the URL must still match the server's access log, so the host - # and path are left alone: no lowercasing of the host, no dot-segment resolution, no decoding. - assert_equal( - "https://Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson", - Discovery.redact_url("https://user:pass@Srv.Example.COM:8443/tenant/%2e%2e/prm%2Ejson?token=abc#frag"), - ) - end - - def test_redact_url_does_not_echo_a_url_it_cannot_parse - assert_equal("[unparseable URL]", Discovery.redact_url("https://user:pass@srv.example.com/pr m.json?token=abc")) - end - def test_canonicalize_url_normalizes_query_to_match_faraday # Faraday rewrites `env.url` before sending a request: it sorts # parameters by name, uppercases percent-encoded hex, and drops @@ -450,121 +437,6 @@ module MCP refute(Discovery.secure_url?("not a url")) end - def test_same_origin_accepts_matching_scheme_host_and_port - assert(Discovery.same_origin?("https://srv.example.com/.well-known/x", "https://srv.example.com/mcp")) - assert(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com:8443/b")) - assert(Discovery.same_origin?("http://localhost:9292/a", "http://localhost:9292/mcp")) - assert(Discovery.same_origin?("https://srv.example.com/a?q=1#f", "https://srv.example.com/mcp")) - end - - def test_same_origin_ignores_case_in_scheme_and_host - assert(Discovery.same_origin?("HTTPS://SRV.Example.COM/a", "https://srv.example.com/mcp")) - end - - def test_same_origin_treats_an_explicit_default_port_as_the_implicit_one - assert(Discovery.same_origin?("https://srv.example.com:443/a", "https://srv.example.com/mcp")) - assert(Discovery.same_origin?("http://srv.example.com:80/a", "http://srv.example.com/mcp")) - end - - def test_same_origin_rejects_differing_components - refute(Discovery.same_origin?("https://attacker.example.com/a", "https://srv.example.com/mcp")) - refute(Discovery.same_origin?("https://169.254.169.254/latest/", "https://srv.example.com/mcp")) - refute(Discovery.same_origin?("https://srv.example.com:8443/a", "https://srv.example.com/mcp")) - refute(Discovery.same_origin?("http://srv.example.com/a", "https://srv.example.com/mcp")) - # A subdomain is a different origin, so a hijacked challenge cannot walk - # sideways into a sibling host that shares the registrable domain. - refute(Discovery.same_origin?("https://evil.srv.example.com/a", "https://srv.example.com/mcp")) - end - - def test_same_origin_rejects_malformed_or_hostless_urls - refute(Discovery.same_origin?("not a url", "https://srv.example.com/mcp")) - refute(Discovery.same_origin?("https://srv.example.com/a", "not a url")) - refute(Discovery.same_origin?("https://", "https://srv.example.com/mcp")) - refute(Discovery.same_origin?(nil, "https://srv.example.com/mcp")) - refute(Discovery.same_origin?("/relative/path", "https://srv.example.com/mcp")) - end - - def test_private_network_host_recognizes_reserved_ipv4_ranges - assert(Discovery.private_network_host?("0.0.0.0")) - assert(Discovery.private_network_host?("10.0.0.5")) - assert(Discovery.private_network_host?("100.64.0.1")) - assert(Discovery.private_network_host?("127.0.0.1")) - # The cloud metadata address this check exists for. - assert(Discovery.private_network_host?("169.254.169.254")) - assert(Discovery.private_network_host?("172.16.0.1")) - assert(Discovery.private_network_host?("172.31.255.255")) - assert(Discovery.private_network_host?("192.168.1.1")) - end - - def test_private_network_host_recognizes_reserved_ipv6_ranges - assert(Discovery.private_network_host?("::")) - assert(Discovery.private_network_host?("::1")) - assert(Discovery.private_network_host?("[::1]")) - assert(Discovery.private_network_host?("fd00::1")) - assert(Discovery.private_network_host?("fe80::1")) - assert(Discovery.private_network_host?("[fe80::1]")) - end - - def test_private_network_host_unwraps_ipv4_mapped_ipv6 - # `::ffff:169.254.169.254` reaches the same interface as the IPv4 spelling, - # so the mapped form must not be a way around the ranges above. - assert(Discovery.private_network_host?("::ffff:169.254.169.254")) - assert(Discovery.private_network_host?("[::ffff:10.0.0.5]")) - assert(Discovery.private_network_host?("::ffff:127.0.0.1")) - refute(Discovery.private_network_host?("::ffff:93.184.216.34")) - end - - def test_private_network_host_refuses_non_canonical_ipv4_spellings - # `inet_aton` accepts forms `IPAddr` rejects, and the resolver reaches the same - # interfaces through them: `2130706433` and `127.1` are 127.0.0.1, and - # `0xa9fea9fe` is the cloud metadata address. Checking only the dotted-decimal - # spelling would refuse `169.254.169.254` and admit every alias of it. - assert(Discovery.private_network_host?("2130706433")) - assert(Discovery.private_network_host?("0x7f000001")) - assert(Discovery.private_network_host?("0xa9fea9fe")) - assert(Discovery.private_network_host?("127.1")) - assert(Discovery.private_network_host?("127.0.1")) - assert(Discovery.private_network_host?("0x7f.0.0.1")) - assert(Discovery.private_network_host?("0251.0376.0251.0376")) - end - - def test_private_network_host_refuses_numeric_spellings_whose_value_is_resolver_dependent - # `010.0.0.1` is 8.0.0.1 read as octal and 10.0.0.1 read as decimal, and platforms - # disagree on which it is. Both readings are refused rather than one being chosen, - # since the reading not taken would otherwise be a way through. - assert(Discovery.private_network_host?("010.0.0.1")) - assert(Discovery.private_network_host?("0177.0.0.1")) - - # The same applies to numeric spellings that land outside the blocked ranges, or - # nowhere at all: nothing is addressed this way on purpose. - assert(Discovery.private_network_host?("0x8080808")) - assert(Discovery.private_network_host?("256.1.1.1")) - end - - def test_private_network_host_recognizes_localhost_by_name_only - assert(Discovery.private_network_host?("localhost")) - assert(Discovery.private_network_host?("LOCALHOST")) - refute(Discovery.private_network_host?("foo.localhost")) - refute(Discovery.private_network_host?("localhost.attacker.com")) - end - - def test_private_network_host_rejects_public_addresses_and_names - refute(Discovery.private_network_host?("93.184.216.34")) - refute(Discovery.private_network_host?("8.8.8.8")) - refute(Discovery.private_network_host?("172.32.0.1")) - refute(Discovery.private_network_host?("2606:4700::1111")) - refute(Discovery.private_network_host?("srv.example.com")) - refute(Discovery.private_network_host?(nil)) - refute(Discovery.private_network_host?("")) - end - - def test_private_network_host_does_not_resolve_names - # Documents the deliberate limit of the predicate: an internal host that is - # named rather than addressed reads as public here. `same_origin?` is what - # keeps the `resource_metadata` URL from reaching one. - refute(Discovery.private_network_host?("vault.corp.internal")) - end - def test_client_id_metadata_document_url_accepts_https_with_path assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json")) assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm")) diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index c13b49b..f27679b 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -68,192 +68,14 @@ module MCP WebMock.reset! end - # A plain authorization-code provider for the destination-check tests. - # They all expect to fail before any redirect happens, so the handlers stay inert; - # a test that reached them would be asserting the wrong thing. - def ssrf_test_provider - Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) {}, - callback_handler: -> { ["code", "state"] }, - ) - end - - def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil, http_client_customizer: nil) + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") ClientCredentialsProvider.new( client_id: "cc-client", client_secret: "cc-secret", token_endpoint_auth_method: token_endpoint_auth_method, - token_request_params: token_request_params, - http_client_customizer: http_client_customizer, ) end - # An authorization-code provider that drives the code exchange and refresh with the given parameters. - def provider_with_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] }) - Provider.new(**authorization_code_provider_arguments(redirect_handler, callback_handler), token_request_params: params) - end - - # Overriding the reader bypasses the constructor's validation, leaving the flow's own check to be exercised. - def provider_returning_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] }) - provider_class = Class.new(Provider) do - define_method(:token_request_params) { params } - end - - provider_class.new(**authorization_code_provider_arguments(redirect_handler, callback_handler)) - end - - def authorization_code_provider_arguments(redirect_handler, callback_handler) - { - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: redirect_handler, - callback_handler: callback_handler, - } - end - - # See `provider_returning_token_request_params`. - def client_credentials_provider_returning(token_request_params:) - provider_class = Class.new(ClientCredentialsProvider) do - define_method(:token_request_params) { token_request_params } - end - - provider_class.new(client_id: "cc-client", client_secret: "cc-secret") - end - - # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); - # fall back to the pre-3.0 API on older Rubies. - def generate_es256_key - if OpenSSL::PKey::EC.respond_to?(:generate) - OpenSSL::PKey::EC.generate("prime256v1") - else - OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) - end - end - - # Records `[method, url]` for every request that passes through, standing in for the tracing middleware - # an application adds through `http_client_customizer`. - class RecordingMiddleware - def initialize(app, log) - @app = app - @log = log - end - - def call(env) - @log << [env.method, env.url.to_s] - @app.call(env) - end - end - - # Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind: - # the flow asked for one URL and the adapter is handed another. - class URLRewritingMiddleware - def initialize(app, from:, to:) - @app = app - @from = from - @to = to - end - - def call(env) - env.url = URI(@to) if env.url.to_s == @from - @app.call(env) - end - end - - # Replaces the per-request context the way instrumentation middleware may; the guard must not depend on it. - class ContextReplacingMiddleware - def initialize(app) - @app = app - end - - def call(env) - env.request.context = { tag: "instrumented" } - @app.call(env) - end - end - - # Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever - # an earlier middleware recorded on the original. - class EnvRebuildingMiddleware - def initialize(app) - @app = app - end - - def call(env) - @app.call(Faraday::Env.from(env.to_h)) - end - end - - # Follows a `3xx` the way `faraday-follow_redirects` does: duplicates the environment, points it at the `Location`, - # and re-enters the stack from its own position. - class RedirectFollowingMiddleware - def initialize(app) - @app = app - end - - def call(env) - response = @app.call(env) - location = response.headers["Location"] - return response unless location && (300..399).cover?(response.status) - - redirected = env.dup - redirected.url = URI(location) - redirected.response = nil - @app.call(redirected) - end - end - - # Replaces the request options wholesale, the way middleware that resets timeouts or contexts might. - class RequestOptionsReplacingMiddleware - def initialize(app) - @app = app - end - - def call(env) - env.request = Faraday::RequestOptions.new - @app.call(env) - end - end - - # Re-enters the stack once with the same environment after a `5xx`, the way retry middleware does. - class RetryingMiddleware - def initialize(app) - @app = app - end - - def call(env) - response = @app.call(env) - return response unless response.status >= 500 - - env.response = nil - @app.call(env) - end - end - - # Records the application's `trace_id` from the per-request context, as a tracing middleware would. - class ContextRecordingMiddleware - def initialize(app, log) - @app = app - @log = log - end - - def call(env) - @log << env.request.context&.dig(:trace_id) - @app.call(env) - end - end - # Runs the full authorization flow and returns the `scope` query parameter # sent on the authorization request. The caller stubs the AS metadata; # this helper supplies a provider whose `grant_types` and optional pre-set @@ -303,278 +125,6 @@ module MCP end end - def test_run_sends_every_authorization_server_request_through_the_provider_customizer - log = [] - provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) }) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_equal([[:get, @prm_url], [:get, @as_metadata_url], [:post, "#{@auth_base}/token"]], log) - - # The SDK's defaults are applied before the customizer runs. - assert_requested(:get, @prm_url, headers: { "Accept" => "application/json" }) - end - - def test_run_refuses_a_customized_connection_that_sends_a_request_off_the_origin - stub_request(:get, "https://other.example.com/prm.json").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") }, - ) - - error = assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_equal(<<~MESSAGE, error.message) - Request to \"#{@prm_url}\" would be sent to \"https://other.example.com/prm.json\", on a different origin; \ - middleware that follows redirects or rewrites URLs is refused. - MESSAGE - - # The guard sits before the adapter, so the request never leaves. - assert_not_requested(:get, "https://other.example.com/prm.json") - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_run_keeps_a_same_origin_rewrite - moved_url = "https://srv.example.com/prm-moved.json" - stub_request(:get, moved_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: moved_url) }, - ) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_requested(:get, moved_url) - end - - def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_context - stub_request(:get, "https://other.example.com/prm.json").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { - faraday.use(ContextReplacingMiddleware) - faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") - }, - ) - - assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:get, "https://other.example.com/prm.json") - end - - def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_ahead_of_the_stamp - # `builder.insert(0, ...)` is where middleware that wants to be outermost puts itself, so a follower can - # land ahead of the SDK's stamp and re-enter the stack; the record of the first URL must survive that. - stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" }) - stub_request(:get, "https://other.example.com/prm.json").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) }, - ) - - error = assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) - assert_not_requested(:get, "https://other.example.com/prm.json") - end - - def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_ahead_of_the_stamp - moved_url = "https://srv.example.com/prm-moved.json" - stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url }) - stub_request(:get, moved_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) }, - ) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_requested(:get, moved_url) - end - - def test_run_refuses_a_request_whose_environment_was_rebuilt - provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) }) - - error = assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_equal(<<~MESSAGE, error.message) - Request to \"#{@prm_url}\" carries no record of the URL the flow asked for; \ - middleware that rebuilds the request environment is refused. - MESSAGE - assert_not_requested(:get, @prm_url) - end - - def test_run_refuses_a_rewritten_token_request - stub_request(:post, "https://other.example.com/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "stolen", token_type: "Bearer"), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { - faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token") - }, - ) - - assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:post, "https://other.example.com/token") - assert_not_requested(:post, "#{@auth_base}/token") - assert_nil(provider.access_token) - end - - def test_run_keeps_the_application_request_context_for_every_request - trace_ids = [] - - result = run_authorization_flow( - http_client_customizer: ->(faraday) { - faraday.options.context = { trace_id: "incident-123" } - faraday.use(ContextRecordingMiddleware, trace_ids) - }, - ) - - assert_equal(:authorized, result) - # Protected Resource Metadata and authorization server metadata (GET), registration (JSON POST), - # and the token exchange (form POST) all carry it. - assert_equal(["incident-123"] * 4, trace_ids) - end - - def test_run_keeps_the_application_request_context_on_a_factory_connection - trace_ids = [] - factory = -> { - Faraday.new do |faraday| - faraday.options.context = { trace_id: "incident-123" } - faraday.use(ContextRecordingMiddleware, trace_ids) - end - } - - result = Flow.new(provider: client_credentials_provider, http_client_factory: factory).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_equal(["incident-123"] * 3, trace_ids) - end - - def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_options - stub_request(:get, "https://other.example.com/prm.json").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider( - http_client_customizer: ->(faraday) { - faraday.use(RequestOptionsReplacingMiddleware) - faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") - }, - ) - - error = assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - # The mismatch, not a lost record: replacing the options must leave the record in place. - assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) - assert_not_requested(:get, "https://other.example.com/prm.json") - end - - def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_after_the_stamp - stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" }) - stub_request(:get, "https://other.example.com/prm.json").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) }) - - error = assert_raises(Flow::DestinationMismatchError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - # The mismatch, not a lost record: the duplicate the follower re-enters with shares the record. - assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message) - assert_not_requested(:get, "https://other.example.com/prm.json") - end - - def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_after_the_stamp - moved_url = "https://srv.example.com/prm-moved.json" - stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url }) - stub_request(:get, moved_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) }) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_requested(:get, moved_url) - end - - def test_run_keeps_a_retry_by_middleware_placed_ahead_of_the_stamp - # The same environment re-enters the stack with the same URL, so the first record still matches. - stub_request(:get, @prm_url).to_return( - { status: 503 }, - { - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - }, - ) - provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.builder.insert(0, RetryingMiddleware) }) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_requested(:get, @prm_url, times: 2) - end - - def test_build_http_client_records_each_request_on_its_own - # A caller using the SDK-built connection directly may address different origins from one request - # to the next; each request gets its own record. - stub_request(:get, "https://a.example.com/one").to_return(status: 200, body: "one") - stub_request(:get, "https://b.example.com/two").to_return(status: 200, body: "two") - connection = Flow.build_http_client - - assert_equal("one", connection.get("https://a.example.com/one").body) - assert_equal("two", connection.get("https://b.example.com/two").body) - end - - def test_run_prefers_an_explicit_http_client_factory_over_the_customizer - provider = client_credentials_provider(http_client_customizer: ->(_faraday) { raise "the customizer must not run" }) - - result = Flow.new(provider: provider, http_client_factory: -> { Faraday.new }).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - end - def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body # `client_secret_post` puts the credentials in the form body rather # than an HTTP Basic header (RFC 6749 Section 2.3.1). @@ -591,21 +141,6 @@ module MCP end end - def test_run_client_credentials_with_client_secret_basic_omits_client_id_from_body - # Explicit Basic authentication must keep both credentials in the header - # to avoid servers treating body credentials as a second auth method. - provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") && - !form.key?("client_id") && - !form.key?("client_secret") - end - end - def test_run_client_credentials_raises_clean_error_when_client_information_missing # The constructor always stores credentials, but if a custom storage loses them # the grant must fail with a domain error rather than a `NoMethodError` from @@ -683,10 +218,17 @@ module MCP ), ) + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); + # fall back to the pre-3.0 API on older Rubies. + key = if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end provider = ClientCredentialsProvider.new( client_id: "cc-client", token_endpoint_auth_method: "private_key_jwt", - private_key: generate_es256_key, + private_key: key, signing_algorithm: "ES256", ) @@ -711,190 +253,6 @@ module MCP end end - def test_run_client_credentials_sends_token_request_params_with_client_secret_basic - provider = client_credentials_provider(token_request_params: { "audience" => "https://api.example.com" }) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "client_credentials" && - form["resource"] == "https://srv.example.com/mcp" && - form["audience"] == "https://api.example.com" && - !form.key?("client_id") && - req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") - end - end - - def test_run_client_credentials_sends_token_request_params_returned_by_a_provider_method - provider = client_credentials_provider_returning(token_request_params: { "audience" => "https://api.example.com" }) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - URI.decode_www_form(req.body).to_h["audience"] == "https://api.example.com" - end - end - - def test_run_client_credentials_sends_the_same_request_for_empty_token_request_params - provider = client_credentials_provider(token_request_params: {}) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - URI.decode_www_form(req.body).map(&:first) == ["grant_type", "resource"] - end - end - - def test_run_client_credentials_sends_token_request_params_with_client_secret_post - provider = client_credentials_provider( - token_endpoint_auth_method: "client_secret_post", - token_request_params: { "audience" => "https://api.example.com" }, - ) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["audience"] == "https://api.example.com" && - form["client_id"] == "cc-client" && - form["client_secret"] == "cc-secret" - end - end - - def test_run_client_credentials_sends_token_request_params_with_private_key_jwt - provider = ClientCredentialsProvider.new( - client_id: "cc-client", - token_endpoint_auth_method: "private_key_jwt", - private_key: generate_es256_key, - signing_algorithm: "ES256", - token_request_params: { "audience" => "https://api.example.com" }, - ) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["audience"] == "https://api.example.com" && - form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && - !form["client_assertion"].to_s.empty? - end - end - - def test_run_sends_token_request_params_on_the_authorization_code_exchange - state_value = nil - provider = provider_with_token_request_params( - { "audience" => "https://api.example.com" }, - redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", state_value] }, - ) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "authorization_code" && - form["code"] == "test-auth-code" && - form["audience"] == "https://api.example.com" - end - end - - def test_refresh_sends_token_request_params - provider = provider_with_token_request_params({ "audience" => "https://api.example.com" }) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:refreshed, result) - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "refresh_token" && - form["refresh_token"] == "saved-rt" && - form["audience"] == "https://api.example.com" - end - end - - def test_run_sends_token_request_params_on_the_jwt_bearer_grant - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag-assertion" }, - token_request_params: { "audience" => "https://api.example.com" }, - ) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && - form["assertion"] == "id-jag-assertion" && - form["audience"] == "https://api.example.com" - end - end - - def test_run_refuses_token_request_params_that_name_a_reserved_parameter - provider = client_credentials_provider_returning(token_request_params: { "grant_type" => "password" }) - - error = assert_raises(Flow::InvalidTokenRequestParamsError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/"grant_type"/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_run_refuses_token_request_params_that_are_not_a_hash_of_strings - shapes = ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }, { "audience" => "x" }.compare_by_identity] - shapes.each do |params| - provider = client_credentials_provider_returning(token_request_params: params) - - assert_raises(Flow::InvalidTokenRequestParamsError, "should refuse #{params.inspect}") do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - end - - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_refresh_refuses_token_request_params_that_name_a_reserved_parameter - # Not an `AuthorizationError`: the transport treats a failed refresh as a reason - # to run the interactive flow, which would fail the same way afterwards. - provider = provider_returning_token_request_params({ "refresh_token" => "other" }) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - error = assert_raises(Flow::InvalidTokenRequestParamsError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/"refresh_token"/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_run_refuses_token_request_params_on_the_authorization_code_exchange - state_value = nil - provider = provider_returning_token_request_params( - { "code" => "other" }, - redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", state_value] }, - ) - - error = assert_raises(Flow::InvalidTokenRequestParamsError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/"code"/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - end - def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider # SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at # the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved. @@ -942,138 +300,6 @@ module MCP assert_not_requested(:post, "#{@auth_base}/token") end - def test_refresh_uses_the_stored_credentials_of_a_cross_app_access_provider - # RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden; - # when the authorization server issued one, it is exchanged with the stored client secret - # and no new ID-JAG assertion is minted. - assertion_calls = 0 - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { - assertion_calls += 1 - "id-jag-assertion" - }, - ) - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) - - result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:refreshed, result) - assert_equal("test-token-from-flow", provider.access_token) - assert_equal(@auth_base, provider.tokens["issuer"]) - assert_equal(0, assertion_calls) - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "refresh_token" && - form["refresh_token"] == "saved-rt" && - !form.key?("assertion") && - req.headers["Authorization"] == "Basic " + Base64.strict_encode64("xaa-client:xaa-secret") - end - end - - def test_refresh_uses_the_stored_credentials_of_a_client_credentials_provider - # RFC 6749 Section 4.4.3 says a refresh token SHOULD NOT be issued for this grant, - # so one in storage is unusual, but it must be exchanged rather than crash the refresh. - provider = client_credentials_provider - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) - - result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:refreshed, result) - assert_equal(@auth_base, provider.tokens["issuer"]) - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "refresh_token" && - form["refresh_token"] == "saved-rt" && - req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") - end - end - - def test_refresh_refuses_an_authorization_server_that_did_not_issue_a_cross_app_access_token - # The token-level issuer binding is what protects these providers: their stored `client_information` carries no `issuer`, - # so `ensure_refreshable_client_information!` has nothing to compare. - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag-assertion" }, - ) - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => "https://old-as.example.com") - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/issued by a different authorization server/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - def test_refresh_refuses_tokens_without_a_recorded_issuer_for_the_client_credentials_and_jwt_bearer_providers - # These providers had no refresh before their tokens recorded the issuer, so nothing older needs - # tolerating; without the record a refresh would go to whatever server discovery names, unasked. - cross_app = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag-assertion" }, - ) - [cross_app, client_credentials_provider].each do |provider| - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - error = assert_raises(Flow::AuthorizationError, "should refuse #{provider.class}") do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/record no issuer/, error.message) - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - assert_not_requested(:get, @prm_url) - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_refresh_raises_cleanly_for_a_provider_with_neither_client_information_nor_a_cimd_url - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { "id-jag-assertion" }, - ) - provider.storage.save_client_information(nil) - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/no client_information/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_run_registers_through_dcr_for_a_provider_without_a_cimd_url_reader - # Registration reads the CIMD URL through the same duck-typed helper as refresh, - # so a provider that lacks the reader goes to Dynamic Client Registration. - provider_class = Class.new(Provider) { undef_method :client_id_metadata_document_url } - state_value = nil - provider = provider_class.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", state_value] }, - ) - - result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:authorized, result) - assert_requested(:post, "#{@auth_base}/register") - end - def test_run_uses_authorization_code_grant_for_default_provider # A standard `Provider` declares `authorization_flow == :authorization_code`, # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. @@ -1103,7 +329,7 @@ module MCP # the Dynamic Client Registration request body. The default loopback redirect URI # exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises # the `"web"` inference. - def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}, http_client_customizer: nil) + def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {}) state_holder = {} provider = Provider.new( client_metadata: { @@ -1115,113 +341,11 @@ module MCP redirect_uri: redirect_uri, redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, callback_handler: -> { ["test-auth-code", state_holder[:state]] }, - http_client_customizer: http_client_customizer, ) Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) end - # Runs the authorization-code flow with an `authorization_request_validator` that records what it - # was handed and answers `approve`. - private def run_flow_with_validator(approve:, recorder: []) - state_holder = {} - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { - recorder << :redirected - state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") - }, - callback_handler: -> { ["test-auth-code", state_holder[:state]] }, - authorization_request_validator: ->(request) { - recorder << request - approve - }, - ) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - def test_run_hands_the_authorization_server_and_scopes_to_the_validator - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - resource: @server_url, - authorization_servers: [@auth_base], - scopes_supported: ["Mail.Read", "Files.ReadWrite.All"], - ), - ) - - recorder = [] - run_flow_with_validator(approve: true, recorder: recorder) - - request = recorder.first - - assert_equal(@auth_base, request.authorization_server) - assert_equal(["Mail.Read", "Files.ReadWrite.All"], request.scopes) - assert_equal(@server_url, request.server_url) - assert_equal("https://srv.example.com/mcp", request.resource) - end - - def test_run_refuses_the_flow_and_registers_nothing_when_the_validator_declines - recorder = [] - - error = assert_raises(Flow::AuthorizationRefusedError) do - run_flow_with_validator(approve: false, recorder: recorder) - end - - assert_match(/refused by `authorization_request_validator`/, error.message) - - # Refused before registration, so nothing of this client is left at the authorization server - # the host just rejected, and no browser was opened. - assert_not_requested(:post, "#{@auth_base}/register") - refute_includes(recorder, :redirected) - end - - def test_run_client_credentials_consults_the_validator - provider = ClientCredentialsProvider.new( - client_id: "cc-client", - client_secret: "cc-secret", - authorization_request_validator: ->(_request) { false }, - ) - - error = assert_raises(Flow::AuthorizationRefusedError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/refused by `authorization_request_validator`/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - end - - def test_run_jwt_bearer_consults_the_validator - minted = false - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(audience:, resource:) { - minted = true - "id-jag-assertion" - }, - authorization_request_validator: ->(_request) { false }, - ) - - error = assert_raises(Flow::AuthorizationRefusedError) do - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/refused by `authorization_request_validator`/, error.message) - assert_not_requested(:post, "#{@auth_base}/token") - - # Refused before the identity provider was asked for an assertion carrying this authorization server as its audience. - refute(minted, "the ID-JAG assertion must not be minted for a refused authorization server") - end - def test_run_registers_native_application_type_for_loopback_redirect_uri run_authorization_flow @@ -1602,9 +726,9 @@ module MCP end def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object - # Valid JSON but the wrong shape counts as nothing usable being published, so it selects the legacy 2025-03-26 path - # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing a domain error rather than - # a raw `TypeError` from indexing the array. + # Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path + # (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing + # a domain error rather than a raw `TypeError` from indexing the array. stub_request(:get, @prm_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -1636,211 +760,6 @@ module MCP assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") end - def test_run_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back - # A request that never reached the server says nothing about whether it publishes PRM, so once - # the other candidates answer 404 the flow stops instead of moving to the legacy authorization base; - # the TypeScript and Python SDKs propagate the error the same way. - challenge_url = "https://srv.example.com/prm.json" - stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) - stub_prm_not_found - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) - end - - assert_match(/Faraday::ConnectionFailed/, error.message) - assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") - assert_not_requested(:post, "https://srv.example.com/register") - end - - def test_run_reports_a_prm_candidate_url_without_its_query_in_the_discovery_error - # The candidate URL comes from the server's `WWW-Authenticate` challenge and lands in every log line - # the error reaches, so the query is dropped, while the path keeps the spelling that was requested - # (`%2E` is not decoded) so the line can be matched against the server's access log. - challenge_url = "https://srv.example.com/prm%2Ejson?token=abc" - stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) - stub_prm_not_found - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) - end - - assert_includes(error.message, "GET https://srv.example.com/prm%2Ejson raised Faraday::ConnectionFailed: connection refused; GET") - refute_includes(error.message, "token=abc") - end - - def test_run_cuts_an_overlong_prm_candidate_url_in_the_discovery_error - # The challenge URL is the server's to choose, so its length is bounded before it lands in the message. - challenge_url = "https://srv.example.com/#{"a" * 5000}/prm.json" - stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused")) - stub_prm_not_found - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) - end - - assert_includes(error.message, "GET #{challenge_url[0, Flow::METADATA_URL_MAX_LENGTH - 3]}... raised Faraday::ConnectionFailed") - refute_includes(error.message, "a" * (Flow::METADATA_URL_MAX_LENGTH - 3)) - end - - def test_run_bounds_the_transport_error_message_in_the_discovery_error - # A caller-supplied client can raise with server-chosen text in the message (Faraday's `raise_error` middleware repeats - # the status line and the request URL), so it is bounded like the parser's message. - stub_request(:get, @prm_url).to_raise(Faraday::ConnectionFailed.new("x" * 5000)) - stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_includes(error.message, "raised Faraday::ConnectionFailed: #{"x" * 125}...; GET") - refute_includes(error.message, "x" * 126) - end - - def test_run_bounds_the_parser_message_in_the_discovery_error - # json before 2.10 repeats the remaining source in `JSON::ParserError#message`, so a malformed body - # could otherwise put up to the response cap into the message once per candidate. - stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "{") - stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 503) - JSON.stubs(:parse).raises(JSON::ParserError, "unexpected token at '#{"{" * 5000}'") - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - # `METADATA_DIAGNOSTIC_MAX_LENGTH` is 128: the first 125 characters survive, then three dots. - assert_includes(error.message, <<~MESSAGE.chomp) - returned a body that is not JSON: unexpected token at '#{"{" * 104}...; \ - GET https://srv.example.com/.well-known/oauth-protected-resource returned 503. - MESSAGE - - refute_includes(error.message, "{" * 105) - end - - def test_run_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back - # A `5xx` or `429` says nothing about what the server publishes either, even when a later candidate answers 404; - # the Python SDK refuses the legacy path the same way. - [503, 429].each do |status| - stub_request(:get, @prm_url).to_return(status: status) - stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) - - error = assert_raises(Flow::MetadataUnreachableError, "status #{status}") do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/returned #{status}/, error.message) - end - - assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") - assert_not_requested(:post, "https://srv.example.com/register") - end - - def test_run_keeps_a_server_error_in_mind_when_a_later_candidate_is_unusable - # An unusable document from a later candidate must not turn an earlier `503` into "nothing published". - challenge_url = "https://srv.example.com/prm.json" - stub_request(:get, challenge_url).to_return(status: 503) - stub_request(:get, @prm_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") - stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) - end - - assert_match(/returned 503/, error.message) - assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") - end - - def test_run_uses_a_later_prm_candidate_when_the_challenge_url_serves_an_unusable_document - # The setup stubs a valid document at the well-known path; a broken one at the challenge URL is skipped. - challenge_url = "https://srv.example.com/prm.json" - stub_request(:get, challenge_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") - - result = Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url) - - assert_equal(:authorized, result) - assert_requested(:get, @prm_url) - end - - def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_a_network_error - # The OAuth document being unreachable must not stop the OpenID one from being tried. - stub_request(:get, @as_metadata_url).to_raise(Faraday::TimeoutError.new("execution expired")) - stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - registration_endpoint: "#{@auth_base}/register", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - - result = run_authorization_flow - - assert_equal(:authorized, result) - assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration") - end - - def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_an_unusable_document - # An OAuth document that is not JSON, or not a JSON object, must not stop the OpenID one from being tried. - stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - registration_endpoint: "#{@auth_base}/register", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - - ["{", "[]"].each do |body| - stub_request(:get, @as_metadata_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body) - - assert_equal(:authorized, run_authorization_flow, "body #{body.inspect}") - end - - assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration", times: 2) - end - - def test_refresh_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back - stub_request(:get, @prm_url).to_raise(Faraday::TimeoutError.new("execution expired")) - stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) - provider = build_legacy_discovery_provider({}) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") - assert_not_requested(:post, "https://srv.example.com/token") - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - def test_refresh_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back - stub_request(:get, @prm_url).to_return(status: 503) - stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) - provider = build_legacy_discovery_provider({}) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - error = assert_raises(Flow::MetadataUnreachableError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/returned 503/, error.message) - assert_not_requested(:post, "https://srv.example.com/token") - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - # Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert # which endpoint was used. def build_legacy_discovery_provider(holder) @@ -1865,22 +784,22 @@ module MCP stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404) end - # Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin with the given `issuer` - # and the OAuth endpoints under a path prefix. - def stub_legacy_metadata_with_prefixed_endpoints(issuer:, iss_supported: false) + def test_run_falls_back_to_server_origin_metadata_without_prm + # Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin, + # OAuth endpoints under a path prefix whose `issuer` differs from the discovery origin. + # The legacy path must not apply the RFC 8414 issuer byte-match (the legacy spec predates it). stub_prm_not_found stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate( - issuer: issuer, + issuer: "https://srv.example.com/oauth", authorization_endpoint: "https://srv.example.com/oauth/authorize", token_endpoint: "https://srv.example.com/oauth/token", registration_endpoint: "https://srv.example.com/oauth/register", response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], - authorization_response_iss_parameter_supported: iss_supported, ), ) stub_request(:post, "https://srv.example.com/oauth/register").to_return( @@ -1893,11 +812,6 @@ module MCP headers: { "Content-Type" => "application/json" }, body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), ) - end - - def test_run_falls_back_to_server_origin_metadata_without_prm - # The document names the origin, so the RFC 8414 Section 3.3 check passes and its prefixed endpoints are used. - stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com") holder = {} provider = build_legacy_discovery_provider(holder) @@ -1906,54 +820,11 @@ module MCP assert_equal(:authorized, result) assert_equal("legacy-token", provider.access_token) - assert_equal("https://srv.example.com", provider.tokens["issuer"]) assert_equal("/oauth/authorize", holder[:authorization_url].path) assert_requested(:post, "https://srv.example.com/oauth/register") assert_requested(:post, "https://srv.example.com/oauth/token") end - def test_run_accepts_legacy_metadata_naming_the_origin_with_a_trailing_slash - # A root issuer rendered as `https://host/` names the same server; the TypeScript and Python SDKs accept it too, - # and the document's spelling is what the RFC 9207 `iss` and the recorded issuer carry. - stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/", iss_supported: true) - - holder = {} - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", holder[:state], "https://srv.example.com/"] }, - ) - - result = Flow.new(provider: provider).run!(server_url: @server_url) - - assert_equal(:authorized, result) - assert_equal("https://srv.example.com/", provider.tokens["issuer"]) - end - - def test_run_refuses_legacy_metadata_whose_issuer_is_not_the_origin - # RFC 8414 Section 3.3 applies on the legacy path as well: the 2025-03-26 spec places the metadata - # at the origin, so an issuer under a path prefix is a mismatch, as it is for the TypeScript and Python SDKs. - stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/oauth") - - holder = {} - provider = build_legacy_discovery_provider(holder) - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).run!(server_url: @server_url) - end - - assert_match(/`issuer` does not match/, error.message) - assert_nil(holder[:authorization_url]) - assert_not_requested(:post, "https://srv.example.com/oauth/register") - assert_not_requested(:post, "https://srv.example.com/oauth/token") - end - def test_run_falls_back_to_default_endpoints_without_any_metadata # Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata, # the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256. @@ -1987,75 +858,6 @@ module MCP end end - def test_run_client_credentials_without_prm_uses_the_legacy_authorization_base - # A PRM-less server takes the legacy path for this grant too: metadata at the origin, no advertised scopes, - # so the provider's own scope is used and the tokens are bound to the origin. - stub_prm_not_found - stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: "https://srv.example.com", - token_endpoint: "https://srv.example.com/token", - token_endpoint_auth_methods_supported: ["client_secret_basic"], - ), - ) - stub_request(:post, "https://srv.example.com/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), - ) - provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", scope: "mcp:read") - - result = Flow.new(provider: provider).run!(server_url: @server_url) - - assert_equal(:authorized, result) - assert_equal("legacy-token", provider.access_token) - assert_equal("https://srv.example.com", provider.tokens["issuer"]) - assert_requested(:post, "https://srv.example.com/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "client_credentials" && - form["scope"] == "mcp:read" && - form["resource"] == "https://srv.example.com/mcp" - end - end - - def test_run_jwt_bearer_without_any_metadata_uses_the_legacy_default_endpoints - stub_prm_not_found - stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404) - stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404) - stub_request(:post, "https://srv.example.com/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), - ) - received = nil - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(audience:, resource:) { - received = { audience: audience, resource: resource } - "id-jag-assertion" - }, - ) - - result = Flow.new(provider: provider).run!(server_url: @server_url) - - assert_equal(:authorized, result) - # The assertion is minted for the legacy authorization base, which is the only identity such a server has. - assert_equal({ audience: "https://srv.example.com", resource: "https://srv.example.com/mcp" }, received) - assert_equal("https://srv.example.com", provider.tokens["issuer"]) - assert_requested(:post, "https://srv.example.com/token") do |req| - form = URI.decode_www_form(req.body).to_h - - form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" && - form["assertion"] == "id-jag-assertion" && - form["resource"] == "https://srv.example.com/mcp" && - !form.key?("scope") - end - end - def test_run_legacy_fallback_rejects_insecure_authorization_base # The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not # become the authorization base URL. @@ -2097,98 +899,6 @@ module MCP assert_match(/`issuer` does not match/, error.message) end - # Legacy metadata served at the MCP server origin that claims the identity of another authorization server, - # with every endpoint at the origin itself. The claim fails the RFC 8414 check, so none of these endpoints is reached. - def stub_legacy_metadata_claiming(issuer) - stub_prm_not_found - stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: issuer, - authorization_endpoint: "https://srv.example.com/authorize", - token_endpoint: "https://srv.example.com/token", - registration_endpoint: "https://srv.example.com/register", - code_challenge_methods_supported: ["S256"], - ), - ) - stub_request(:post, "https://srv.example.com/register").to_return( - status: 201, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(client_id: "legacy-client"), - ) - stub_request(:post, "https://srv.example.com/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600), - ) - end - - def test_run_refuses_legacy_metadata_claiming_another_authorization_server_before_the_validator - stub_legacy_metadata_claiming(@auth_base) - recorder = [] - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) { recorder << :redirected }, - callback_handler: -> { ["test-auth-code", "state"] }, - authorization_request_validator: ->(request) { - recorder << request - true - }, - ) - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).run!(server_url: @server_url) - end - - assert_match(/`issuer` does not match/, error.message) - assert_empty(recorder) - assert_not_requested(:post, "https://srv.example.com/register") - assert_not_requested(:post, "https://srv.example.com/token") - end - - def test_refresh_refuses_legacy_metadata_claiming_the_issuer_that_minted_the_tokens - # The tokens came from https://auth.example.com; a PRM-less server claiming that issuer at its own origin - # must not receive them, so the refresh token stays for the server that issued it. - stub_legacy_metadata_claiming(@auth_base) - provider = build_legacy_discovery_provider({}) - provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base) - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).refresh!(server_url: @server_url) - end - - assert_match(/`issuer` does not match/, error.message) - assert_not_requested(:post, "https://srv.example.com/token") - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - def test_run_does_not_present_client_information_bound_elsewhere_when_legacy_metadata_claims_that_issuer - # Client information issued by https://auth.example.com is bound to it (SEP-2352); a PRM-less server claiming - # that issuer is refused before the credentials could be presented anywhere. - stub_legacy_metadata_claiming(@auth_base) - holder = {} - provider = build_legacy_discovery_provider(holder) - provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret", "issuer" => @auth_base) - - assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).run!(server_url: @server_url) - end - - assert_nil(holder[:authorization_url]) - assert_not_requested(:post, "https://srv.example.com/register") - assert_not_requested(:post, "https://srv.example.com/token") - assert_equal("conf-secret", provider.client_information["client_secret"]) - assert_equal(@auth_base, provider.client_information["issuer"]) - end - def test_run_raises_when_prm_authorization_servers_is_not_an_array # `authorization_servers` MUST be an Array per RFC 9728. A misbehaving # PRM that returns a String would otherwise reach `.first` and raise @@ -2221,71 +931,6 @@ module MCP assert_match(/authorization_servers/i, error.message) end - def test_run_refuses_an_authorization_server_metadata_body_over_the_cap - stub_request(:get, @as_metadata_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: over_cap_body, - ) - - error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } - - assert_match(/exceeds \d+ bytes/, error.message) - end - - def test_run_refuses_a_protected_resource_metadata_body_over_the_cap - # An oversized document is refused outright rather than treated as unpublished, so the flow stops - # without the legacy fallback. The padded document is valid JSON naming an authorization server, - # so contacting that server is exactly what would happen if the body had been read: - # the assertion below fails if the cap stops working. - stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404) - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - resource: "https://srv.example.com/mcp", - authorization_servers: [@auth_base], - padding: "a" * (4 * 1024 * 1024), - ), - ) - - error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } - - assert_match(/exceeds \d+ bytes/, error.message) - assert_not_requested(:get, @as_metadata_url) - assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server") - end - - def test_run_refuses_a_dynamic_client_registration_body_over_the_cap - stub_request(:post, "#{@auth_base}/register").to_return( - status: 201, - headers: { "Content-Type" => "application/json" }, - body: over_cap_body, - ) - - error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } - - assert_match(/exceeds \d+ bytes/, error.message) - end - - def test_run_refuses_a_token_endpoint_body_over_the_cap - stub_request(:post, "#{@auth_base}/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: over_cap_body, - ) - - error = assert_raises(Flow::AuthorizationError) { run_authorization_flow } - - assert_match(/exceeds \d+ bytes/, error.message) - end - - # One byte past the cap, so the three request wrappers are each proven to install the streaming callback. - # A wrapper that forgot it would buffer this whole body and parse it. - def over_cap_body - "a" * (4 * 1024 * 1024 + 1) - end - def test_run_raises_when_token_response_is_not_a_json_object # The token endpoint MUST return a JSON object per RFC 6749 §5.1. # A non-object body would otherwise be persisted into the provider @@ -2357,7 +1002,6 @@ module MCP headers: { "Content-Type" => "application/json" }, body: "null", ) - stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").to_return(status: 404) provider = Provider.new( client_metadata: { @@ -3035,10 +1679,8 @@ module MCP expected = "Basic " + Base64.strict_encode64("pre-registered-client:pre-registered-secret") assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h req.headers["Authorization"] == expected && - !form.key?("client_id") && - !form.key?("client_secret") + !URI.decode_www_form(req.body).to_h.key?("client_secret") end end @@ -3102,212 +1744,10 @@ module MCP assert_match(/resource_metadata|HTTPS|Communication Security/i, error.message) end - def test_run_does_not_fetch_a_resource_metadata_url_off_the_server_origin - # A server that answers 401 chooses the `resource_metadata` URL, and discovery puts it first - # in the candidate list. Left unchecked, a single response header aims the client's first OAuth request - # at any host it can route to, including the cloud metadata address. The assertion that matters is not - # the raise but `assert_not_requested`: nothing downstream can un-send a request. - metadata_url = "https://169.254.169.254/latest/meta-data/iam/" - stub_request(:get, metadata_url).to_return(status: 200, body: "{}") - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: ssrf_test_provider).run!( - server_url: @server_url, - resource_metadata_url: metadata_url, - ) - end - - assert_not_requested(:get, metadata_url) - assert_match(/resource_metadata/i, error.message) - assert_match(/origin/i, error.message) - end - - def test_run_does_not_fetch_an_https_resource_metadata_url_on_a_foreign_public_origin - # The scheme check alone passes this one: it is HTTPS, just not the server's. - metadata_url = "https://attacker.example.com/.well-known/oauth-protected-resource" - stub_request(:get, metadata_url).to_return(status: 200, body: "{}") - - assert_raises(Flow::AuthorizationError) do - Flow.new(provider: ssrf_test_provider).run!( - server_url: @server_url, - resource_metadata_url: metadata_url, - ) - end - - assert_not_requested(:get, metadata_url) - end - - def test_refresh_does_not_fetch_a_resource_metadata_url_off_the_server_origin - # `refresh!` reads the same header on a 401 and must gate it identically; - # otherwise the token-refresh path stays reachable after `run!` is closed. - metadata_url = "https://169.254.169.254/latest/meta-data/iam/" - stub_request(:get, metadata_url).to_return(status: 200, body: "{}") - - provider = ssrf_test_provider - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).refresh!( - server_url: @server_url, - resource_metadata_url: metadata_url, - ) - end - - assert_not_requested(:get, metadata_url) - end - - def test_run_does_not_contact_an_authorization_server_on_a_private_address - # One hop deeper than the `resource_metadata` URL: the server serves its own PRM from its own origin, - # then names a private address as its authorization server. Same-origin cannot see this one, - # so the range check has to. - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - resource: @server_url, - authorization_servers: ["https://169.254.169.254"], - ), - ) - stub_request(:get, %r{\Ahttps://169\.254\.169\.254/}).to_return(status: 200, body: "{}") - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:get, %r{\Ahttps://169\.254\.169\.254/}) - assert_match(/private network/i, error.message) - end - - def test_run_does_not_contact_an_authorization_server_spelled_as_a_packed_integer - # `0xa9fea9fe` is 169.254.169.254 written for `inet_aton`. The connection resolves it to - # the same address, so the previous test's protection has to survive the rewrite. - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - resource: @server_url, - authorization_servers: ["https://0xa9fea9fe"], - ), - ) - stub_request(:get, %r{\Ahttps://0xa9fea9fe/}).to_return(status: 200, body: "{}") - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:get, %r{\Ahttps://0xa9fea9fe/}) - assert_match(/private network/i, error.message) - end - - def test_run_does_not_post_to_a_token_endpoint_on_a_private_address - # The token endpoint receives the client credentials and the authorization code, - # so an AS document that relocates it inside the client's network is a credential disclosure, - # not just a probe. - stub_request(:get, @as_metadata_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "https://10.0.0.5/token", - registration_endpoint: "#{@auth_base}/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - stub_request(:post, "https://10.0.0.5/token").to_return(status: 200, body: "{}") - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:post, "https://10.0.0.5/token") - assert_match(/private network/i, error.message) - end - - def test_run_does_not_post_to_a_registration_endpoint_on_a_private_address - stub_request(:get, @as_metadata_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - registration_endpoint: "https://192.168.1.1/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - stub_request(:post, "https://192.168.1.1/register").to_return(status: 201, body: "{}") - - assert_raises(Flow::AuthorizationError) do - Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_not_requested(:post, "https://192.168.1.1/register") - end - - def test_run_allows_a_private_authorization_server_when_the_mcp_server_is_also_private - # The shape the conformance harness runs and the shape local development takes: - # the MCP server and the authorization server sit on two loopback ports. - # Once the caller has pointed the client at a private address, the authorization server - # living there too is not an escalation, and refusing it would break every local setup. - server_url = "http://localhost:9292/mcp" - prm_url = "http://localhost:9292/.well-known/oauth-protected-resource/mcp" - auth_base = "http://localhost:9393" - - stub_request(:get, prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: server_url, authorization_servers: [auth_base]), - ) - stub_request(:get, "#{auth_base}/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: auth_base, - authorization_endpoint: "#{auth_base}/authorize", - token_endpoint: "#{auth_base}/token", - registration_endpoint: "#{auth_base}/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - stub_request(:post, "#{auth_base}/register").to_return( - status: 201, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(client_id: "local-client"), - ) - stub_request(:post, "#{auth_base}/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "local-token", token_type: "Bearer", expires_in: 3600), - ) - - state_holder = {} - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", state_holder[:state]] }, - ) - - result = Flow.new(provider: provider).run!(server_url: server_url, resource_metadata_url: prm_url) - - assert_equal(:authorized, result) - assert_equal("local-token", provider.access_token) - end - def test_run_rejects_when_as_metadata_issuer_does_not_match - # RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal the discovery URL. - # A metadata document advertising a different issuer than the one PRM pointed at is treated - # as a confused-deputy attempt. + # RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal + # the discovery URL. A metadata document advertising a different issuer + # than the one PRM pointed at is treated as a confused-deputy attempt. stub_request(:get, @as_metadata_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -3341,8 +1781,9 @@ module MCP end def test_run_rejects_when_as_metadata_issuer_differs_by_trailing_slash - # RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent after normalization". - # `https://auth.example.com` and `https://auth.example.com/` are different strings and MUST NOT match. + # RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent + # after normalization". `https://auth.example.com` and + # `https://auth.example.com/` are different strings and MUST NOT match. stub_request(:get, @as_metadata_url).to_return( status: 200, headers: { "Content-Type" => "application/json" }, @@ -3542,94 +1983,6 @@ module MCP assert_equal("saved-rt", provider.tokens["refresh_token"]) end - def test_refresh_with_client_secret_basic_omits_client_id_from_body - # Stored credentials without an auth method default to Basic. Refresh - # must also omit the body `client_id` when using that fallback. - stub_request(:post, "#{@auth_base}/token") - .with(body: hash_including("grant_type" => "refresh_token")) - .to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600), - ) - - provider = Provider.new( - client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) {}, - callback_handler: -> { [nil, nil] }, - ) - provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_requested(:post, "#{@auth_base}/token") do |req| - form = URI.decode_www_form(req.body).to_h - req.headers["Authorization"] == "Basic " + Base64.strict_encode64("conf-client:conf-secret") && - !form.key?("client_id") && - !form.key?("client_secret") - end - end - - def test_run_records_the_issuer_that_minted_the_tokens - state_holder = {} - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", state_holder[:state]] }, - ) - - Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(@auth_base, provider.tokens["issuer"]) - end - - def test_refresh_refuses_an_authorization_server_that_did_not_issue_the_tokens - # The stored tokens came from a different authorization server, so this refresh token must not reach the one named now. - stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("must not reach the token endpoint.")) - - provider = refresh_only_provider - provider.save_tokens( - "access_token" => "stale-at", - "refresh_token" => "saved-rt", - "issuer" => "https://issued-by.example.com", - ) - - error = assert_raises(Flow::AuthorizationError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - - assert_match(/issued by a different authorization server/, error.message) - - # Left in place: it is still good at the server that issued it, and the caller answers this by running the full authorization flow. - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - def test_refresh_proceeds_for_tokens_stored_before_the_issuer_was_recorded - stub_request(:post, "#{@auth_base}/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"), - ) - - provider = refresh_only_provider - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - - assert_equal(:refreshed, result) - - # Recorded on the way out, so the binding takes effect from here on. - assert_equal(@auth_base, provider.tokens["issuer"]) - end - def test_refresh_succeeds_when_stored_issuer_matches stub_request(:post, "#{@auth_base}/token").with( body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"), @@ -3891,161 +2244,7 @@ module MCP provider.save_client_information("client_id" => "test-client") provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt") - error = assert_raises(Flow::InvalidGrantError) do - Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) - end - assert_equal(400, error.http_status) - assert_equal("invalid_grant", error.error) - assert_equal("refresh token expired", error.error_description) - assert_equal("Token endpoint returned status 400. invalid_grant: refresh token expired", error.message) - end - - def test_token_exchange_preserves_oauth_diagnostics - stub_request(:post, "#{@auth_base}/token").to_return( - status: 400, - body: JSON.generate( - error: "invalid_request", - error_description: "Client must not use multiple authentication methods", - access_token: "do-not-log-access-token", - refresh_token: "do-not-log-refresh-token", - client_secret: "do-not-log-client-secret", - error_uri: "https://auth.example.com/error?secret=do-not-log", - ), - ) - - error = assert_raises(Flow::AuthorizationError) do - capture_authorization_scope(grant_types: ["authorization_code"]) - end - - assert_equal(400, error.http_status) - assert_equal("invalid_request", error.error) - assert_equal("Client must not use multiple authentication methods", error.error_description) - assert_equal( - "Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods", - error.message, - ) - end - - def test_refresh_preserves_oauth_diagnostics - error = refresh_token_endpoint_error( - JSON.generate(error: "invalid_client", error_description: "Client authentication failed"), - status: 401, - ) - - assert_instance_of(Flow::AuthorizationError, error) - assert_equal(401, error.http_status) - assert_equal("invalid_client", error.error) - assert_equal("Client authentication failed", error.error_description) - assert_equal("Token endpoint returned status 401. invalid_client: Client authentication failed", error.message) - end - - def test_token_endpoint_errors_fall_back_for_malformed_bodies - ["", "secret", "{broken", "null", "[]", '"secret"', "42"].each do |body| - error = refresh_token_endpoint_error(body) - - assert_instance_of(Flow::AuthorizationError, error) - assert_equal(400, error.http_status) - assert_nil(error.error) - assert_nil(error.error_description) - assert_equal("Token endpoint returned status 400.", error.message) - end - end - - def test_token_endpoint_errors_ignore_non_string_and_empty_fields - [nil, 42, true, [], { secret: "hidden" }, "", " \r\n\t"].each do |value| - error = refresh_token_endpoint_error(JSON.generate(error: value, error_description: value)) - - assert_nil(error.error) - assert_nil(error.error_description) - assert_equal("Token endpoint returned status 400.", error.message) - end - end - - def test_token_endpoint_errors_preserve_optional_fields_independently - error = refresh_token_endpoint_error(JSON.generate(error: "provider_extension")) - assert_equal("provider_extension", error.error) - assert_nil(error.error_description) - assert_equal("Token endpoint returned status 400. provider_extension", error.message) - - error = refresh_token_endpoint_error(JSON.generate(error_description: "Details without a code")) - assert_nil(error.error) - assert_equal("Details without a code", error.error_description) - assert_equal("Token endpoint returned status 400. Details without a code", error.message) - end - - def test_token_endpoint_errors_sanitize_without_changing_grant_classification - error = refresh_token_endpoint_error( - JSON.generate(error: "invalid_grant\n", error_description: "expired\r\n\t\e\u0000\"\\\u2028token"), - ) - - assert_instance_of(Flow::AuthorizationError, error) - assert_equal("invalid_grant", error.error) - assert_equal("expired token", error.error_description) - assert_equal("Token endpoint returned status 400. invalid_grant: expired token", error.message) - end - - def test_invalid_grant_with_invalid_utf8_description_preserves_classification - error = refresh_token_endpoint_error( - "{\"error\":\"invalid_grant\",\"error_description\":\"bad \xFF byte\"}".b, - ) - - assert_instance_of(Flow::InvalidGrantError, error) - assert_equal(400, error.http_status) - assert_equal("invalid_grant", error.error) - assert_equal("bad byte", error.error_description) - assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message) - end - - def test_token_endpoint_errors_scrub_invalid_utf8_in_both_fields - error = refresh_token_endpoint_error( - "{\"error\":\"invalid_grant\xFF\",\"error_description\":\"bad \xFF byte\"}".b, - ) - - assert_instance_of(Flow::AuthorizationError, error) - assert_equal(400, error.http_status) - assert_equal("invalid_grant", error.error) - assert_equal("bad byte", error.error_description) - assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message) - end - - def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises - Flow.any_instance.stubs(:bounded_diagnostic).raises(ArgumentError, "sensitive provider text") - - { "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass| - error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details")) - - assert_instance_of(klass, error) - assert_equal(400, error.http_status) - assert_nil(error.error) - assert_nil(error.error_description) - assert_equal("Token endpoint returned status 400.", error.message) - end - end - - def test_token_endpoint_errors_bound_diagnostic_lengths - error = refresh_token_endpoint_error(JSON.generate(error: "e" * 200, error_description: "d" * 1000)) - - assert_equal("#{"e" * 125}...", error.error) - assert_equal("#{"d" * 509}...", error.error_description) - assert_equal("Token endpoint returned status 400. #{error.error}: #{error.error_description}", error.message) - end - - def test_other_authorization_errors_have_no_token_endpoint_diagnostics - error = Flow::AuthorizationError.new("Discovery failed") - - assert_equal("Discovery failed", error.message) - assert_nil(error.http_status) - assert_nil(error.error) - assert_nil(error.error_description) - end - - def refresh_token_endpoint_error(body, status: 400) - stub_request(:post, "#{@auth_base}/token").to_return(status: status, body: body) - provider = ssrf_test_provider - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt") - - assert_raises(Flow::AuthorizationError) do + assert_raises(Flow::InvalidGrantError) do Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url) end end @@ -4433,20 +2632,6 @@ module MCP URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp" end end - - private - - def refresh_only_provider - provider = Provider.new( - client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) {}, - callback_handler: -> { [nil, nil] }, - ) - provider.save_client_information("client_id" => "test-client") - - provider - end end end end diff --git a/test/mcp/client/oauth/http_oauth_test.rb b/test/mcp/client/oauth/http_oauth_test.rb index dcbe302..26d074d 100644 --- a/test/mcp/client/oauth/http_oauth_test.rb +++ b/test/mcp/client/oauth/http_oauth_test.rb @@ -11,46 +11,6 @@ module MCP class Client module OAuth class HTTPOAuthTest < Minitest::Test - # Records `[method, url]` for every request that passes through, standing in for the tracing - # middleware an application adds through `http_client_customizer`. - class RecordingMiddleware - def initialize(app, log) - @app = app - @log = log - end - - def call(env) - @log << [env.method, env.url.to_s] - @app.call(env) - end - end - - # Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind. - class URLRewritingMiddleware - def initialize(app, from:, to:) - @app = app - @from = from - @to = to - end - - def call(env) - env.url = URI(@to) if env.url.to_s == @from - @app.call(env) - end - end - - # Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever - # an earlier middleware recorded on the original. - class EnvRebuildingMiddleware - def initialize(app) - @app = app - end - - def call(env) - @app.call(Faraday::Env.from(env.to_h)) - end - end - def setup WebMock.enable! @mcp_url = "https://srv.example.com/mcp" @@ -141,128 +101,6 @@ module MCP assert_equal("test-token-after-flow", provider.access_token) end - def test_send_request_runs_the_oauth_flow_through_the_provider_customizer - stub_request(:post, @mcp_url).with { |req| - req.headers["Authorization"].nil? - }.to_return( - status: 401, - headers: { - "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"), - }, - body: "", - ) - - stub_request(:post, @mcp_url).with( - headers: { "Authorization" => "Bearer test-token-after-flow" } - ).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), - ) - - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - - stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - registration_endpoint: "#{@auth_base}/register", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - - stub_request(:post, "#{@auth_base}/register").to_return( - status: 201, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(client_id: "test-client"), - ) - - stub_request(:post, "#{@auth_base}/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600), - ) - - log = [] - state_holder = {} - provider = Provider.new( - client_metadata: { - client_name: "ruby-sdk-test", - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { - state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") - }, - callback_handler: -> { ["test-auth-code", state_holder[:state]] }, - http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) }, - ) - - transport = HTTP.new(url: @mcp_url, oauth: provider) - response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - - assert_equal({ "ok" => true }, response["result"]) - assert_equal( - [ - [:get, @prm_url], - [:get, "#{@auth_base}/.well-known/oauth-authorization-server"], - [:post, "#{@auth_base}/register"], - [:post, "#{@auth_base}/token"], - ], - log, - ) - end - - def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin - # End to end over the transport, which is where the header is actually parsed: - # a server that answers 401 must not be able to name an unrelated host in - # `resource_metadata` and have the client fetch it. The request never leaving - # is the point; the 401 surfacing to the caller is secondary. - metadata_url = "https://169.254.169.254/latest/meta-data/iam/" - - stub_request(:post, @mcp_url).to_return( - status: 401, - headers: { - "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{metadata_url}"), - }, - body: "", - ) - stub_request(:get, metadata_url).to_return(status: 200, body: "{}") - - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) {}, - callback_handler: -> { ["code", "state"] }, - ) - - transport = HTTP.new(url: @mcp_url, oauth: provider) - - assert_raises(StandardError) do - transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - end - - assert_not_requested(:get, metadata_url) - end - def test_send_request_runs_step_up_flow_on_403_insufficient_scope # First call with the initial token: 403 carrying an `insufficient_scope` # Bearer challenge with the escalated scope. The transport must re-run @@ -752,207 +590,6 @@ module MCP assert_equal("refreshed-token", provider.access_token) end - def test_send_request_refreshes_for_a_cross_app_access_provider_holding_a_refresh_token - # Refresh serves every provider, not only the authorization-code one that has a CIMD URL reader. - stub_request(:post, @mcp_url).with { |req| - req.headers["Authorization"] != "Bearer refreshed-token" - }.to_return( - status: 401, - headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, - body: "", - ) - - stub_request(:post, @mcp_url).with( - headers: { "Authorization" => "Bearer refreshed-token" } - ).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), - ) - - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - - stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - token_endpoint: "#{@auth_base}/token", - grant_types_supported: ["urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"], - token_endpoint_auth_methods_supported: ["client_secret_basic"], - ), - ) - - stub_request(:post, "#{@auth_base}/token").with( - body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt") - ).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600), - ) - - assertion_calls = 0 - provider = CrossAppAccessProvider.new( - client_id: "xaa-client", - client_secret: "xaa-secret", - assertion_provider: ->(**) { - assertion_calls += 1 - "id-jag-assertion" - }, - ) - provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt", "issuer" => @auth_base) - - transport = HTTP.new(url: @mcp_url, oauth: provider) - response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - - assert_equal({ "ok" => true }, response["result"]) - assert_equal("refreshed-token", provider.access_token) - assert_equal(0, assertion_calls) - end - - def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing - # A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized: - # the refresh attempt must raise rather than fall through to the interactive flow, - # which would fail the same way after the user signed in. - stub_request(:post, @mcp_url).to_return( - status: 401, - headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, - body: "", - ) - - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - - stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - - redirected = false - provider_class = Class.new(Provider) do - define_method(:token_request_params) { { "grant_type" => "password" } } - end - provider = provider_class.new( - client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) { redirected = true }, - callback_handler: -> { ["code", "state"] }, - ) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") - - transport = HTTP.new(url: @mcp_url, oauth: provider) - - assert_raises(Flow::InvalidTokenRequestParamsError) do - transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - end - refute(redirected) - assert_not_requested(:post, "#{@auth_base}/token") - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - def test_send_request_surfaces_a_refused_refresh_without_falling_back - # A customizer middleware that would carry the refresh request off the origin is refused before - # the request leaves, and that refusal is not one of the failures that start the interactive flow - # or discard the stored tokens. - stub_request(:post, @mcp_url).to_return( - status: 401, - headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, - body: "", - ) - - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]), - ) - - stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - registration_endpoint: "#{@auth_base}/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - - stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) - stub_request(:post, "https://other.example.com/token").to_raise(StandardError.new("the rewritten request must not leave.")) - - provider = build_provider( - http_client_customizer: ->(faraday) { - faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token") - }, - ) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") - - transport = HTTP.new(url: @mcp_url, oauth: provider) - - assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do - transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - end - - assert_not_requested(:post, "https://other.example.com/token") - assert_not_requested(:post, "#{@auth_base}/token") - assert_not_requested(:post, "#{@auth_base}/register") - assert_equal("saved-rt", provider.tokens["refresh_token"]) - end - - def test_send_request_surfaces_a_refresh_whose_environment_was_rebuilt_without_falling_back - # The missing-record refusal takes the same route as the origin mismatch: out of `send_request`, - # with no interactive fallback and the stored tokens intact. - stub_request(:post, @mcp_url).to_return( - status: 401, - headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, - body: "", - ) - stub_request(:get, @prm_url).to_raise(StandardError.new("the rebuilt request must not leave.")) - stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called.")) - - interactive_flow_started = false - provider = Provider.new( - client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(_url) { interactive_flow_started = true }, - callback_handler: -> { ["code", "state"] }, - http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) }, - ) - provider.save_client_information("client_id" => "test-client") - provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt") - - transport = HTTP.new(url: @mcp_url, oauth: provider) - - error = assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do - transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) - end - - assert_match(/carries no record of the URL the flow asked for/, error.message) - refute(interactive_flow_started, "the refusal must not fall back to the interactive flow") - assert_not_requested(:get, @prm_url) - assert_not_requested(:post, "#{@auth_base}/token") - assert_not_requested(:post, "#{@auth_base}/register") - assert_equal({ "access_token" => "stale-token", "refresh_token" => "saved-rt" }, provider.tokens) - end - def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure # A 5xx (or any non-`invalid_grant`) from the token endpoint indicates # a transient AS outage, NOT that the refresh token is dead. @@ -1759,94 +1396,6 @@ module MCP assert_requested(:post, "#{@auth_base}/token", times: 1) end - def test_send_request_reauthorizes_when_the_authorization_server_did_not_issue_the_stored_tokens - # The refusal in `refresh!` is only half the answer; this is the other half. - # A renamed authorization server makes the refresh fail, the transport falls through to a full authorization, - # and that is where the embedding application is asked about the new one. - stub_request(:post, @mcp_url).with { |req| - req.headers["Authorization"] != "Bearer fresh-at" - }.to_return( - status: 401, - headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") }, - body: "", - ) - stub_request(:post, @mcp_url).with( - headers: { "Authorization" => "Bearer fresh-at" } - ).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }), - ) - - stub_request(:get, @prm_url).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(resource: @mcp_url, authorization_servers: [@auth_base]), - ) - stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate( - issuer: @auth_base, - authorization_endpoint: "#{@auth_base}/authorize", - token_endpoint: "#{@auth_base}/token", - registration_endpoint: "#{@auth_base}/register", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - ), - ) - stub_request(:post, "#{@auth_base}/register").to_return( - status: 201, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(client_id: "reauthorized-client"), - ) - stub_request(:post, "#{@auth_base}/token").to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"), - ) - - seen = [] - state_holder = {} - provider = Provider.new( - client_metadata: { - redirect_uris: ["http://localhost:0/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }, - redirect_uri: "http://localhost:0/callback", - redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") }, - callback_handler: -> { ["test-auth-code", state_holder[:state]] }, - authorization_request_validator: ->(request) { - seen << request.authorization_server - true - }, - ) - - # Registered and refreshable, so the refresh path is genuinely reachable and the issuer binding is - # what stops it rather than a missing client identity. - provider.save_client_information("client_id" => "test-client") - provider.save_tokens( - "access_token" => "stale-at", - "refresh_token" => "saved-rt", - "issuer" => "https://issued-by.example.com", - ) - - HTTP.new(url: @mcp_url, oauth: provider).send_request( - request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }, - ) - - # The refresh token was never presented to the authorization server now being named. - assert_not_requested(:post, "#{@auth_base}/token") do |req| - URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token" - end - assert_equal([@auth_base], seen) - assert_equal(@auth_base, provider.tokens["issuer"]) - end - private # Stubs PRM, AS metadata, /register, and /token so a full authorization @@ -1931,13 +1480,12 @@ module MCP provider end - def build_provider(http_client_customizer: nil) + def build_provider Provider.new( client_metadata: { redirect_uris: ["http://localhost:0/callback"] }, redirect_uri: "http://localhost:0/callback", redirect_handler: ->(_url) {}, callback_handler: -> { ["code", "state"] }, - http_client_customizer: http_client_customizer, ) end end diff --git a/test/mcp/client/oauth/id_jag_token_exchange_test.rb b/test/mcp/client/oauth/id_jag_token_exchange_test.rb index 23d152c..a5f6914 100644 --- a/test/mcp/client/oauth/id_jag_token_exchange_test.rb +++ b/test/mcp/client/oauth/id_jag_token_exchange_test.rb @@ -101,17 +101,6 @@ module MCP error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } assert_match(/not a JSON object/, error.message) end - - def test_request_refuses_a_response_body_over_the_cap - stub_request(:post, @token_endpoint).to_return( - status: 200, - headers: { "Content-Type" => "application/json" }, - body: "a" * (4 * 1024 * 1024 + 1), - ) - - error = assert_raises(IDJAGTokenExchange::ExchangeError) { request_exchange } - assert_match(/exceeds \d+ bytes/, error.message) - end end end end diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index 7425131..3db9427 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -38,26 +38,6 @@ module MCP end end - def test_initialize_defaults_http_client_customizer_to_nil - assert_nil(Provider.new(**args_for("https://app.example.com/callback")).http_client_customizer) - end - - def test_initialize_keeps_the_http_client_customizer - customizer = ->(_faraday) {} - provider = Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: customizer) - - assert_same(customizer, provider.http_client_customizer) - end - - def test_initialize_rejects_a_non_callable_http_client_customizer - # A connection object in place of a callable is the likely mistake. - error = assert_raises(ArgumentError) do - Provider.new(**args_for("https://app.example.com/callback"), http_client_customizer: Object.new) - end - - assert_equal("http_client_customizer must respond to call (got Object).", error.message) - end - def test_initialize_rejects_non_loopback_http_redirect_uri # Communication Security: a non-loopback `http://` redirect URI would # let an attacker steal the authorization code from a network sniffer, @@ -207,30 +187,6 @@ module MCP assert_equal(:authorization_code, provider.authorization_flow) end - - def test_token_request_params_is_nil_by_default - provider = Provider.new(**args_for("https://app.example.com/callback")) - - assert_nil(provider.token_request_params) - end - - def test_initialize_keeps_a_frozen_copy_of_token_request_params - params = { "audience" => +"https://api.example.com" } - provider = Provider.new(**args_for("https://app.example.com/callback"), token_request_params: params) - - params["audience"] << "/changed" - - assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params) - assert_predicate(provider.token_request_params, :frozen?) - end - - def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself - error = assert_raises(Flow::InvalidTokenRequestParamsError) do - Provider.new(**args_for("https://app.example.com/callback"), token_request_params: { "code" => "x" }) - end - - assert_includes(error.message, '"code"') - end end end end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 08e8d17..1995222 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -9,8 +9,6 @@ require "mcp/client/tool" module MCP class Client class StdioTest < Minitest::Test - include DeprecationWarningTestHelper - def test_send_request_raises_when_connect_not_called Open3.expects(:popen3).never @@ -85,245 +83,6 @@ module MCP stderr_read.close end - def test_send_request_answers_a_server_ping_inline - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - pong_line = nil - server_thread = Thread.new do - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0.0" }, - }, - })) - stdout_write.flush - - # Read initialized notification, then the tools/list request. - stdin_read.gets - stdin_read.gets - - # Ping the client while it awaits the response; the receiver "MUST respond promptly". - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) - stdout_write.flush - - pong_line = stdin_read.gets - - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) - stdout_write.flush - end - - transport.connect - response = transport.send_request(request: request) - server_thread.join - - assert_equal("test-id", response["id"]) - pong = JSON.parse(pong_line) - assert_equal("srv-ping-1", pong["id"]) - assert_equal({}, pong["result"]) - refute(pong.key?("error")) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - - def test_a_server_ping_reusing_the_awaited_id_is_answered_and_not_returned_as_the_response - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - pong_line = nil - server_thread = Thread.new do - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0.0" }, - }, - })) - stdout_write.flush - - # Read initialized notification, then the tools/list request. - stdin_read.gets - stdin_read.gets - - # JSON-RPC id spaces are per sender, so a server ping may reuse the id the client - # is awaiting; it must still be answered rather than treated as the response. - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", method: "ping" })) - stdout_write.flush - - pong_line = stdin_read.gets - - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) - stdout_write.flush - end - - transport.connect - response = transport.send_request(request: request) - server_thread.join - - assert_equal("test-id", response["id"]) - assert_empty(response.dig("result", "tools")) - pong = JSON.parse(pong_line) - assert_equal("test-id", pong["id"]) - assert_equal({}, pong["result"]) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - - def test_a_pong_that_cannot_be_written_does_not_fail_the_awaited_request - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - server_thread = Thread.new do - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0.0" }, - }, - })) - stdout_write.flush - - # Read initialized notification, then the tools/list request. - stdin_read.gets - stdin_read.gets - - # Break the write path: the pong cannot be delivered, and that must not fail the request. - stdin_read.close - - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) - stdout_write.flush - end - - transport.connect - response = transport.send_request(request: request) - - assert_equal("test-id", response["id"]) - assert_empty(response.dig("result", "tools")) - ensure - server_thread.join - stdin_read.close unless stdin_read.closed? - stdin_write.close - stdout_read.close - stdout_write.close - end - - def test_a_ping_with_a_malformed_id_is_not_answered - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - request = { - jsonrpc: "2.0", - id: "test-id", - method: "tools/list", - } - - pong_line = nil - server_thread = Thread.new do - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0.0" }, - }, - })) - stdout_write.flush - - # Read initialized notification, then the tools/list request. - stdin_read.gets - stdin_read.gets - - # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames outright. - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: { "x" => 1 }, method: "ping" })) - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-2", method: "ping" })) - stdout_write.flush - - # The single pong that arrives must answer the well-formed ping, not the malformed one. - pong_line = stdin_read.gets - - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) - stdout_write.flush - end - - transport.connect - response = transport.send_request(request: request) - server_thread.join - - assert_equal("test-id", response["id"]) - pong = JSON.parse(pong_line) - assert_equal("srv-ping-2", pong["id"]) - assert_equal({}, pong["result"]) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - def test_send_request_skips_notifications stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -1057,102 +816,6 @@ module MCP stdout_write.close end - def test_connect_adopts_a_counter_offered_protocol_version - # A server may answer `initialize` with a version other than the one offered, and the connection - # then speaks the answer rather than the offer. This server counter-offers a handshake version - # to clients asking `initialize` for a modern one, which only resolves anything because - # clients behave this way. - counter_offered = "2025-06-18" - refute_equal( - counter_offered, - MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, - "the counter-offer has to differ from the offer for this to test anything", - ) - - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - offered = nil - - server_thread = Thread.new do - init_request = JSON.parse(stdin_read.gets) - offered = init_request.dig("params", "protocolVersion") - stdout_write.puts(JSON.generate( - jsonrpc: "2.0", - id: init_request["id"], - result: { protocolVersion: counter_offered }, - )) - stdout_write.flush - stdin_read.gets - end - - transport.connect - - assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered) - assert_equal(counter_offered, transport.protocol_version) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - - def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result - # A server answering `initialize` with a modern version is refused like an unknown one: - # the handshake settles on a legacy version by definition (SEP-2575 era model), - # and the TypeScript and Python clients refuse a modern counter-offer the same way. - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - server_thread = Thread.new do - init_line = stdin_read.gets - init_request = JSON.parse(init_line) - stdout_write.puts(JSON.generate( - jsonrpc: "2.0", - id: init_request["id"], - result: { protocolVersion: "2026-07-28" }, - )) - stdout_write.flush - end - - error = assert_raises(RequestHandlerError) do - transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} }) - end - - assert_includes(error.message, "2026-07-28") - refute_predicate(transport, :connected?) - ensure - server_thread.join - stdin_read.close - stdin_write.close - stdout_read.close - stdout_write.close - end - - def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake - # Validated before the child process is spawned: a pure argument error must not leave - # an orphaned server process behind. - Open3.expects(:popen3).never - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - error = assert_raises(ArgumentError) do - transport.connect(protocol_version: "2026-07-28", mode: :legacy) - end - - assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake") - end - def test_connect_raises_on_jsonrpc_error_response stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -1717,235 +1380,6 @@ module MCP [transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]] end - def test_send_request_allows_server_discover_before_connect - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - server_thread = Thread.new do - discover_request = JSON.parse(stdin_read.gets) - response = { - jsonrpc: "2.0", - id: discover_request["id"], - result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" }, - } - stdout_write.puts(JSON.generate(response)) - stdout_write.flush - end - - # No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery. - response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" }) - - assert_equal(["2026-07-28"], response.dig("result", "supportedVersions")) - refute_predicate(transport, :connected?) - ensure - server_thread&.join - [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| - io.close unless io.closed? - end - end - - def test_connect_modern_probes_discover_and_stamps_requests - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - received = [] - server_thread = Thread.new do - discover_request = JSON.parse(stdin_read.gets) - received << discover_request - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: discover_request["id"], - result: { - supportedVersions: ["2026-07-28"], - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0" }, - ttlMs: 0, - cacheScope: "private", - }, - })) - stdout_write.flush - - tools_request = JSON.parse(stdin_read.gets) - received << tools_request - stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } })) - stdout_write.flush - end - - result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" }) - response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" }) - - assert_equal(["2026-07-28"], result["supportedVersions"]) - assert_predicate(transport, :modern?) - assert_predicate(transport, :connected?) - assert_equal("2026-07-28", transport.protocol_version) - assert_equal("t1", response["id"]) - - # No `initialize` was sent; both frames carry the SEP-2575 envelope. - assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] }) - triple = received[1].dig("params", "_meta") - assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"]) - assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"]) - assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"]) - ensure - server_thread&.join - [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| - io.close unless io.closed? - end - end - - def test_connect_modern_warns_for_deprecated_capabilities - # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks. - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - server_thread = Thread.new do - discover_request = JSON.parse(stdin_read.gets) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: discover_request["id"], - result: { - supportedVersions: ["2026-07-28"], - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0" }, - ttlMs: 0, - cacheScope: "private", - }, - })) - stdout_write.flush - end - - assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do - transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} }) - end - - assert_predicate(transport, :modern?) - ensure - server_thread&.join - [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| - io.close unless io.closed? - end - end - - def test_connect_auto_falls_back_to_the_legacy_handshake - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - - server_thread = Thread.new do - discover_request = JSON.parse(stdin_read.gets) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: discover_request["id"], - error: { code: -32601, message: "Method not found" }, - })) - stdout_write.flush - - init_request = JSON.parse(stdin_read.gets) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0" }, - }, - })) - stdout_write.flush - - # `notifications/initialized` - stdin_read.gets - end - - result = transport.connect(mode: :auto) - - assert_equal("2025-11-25", result["protocolVersion"]) - assert_equal("2025-11-25", transport.protocol_version) - refute_predicate(transport, :modern?) - assert_predicate(transport, :connected?) - ensure - server_thread&.join - [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| - io.close unless io.closed? - end - end - - def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured - transport = Stdio.new(command: "ruby", args: ["server.rb"]) - transport.stubs(:start) - - observed_timeout = nil - transport.define_singleton_method(:probe_discover) do - observed_timeout = @read_timeout - raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error) - end - transport.define_singleton_method(:connect_legacy) do |**| - { "protocolVersion" => "2025-11-25" } - end - - result = transport.connect(mode: :auto) - - assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout) - assert_nil(transport.instance_variable_get(:@read_timeout)) - assert_equal("2025-11-25", result["protocolVersion"]) - end - - def test_connect_auto_falls_back_when_the_server_never_answers_the_probe - stdin_read, stdin_write = IO.pipe - stdout_read, stdout_write = IO.pipe - stderr_read, _ = IO.pipe - - Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) - # A configured `read_timeout` also bounds the probe; the server below reads - # the `server/discover` request and never responds, which must count as - # legacy evidence instead of blocking `connect` forever. - transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05) - - server_thread = Thread.new do - # Silently swallow the probe. - stdin_read.gets - - init_request = JSON.parse(stdin_read.gets) - stdout_write.puts(JSON.generate({ - jsonrpc: "2.0", - id: init_request["id"], - result: { - protocolVersion: "2025-11-25", - capabilities: {}, - serverInfo: { name: "test-server", version: "1.0" }, - }, - })) - stdout_write.flush - - # `notifications/initialized` - stdin_read.gets - end - - result = transport.connect(mode: :auto) - - assert_equal("2025-11-25", result["protocolVersion"]) - refute_predicate(transport, :modern?) - ensure - server_thread&.join - [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io| - io.close unless io.closed? - end - end - def mock_wait_thread thread = mock("wait_thread") thread.stubs(:alive?).returns(true) diff --git a/test/mcp/client/tool_test.rb b/test/mcp/client/tool_test.rb index f2025ab..88e82e9 100644 --- a/test/mcp/client/tool_test.rb +++ b/test/mcp/client/tool_test.rb @@ -33,24 +33,6 @@ module MCP assert_nil(@tool.output_schema) end - def test_annotations_returns_nil_when_not_provided - assert_nil(@tool.annotations) - end - - def test_annotations_returns_annotations_when_provided - tool_with_annotations = Tool.new( - name: "test_tool_with_annotations", - description: "A test tool with annotations", - input_schema: { "type" => "object" }, - annotations: { "readOnlyHint" => true, "title" => "Test Tool" }, - ) - - assert_equal( - { "readOnlyHint" => true, "title" => "Test Tool" }, - tool_with_annotations.annotations, - ) - end - def test_output_schema_returns_output_schema_when_provided tool_with_output = Tool.new( name: "test_tool_with_output", @@ -71,14 +53,12 @@ module MCP description: "A tool with all parameters", input_schema: { "type" => "object" }, output_schema: { "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, - annotations: { "readOnlyHint" => true }, ) assert_equal("full_tool", tool.name) assert_equal("A tool with all parameters", tool.description) assert_equal({ "type" => "object" }, tool.input_schema) assert_equal({ "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, tool.output_schema) - assert_equal({ "readOnlyHint" => true }, tool.annotations) end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index bbd0652..3f7d180 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -55,14 +55,15 @@ module MCP Client.new(transport: transport).on_elicitation(&handler) end - def test_on_elicitation_is_accepted_when_transport_does_not_support_server_requests - # The handler still answers embedded `elicitation/create` requests of SEP-2322 `input_required` results, - # which need no server-to-client route. Refusing to register it would lock stdio clients, - # and every modern-lifecycle connection, out of the only way the spec leaves for asking. + def test_on_elicitation_raises_when_transport_does_not_support_server_requests transport = mock transport.stubs(:respond_to?).with(:on_server_request).returns(false) - Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } } + error = assert_raises(ArgumentError) do + Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } } + end + + assert_includes(error.message, "does not support server-to-client requests") end def test_on_sampling_registers_handler_on_transport @@ -73,231 +74,15 @@ module MCP Client.new(transport: transport).on_sampling(&handler) end - def test_on_sampling_is_accepted_when_transport_does_not_support_server_requests + def test_on_sampling_raises_when_transport_does_not_support_server_requests transport = mock transport.stubs(:respond_to?).with(:on_server_request).returns(false) - Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } } - end - - def test_on_roots_registers_handler_on_transport - transport = mock - handler = proc { { roots: [{ uri: "file:///project", name: "Project" }] } } - transport.expects(:on_server_request).with("roots/list") - - Client.new(transport: transport).on_roots(&handler) - end - - # A transport that declares `mode:` on `connect`, recording what it received. - # The `:not_forwarded` sentinel distinguishes "Client did not forward mode" from - # "Client forwarded mode: :legacy". - class ModeAwareTransport - attr_reader :received - - def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :not_forwarded) - @received = { - client_info: client_info, - protocol_version: protocol_version, - capabilities: capabilities, - mode: mode, - } - { "supportedVersions" => ["2026-07-28"] } - end - end - - def test_connect_defaults_to_auto_on_transports_declaring_mode - transport = ModeAwareTransport.new - - Client.new(transport: transport).connect - - assert_equal(:auto, transport.received[:mode]) - end - - def test_connect_keeps_the_legacy_call_shape_for_transports_without_mode - # Custom transports whose `connect` does not declare `mode:` keep receiving - # the historical call shape, so the `:auto` default cannot break them. - transport = mock - transport.expects(:connect).with( - client_info: nil, protocol_version: nil, capabilities: {}, - ).returns({ "protocolVersion" => "2025-11-25" }).once - - Client.new(transport: transport).connect - end - - def test_connect_routes_explicit_legacy_through_the_legacy_call_shape - transport = ModeAwareTransport.new - - Client.new(transport: transport).connect(mode: :legacy) - - assert_equal(:not_forwarded, transport.received[:mode]) - end - - def test_connect_pins_legacy_for_an_explicit_stable_protocol_version - # An explicitly requested legacy-generation version must never be overridden - # by the default negotiation adopting the modern lifecycle. - transport = ModeAwareTransport.new - - Client.new(transport: transport).connect(protocol_version: "2025-11-25") - - assert_equal(:not_forwarded, transport.received[:mode]) - assert_equal("2025-11-25", transport.received[:protocol_version]) - end - - def test_connect_negotiates_automatically_for_an_explicit_modern_protocol_version - transport = ModeAwareTransport.new - - Client.new(transport: transport).connect(protocol_version: "2026-07-28") - - assert_equal(:auto, transport.received[:mode]) - end - - def test_connect_raises_for_explicit_non_legacy_mode_on_a_transport_without_mode - transport = mock - transport.expects(:connect).never - error = assert_raises(ArgumentError) do - Client.new(transport: transport).connect(mode: :modern) + Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } } end - assert_includes(error.message, "does not support mode:") - end - - def test_connect_rejects_unknown_modes - transport = ModeAwareTransport.new - - assert_raises(ArgumentError) do - Client.new(transport: transport).connect(mode: :bogus) - end - end - - def test_discover_returns_a_discover_result - transport = mock - transport.expects(:send_request).with do |args| - args[:request][:method] == "server/discover" - end.returns({ - "result" => { - "supportedVersions" => ["2026-07-28"], - "capabilities" => { "tools" => {} }, - "_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "test-server", "version" => "1.0" } }, - "instructions" => "Optional instructions", - "ttlMs" => 3_600_000, - "cacheScope" => "public", - }, - }).once - - result = Client.new(transport: transport).discover - - assert_equal(["2026-07-28"], result.supported_versions) - assert_equal({ "tools" => {} }, result.capabilities) - assert_equal({ "name" => "test-server", "version" => "1.0" }, result.server_info) - assert_equal("Optional instructions", result.instructions) - assert_equal(3_600_000, result.ttl_ms) - assert_equal("public", result.cache_scope) - end - - def test_discover_maps_a_top_level_server_info_as_a_fallback - # Servers built against the frozen SEP text still stamp `serverInfo` at the top level; - # the finalized spec moved it into the result `_meta`. - transport = mock - transport.expects(:send_request).returns({ - "result" => { - "supportedVersions" => ["2026-07-28"], - "serverInfo" => { "name" => "sep-era-server", "version" => "1.0" }, - }, - }).once - - result = Client.new(transport: transport).discover - - assert_equal({ "name" => "sep-era-server", "version" => "1.0" }, result.server_info) - end - - def test_discover_raises_validation_error_on_missing_result - transport = mock - transport.expects(:send_request).returns({}).once - - assert_raises(Client::ValidationError) do - Client.new(transport: transport).discover - end - end - - def test_discover_raises_server_error_on_jsonrpc_error - transport = mock - transport.expects(:send_request).returns({ - "error" => { "code" => -32601, "message" => "Method not found" }, - }).once - - error = assert_raises(Client::ServerError) do - Client.new(transport: transport).discover - end - - assert_equal(-32601, error.code) - end - - def test_era_independent_readers_on_a_legacy_result - transport = mock - transport.stubs(:server_info).returns({ - "protocolVersion" => "2025-11-25", - "capabilities" => { "tools" => {} }, - "serverInfo" => { "name" => "legacy-server", "version" => "1.0" }, - "instructions" => "Be helpful", - }) - - client = Client.new(transport: transport) - - assert_equal("2025-11-25", client.protocol_version) - assert_equal({ "tools" => {} }, client.server_capabilities) - assert_equal("Be helpful", client.instructions) - assert_equal({ "name" => "legacy-server", "version" => "1.0" }, client.server_implementation) - end - - def test_era_independent_readers_on_a_modern_result - # A modern `DiscoverResult` has no `protocolVersion`; the adopted version comes - # from the transport. `serverInfo` sits in the result `_meta` per the final spec. - transport = mock - transport.stubs(:protocol_version).returns("2026-07-28") - transport.stubs(:server_info).returns({ - "supportedVersions" => ["2026-07-28"], - "capabilities" => { "tools" => {} }, - "instructions" => "Be helpful", - "_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "modern-server", "version" => "2.0" } }, - "ttlMs" => 0, - "cacheScope" => "private", - }) - - client = Client.new(transport: transport) - - assert_equal("2026-07-28", client.protocol_version) - assert_equal({ "tools" => {} }, client.server_capabilities) - assert_equal("Be helpful", client.instructions) - assert_equal({ "name" => "modern-server", "version" => "2.0" }, client.server_implementation) - end - - def test_server_implementation_falls_back_to_the_top_level_and_tolerates_absence - transport = mock - transport.stubs(:server_info).returns({ - "supportedVersions" => ["2026-07-28"], - "serverInfo" => { "name" => "top-level", "version" => "1.0" }, - }) - - assert_equal({ "name" => "top-level", "version" => "1.0" }, Client.new(transport: transport).server_implementation) - - anonymous = mock - anonymous.stubs(:server_info).returns({ "supportedVersions" => ["2026-07-28"] }) - - assert_nil(Client.new(transport: anonymous).server_implementation) - end - - def test_era_independent_readers_return_nil_before_connect - transport = mock - transport.stubs(:respond_to?).with(:server_info).returns(false) - transport.stubs(:respond_to?).with(:protocol_version).returns(false) - - client = Client.new(transport: transport) - - assert_nil(client.protocol_version) - assert_nil(client.server_capabilities) - assert_nil(client.instructions) - assert_nil(client.server_implementation) + assert_includes(error.message, "does not support server-to-client requests") end def test_connected_delegates_to_transport_when_supported @@ -339,7 +124,6 @@ module MCP "description" => "tool1", "inputSchema" => {}, "outputSchema" => { "type" => "object", "properties" => { "result" => { "type" => "string" } } }, - "annotations" => { "readOnlyHint" => true, "title" => "Tool One" }, }, { "name" => "tool2", "description" => "tool2", "inputSchema" => {} }, ], @@ -357,10 +141,8 @@ module MCP assert_equal(2, tools.size) assert_equal("tool1", tools.first.name) assert_equal({ "type" => "object", "properties" => { "result" => { "type" => "string" } } }, tools.first.output_schema) - assert_equal({ "readOnlyHint" => true, "title" => "Tool One" }, tools.first.annotations) assert_equal("tool2", tools.last.name) assert_nil(tools.last.output_schema) - assert_nil(tools.last.annotations) end def test_tools_returns_empty_array_when_no_tools @@ -378,62 +160,6 @@ module MCP assert_equal([], tools) end - def test_tools_excludes_invalid_x_mcp_header_definitions_on_a_modern_connection - # SEP-2243: on the modern lifecycle a tool definition violating the `x-mcp-header` - # constraints MUST be excluded from `tools/list` results, with a warning naming the tool. - transport = mock - transport.stubs(:modern?).returns(true) - mock_response = { - "result" => { - "tools" => [ - { "name" => "valid_tool", "inputSchema" => { "type" => "object" } }, - { - "name" => "invalid_tool", - "inputSchema" => { - "type" => "object", - "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, - }, - }, - ], - }, - } - transport.expects(:send_request).returns(mock_response).once - client = Client.new(transport: transport) - - # `rake` runs the suite with `-W0`, which silences `Kernel#warn`; restore warnings - # locally so the SHOULD-level warning is observable. - original_verbose = $VERBOSE - $VERBOSE = false - tools = nil - _out, warning = capture_io { tools = client.tools } - $VERBOSE = original_verbose - - assert_equal(["valid_tool"], tools.map(&:name)) - assert_includes(warning, "invalid_tool") - end - - def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection - transport = mock - transport.stubs(:modern?).returns(false) - mock_response = { - "result" => { - "tools" => [ - { - "name" => "invalid_tool", - "inputSchema" => { - "type" => "object", - "properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } }, - }, - }, - ], - }, - } - transport.expects(:send_request).returns(mock_response).once - client = Client.new(transport: transport) - - assert_equal(["invalid_tool"], client.tools.map(&:name)) - end - def test_call_tool_sends_request_to_transport_and_returns_content transport = mock tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) @@ -553,190 +279,6 @@ module MCP assert_raises(Client::InputRequiredError) { client.list_tools } end - def test_call_tool_drives_the_input_required_loop_with_a_configured_handler - transport = mock - sent_requests = [] - input_required_response = { - "result" => { - "resultType" => "input_required", - "inputRequests" => { - "region" => { "method" => "elicitation/create", "params" => { "message" => "Which region?" } }, - }, - "requestState" => "state-1", - }, - } - final_response = { "result" => { "content" => [{ "type" => "text", "text" => "done" }] } } - transport.expects(:send_request).twice.with do |args| - sent_requests << args[:request] - true - end.returns(input_required_response, final_response) - - handled_params = nil - client = Client.new(transport: transport) - client.on_elicitation do |params| - handled_params = params - { action: "accept", content: { value: "us-east-1" } } - end - - response = client.call_tool(name: "my_tool", arguments: { city: "Tokyo" }) - - assert_equal("done", response.dig("result", "content", 0, "text")) - assert_equal({ "message" => "Which region?" }, handled_params) - - first_leg, retry_leg = sent_requests - # The ORIGINAL params are preserved; the answers ride under the same keys with - # the byte-exact `requestState` echo, on a fresh JSON-RPC id. - assert_equal("my_tool", retry_leg.dig(:params, :name)) - assert_equal({ city: "Tokyo" }, retry_leg.dig(:params, :arguments)) - assert_equal( - { "region" => { action: "accept", content: { value: "us-east-1" } } }, - retry_leg.dig(:params, :inputResponses), - ) - assert_equal("state-1", retry_leg.dig(:params, :requestState)) - refute_equal(first_leg[:id], retry_leg[:id]) - end - - def test_input_required_loop_backs_off_on_request_state_only_legs - transport = mock - state_only_response = { - "result" => { "resultType" => "input_required", "requestState" => "busy-state" }, - } - final_response = { "result" => { "content" => [] } } - transport.stubs(:send_request).returns(state_only_response, state_only_response, final_response) - - client = Client.new(transport: transport) - client.on_elicitation { |_params| {} } - slept = [] - client.stubs(:sleep).with { |seconds| slept << seconds } - - response = client.call_tool(name: "my_tool") - - assert_equal(final_response, response) - - # Exponential from 50ms, matching the Python SDK. - assert_equal([0.05, 0.1], slept) - end - - def test_input_required_loop_raises_after_max_rounds - transport = mock - input_required_response = { - "result" => { - "resultType" => "input_required", - "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => {} } }, - "requestState" => "state-1", - }, - } - transport.stubs(:send_request).returns(input_required_response) - - client = Client.new(transport: transport, input_required_max_rounds: 2) - client.on_elicitation { |_params| { action: "decline" } } - - error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") } - - assert_includes(error.message, "after 2 rounds") - assert_equal("state-1", error.request_state) - end - - def test_input_required_loop_falls_back_to_the_error_for_unhandled_request_kinds - transport = mock - input_required_response = { - "result" => { - "resultType" => "input_required", - "inputRequests" => { "llm" => { "method" => "sampling/createMessage", "params" => {} } }, - "requestState" => "state-1", - }, - } - transport.expects(:send_request).returns(input_required_response).once - - client = Client.new(transport: transport) - client.on_elicitation { |_params| {} } - - error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") } - - assert_includes(error.message, "sampling/createMessage") - assert_equal("state-1", error.request_state) - end - - def test_input_required_loop_runs_on_a_transport_without_server_request_support - # The modern lifecycle forbids server-to-client requests, so an `input_required` result is the only - # way a server can ask. A transport with no `on_server_request` must still drive the loop. - transport = mock - transport.stubs(:respond_to?).with(:on_server_request).returns(false) - input_required_response = { - "result" => { - "resultType" => "input_required", - "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "Region?" } } }, - "requestState" => "state-1", - }, - } - final_response = { "result" => { "content" => [{ "text" => "done" }] } } - transport.expects(:send_request).twice.returns(input_required_response, final_response) - - client = Client.new(transport: transport) - client.on_elicitation { |_params| { action: "accept", content: { value: "us-east-1" } } } - - response = client.call_tool(name: "my_tool") - - assert_equal("done", response.dig("result", "content", 0, "text")) - end - - def test_input_required_loop_uses_a_roots_handler_registered_with_on_roots - transport = mock - input_required_response = { - "result" => { - "resultType" => "input_required", - "inputRequests" => { "r" => { "method" => "roots/list" } }, - "requestState" => "state-1", - }, - } - final_response = { "result" => { "content" => [{ "text" => "done" }] } } - transport.expects(:on_server_request).with("roots/list") - transport.expects(:send_request).twice.returns(input_required_response, final_response) - - client = Client.new(transport: transport) - client.on_roots { |_params| { roots: [{ uri: "file:///project", name: "Project" }] } } - - response = client.call_tool(name: "my_tool") - - assert_equal("done", response.dig("result", "content", 0, "text")) - end - - def test_call_tool_sends_manual_retry_fields - transport = mock - transport.expects(:send_request).with do |args| - params = args[:request][:params] - params[:inputResponses] == { "region" => { action: "accept" } } && params[:requestState] == "state-1" && params[:name] == "my_tool" - end.returns({ "result" => { "content" => [] } }).once - - client = Client.new(transport: transport) - client.call_tool(name: "my_tool", input_responses: { "region" => { action: "accept" } }, request_state: "state-1") - end - - def test_get_prompt_drives_the_input_required_loop - transport = mock - input_required_response = { - "result" => { - "resultType" => "input_required", - "inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "?" } } }, - "requestState" => "prompt-state", - }, - } - final_response = { "result" => { "messages" => [] } } - sent_requests = [] - transport.expects(:send_request).twice.with do |args| - sent_requests << args[:request] - true - end.returns(input_required_response, final_response) - - client = Client.new(transport: transport) - client.on_elicitation { |_params| { action: "accept", content: {} } } - - result = client.get_prompt(name: "my_prompt") - - assert_equal({ "messages" => [] }, result) - assert_equal("prompt-state", sent_requests[1].dig(:params, :requestState)) - end - def test_call_tool_raises_when_no_name_or_tool client = Client.new(transport: mock) @@ -1645,59 +1187,6 @@ module MCP assert_equal(2, tools.size) end - def test_tools_raises_when_the_server_offers_more_pages_than_max_pages - transport = mock - - pages = (1..3).map do |n| - { - "result" => { - "tools" => [{ "name" => "tool#{n}", "description" => "tool#{n}", "inputSchema" => {} }], - "nextCursor" => "cursor#{n}", - }, - } - end - - # A fresh cursor on every response defeats the repeat guard, so only `max_pages` stops the walk. - transport.expects(:send_request).times(3).returns(*pages) - - client = Client.new(transport: transport, max_pages: 3) - - error = assert_raises(Client::PaginationLimitError) { client.tools } - assert_equal( - "Server returned more than 3 pages; pass a larger `max_pages:` to `MCP::Client.new` if this is expected.", - error.message, - ) - end - - def test_tools_returns_every_page_when_the_server_stops_exactly_at_max_pages - transport = mock - - page1 = { - "result" => { - "tools" => [{ "name" => "tool1", "description" => "tool1", "inputSchema" => {} }], - "nextCursor" => "cursor1", - }, - } - page2 = { - "result" => { - "tools" => [{ "name" => "tool2", "description" => "tool2", "inputSchema" => {} }], - }, - } - - transport.expects(:send_request).twice.returns(page1, page2) - - client = Client.new(transport: transport, max_pages: 2) - tools = client.tools - - assert_equal(["tool1", "tool2"], tools.map(&:name)) - end - - def test_raises_argument_error_when_max_pages_is_not_positive - error = assert_raises(ArgumentError) { Client.new(transport: mock, max_pages: 0) } - - assert_equal("max_pages must be a positive Integer", error.message) - end - def test_tools_breaks_when_server_cycles_between_cursors transport = mock diff --git a/test/mcp/configuration_test.rb b/test/mcp/configuration_test.rb index 058bc19..8270b10 100644 --- a/test/mcp/configuration_test.rb +++ b/test/mcp/configuration_test.rb @@ -36,21 +36,15 @@ module MCP assert_equal test_context, reported_context end + # https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15 test "initializes with default protocol version" do - # The unset pin reads as the version the handshake settles on, which is also the only kind of - # value the writer accepts: reading back a version that cannot be set again would break code - # that round-trips the setting. config = Configuration.new - - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version - refute_predicate config, :protocol_version? - assert_nothing_raised { Configuration.new(protocol_version: config.protocol_version) } + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version end - test "uses the default protocol version when protocol_version is set to nil" do + test "uses the draft protocol version when protocol_version is set to nil" do config = Configuration.new(protocol_version: nil) - - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, config.protocol_version + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version end test "raises ArgumentError when setting the draft protocol version" do @@ -72,29 +66,6 @@ module MCP assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message) end - test "exposes the SEP-2575 modern protocol versions" do - assert_equal "2026-07-28", Configuration::LATEST_MODERN_PROTOCOL_VERSION - assert_equal ["2026-07-28"], Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS - assert Configuration.modern_protocol_version?("2026-07-28") - refute Configuration.modern_protocol_version?("2025-11-25") - end - - test "rejects a modern version as the protocol version pin" do - # The pin scopes the `initialize` handshake; a modern version has no handshake to pin - # (its version rides every request in `_meta`), so accepting it would configure nothing. - # The message names that reason instead of listing the accepted values, because this is - # the error an upgrade from a release that accepted the value lands on. - exception = assert_raises(ArgumentError) do - Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION) - end - - assert_includes(exception.message, "is a modern protocol version and cannot be pinned here") - assert_includes(exception.message, "remove the setting") - - config = Configuration.new - assert_raises(ArgumentError) { config.protocol_version = "2026-07-28" } - end - test "raises ArgumentError when protocol_version is not a boolean value" do config = Configuration.new exception = assert_raises(ArgumentError) do @@ -160,35 +131,6 @@ module MCP refute merged.validate_tool_call_arguments end - test "defaults instrument_server_context to false" do - config = Configuration.new - - refute_predicate config, :instrument_server_context? - end - - test "accepts instrument_server_context in the constructor" do - config = Configuration.new(instrument_server_context: true) - - assert_predicate config, :instrument_server_context? - end - - test "raises ArgumentError when instrument_server_context is not a boolean value" do - config = Configuration.new - - exception = assert_raises(ArgumentError) do - config.instrument_server_context = "true" - end - assert_equal("instrument_server_context must be a boolean", exception.message) - end - - test "merge carries instrument_server_context from the other configuration" do - base = Configuration.new(instrument_server_context: true) - other = Configuration.new(instrument_server_context: false) - - refute_predicate base.merge(other), :instrument_server_context? - assert_predicate other.merge(base), :instrument_server_context? - end - test "defaults validate_tool_call_results to false" do config = Configuration.new refute config.validate_tool_call_results diff --git a/test/mcp/elicitation/enum_schema_test.rb b/test/mcp/elicitation/enum_schema_test.rb deleted file mode 100644 index 19a4246..0000000 --- a/test/mcp/elicitation/enum_schema_test.rb +++ /dev/null @@ -1,143 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - module Elicitation - class EnumSchemaTest < ActiveSupport::TestCase - test "untitled_single_select returns string + enum schema" do - schema = EnumSchema.untitled_single_select(values: ["red", "green", "blue"]) - - assert_equal({ type: "string", enum: ["red", "green", "blue"] }, schema.to_h) - end - - test "untitled_single_select preserves default value" do - schema = EnumSchema.untitled_single_select(values: ["red", "green"], default: "red") - - assert_equal "red", schema.to_h[:default] - end - - test "titled_single_select returns string + oneOf schema" do - schema = EnumSchema.titled_single_select( - options: [ - { value: "small", title: "Small" }, - { value: "large", title: "Large" }, - ], - ) - - assert_equal( - { - type: "string", - oneOf: [ - { const: "small", title: "Small" }, - { const: "large", title: "Large" }, - ], - }, - schema.to_h, - ) - end - - test "untitled_multi_select returns array + items.enum schema" do - schema = EnumSchema.untitled_multi_select(values: ["a", "b", "c"]) - - assert_equal( - { type: "array", items: { type: "string", enum: ["a", "b", "c"] } }, - schema.to_h, - ) - end - - test "untitled_multi_select preserves default array" do - schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: ["a"]) - - assert_equal ["a"], schema.to_h[:default] - end - - test "titled_multi_select returns array + items.anyOf schema" do - schema = EnumSchema.titled_multi_select( - options: [ - { value: "us", title: "United States" }, - { value: "jp", title: "Japan" }, - ], - ) - - assert_equal( - { - type: "array", - items: { - anyOf: [ - { const: "us", title: "United States" }, - { const: "jp", title: "Japan" }, - ], - }, - }, - schema.to_h, - ) - end - - test "legacy_titled returns enum + enumNames" do - schema = EnumSchema.legacy_titled( - values: ["s", "m", "l"], - value_titles: ["Small", "Medium", "Large"], - ) - - assert_equal( - { - type: "string", - enum: ["s", "m", "l"], - enumNames: ["Small", "Medium", "Large"], - }, - schema.to_h, - ) - end - - test "title and description are included when provided" do - schema = EnumSchema.untitled_single_select( - values: ["a"], - title: "Color", - description: "Pick a color", - ) - - assert_equal "Color", schema.to_h[:title] - assert_equal "Pick a color", schema.to_h[:description] - end - - test "raises ArgumentError when values array is empty" do - assert_raises(ArgumentError) do - EnumSchema.untitled_single_select(values: []) - end - end - - test "raises ArgumentError when titled options miss :value" do - assert_raises(ArgumentError) do - EnumSchema.titled_single_select(options: [{ title: "missing value" }]) - end - end - - test "raises ArgumentError when titled options miss :title" do - assert_raises(ArgumentError) do - EnumSchema.titled_single_select(options: [{ value: "v" }]) - end - end - - test "raises ArgumentError when legacy value_titles length differs" do - assert_raises(ArgumentError) do - EnumSchema.legacy_titled(values: ["a", "b"], value_titles: ["A"]) - end - end - - test "preserves an empty-string default value" do - # Regression for `unless @default.nil?`: empty-but-non-nil defaults must still survive `to_h` - # (the guard distinguishes nil from "absent vs supplied", not truthy vs falsey). - schema = EnumSchema.untitled_single_select(values: ["", "yes", "no"], default: "") - - assert_equal "", schema.to_h[:default] - end - - test "preserves an empty-array default for multi-select" do - schema = EnumSchema.untitled_multi_select(values: ["a", "b"], default: []) - - assert_equal [], schema.to_h[:default] - end - end - end -end diff --git a/test/mcp/error_codes_test.rb b/test/mcp/error_codes_test.rb index 80826b8..f00da6a 100644 --- a/test/mcp/error_codes_test.rb +++ b/test/mcp/error_codes_test.rb @@ -6,7 +6,6 @@ module MCP class ErrorCodesTest < ActiveSupport::TestCase test "exposes the SEP-2575 stateless lifecycle error codes" do # The exact values are wire vocabulary shared with other SDKs. - assert_equal(-32020, ErrorCodes::HEADER_MISMATCH) assert_equal(-32021, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY) assert_equal(-32022, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION) end diff --git a/test/mcp/icon_test.rb b/test/mcp/icon_test.rb index f2fbc9a..e8c837c 100644 --- a/test/mcp/icon_test.rb +++ b/test/mcp/icon_test.rb @@ -15,112 +15,32 @@ module MCP assert_equal({ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }, icon.to_h) end - def test_initialization_with_only_src - icon = Icon.new(src: "https://example.com/icon.png") + def test_initialization_by_default + icon = Icon.new assert_nil(icon.mime_type) assert_nil(icon.sizes) - assert_equal("https://example.com/icon.png", icon.src) + assert_nil(icon.src) assert_nil(icon.theme) - assert_equal({ src: "https://example.com/icon.png" }, icon.to_h) - end - - def test_src_is_required - exception = assert_raises(ArgumentError) do - Icon.new - end - assert_equal("missing keyword: :src", exception.message) - end - - def test_src_rejects_nil_and_an_empty_string - [nil, ""].each do |src| - exception = assert_raises(ArgumentError) do - Icon.new(src: src) - end - assert_equal("The value of src must be a non-empty String (got #{src.class}).", exception.message) - end - end - - def test_src_rejects_a_non_string - exception = assert_raises(ArgumentError) do - Icon.new(src: :icon) - end - assert_equal("The value of src must be a non-empty String (got Symbol).", exception.message) - end - - def test_sizes_accepts_an_array_of_strings - [["48x48", "96x96"], ["any"], []].each do |sizes| - icon = Icon.new(sizes: sizes, src: "https://example.com/icon.png") - - assert_equal(sizes, icon.to_h[:sizes]) - end - end - - # https://github.com/modelcontextprotocol/ruby-sdk/issues/562 - def test_sizes_rejects_a_string - exception = assert_raises(ArgumentError) do - Icon.new(mime_type: "image/png", sizes: "51x51", src: "https://example.com/icon.png") - end - assert_equal( - 'The value of sizes must be an Array of Strings such as ["48x48"] or ["any"] (got String).', - exception.message, - ) - end - - def test_sizes_rejects_an_array_holding_a_non_string - { - ["48x48", 96] => "Integer", - [nil] => "NilClass", - ["48x48", nil] => "NilClass", - [nil, 96] => "NilClass", - }.each do |sizes, offender| - exception = assert_raises(ArgumentError) do - Icon.new(sizes: sizes, src: "https://example.com/icon.png") - end - assert_equal( - "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (got #{offender} inside the Array).", - exception.message, - ) - end - end - - def test_src_accepts_any_scheme_and_sizes_accept_any_string - string_subclass = Class.new(String) - icon = Icon.new(sizes: ["unconventional", +"48x48", string_subclass.new("any")], src: "custom:icon") - - assert_equal({ sizes: ["unconventional", "48x48", "any"], src: "custom:icon" }, icon.to_h) - end - - def test_mime_type_rejects_a_non_string - exception = assert_raises(ArgumentError) do - Icon.new(mime_type: :png, src: "https://example.com/icon.png") - end - assert_equal("The value of mime_type must be a String (got Symbol).", exception.message) + assert_equal({}, icon.to_h) end def test_valid_theme_for_light assert_nothing_raised do - Icon.new(src: "https://example.com/icon.png", theme: "light") + Icon.new(theme: "light") end end def test_valid_theme_for_dark assert_nothing_raised do - Icon.new(src: "https://example.com/icon.png", theme: "dark") + Icon.new(theme: "dark") end end def test_invalid_theme exception = assert_raises(ArgumentError) do - Icon.new(src: "https://example.com/icon.png", theme: "unexpected") - end - assert_equal('The value of theme must specify "light" or "dark".', exception.message) - end - - def test_theme_rejects_false - exception = assert_raises(ArgumentError) do - Icon.new(src: "https://example.com/icon.png", theme: false) + Icon.new(theme: "unexpected") end assert_equal('The value of theme must specify "light" or "dark".', exception.message) end diff --git a/test/mcp/instrumentation_test.rb b/test/mcp/instrumentation_test.rb index 8fb06af..ec2e3aa 100644 --- a/test/mcp/instrumentation_test.rb +++ b/test/mcp/instrumentation_test.rb @@ -40,79 +40,6 @@ module MCP end end - # Mirrors `MCP::Server`, which exposes the user-defined hash through - # `attr_accessor :server_context`. - class SubjectWithServerContext < Subject - attr_accessor :server_context - - def initialize(server_context) - super() - @server_context = server_context - end - end - - test "#instrument_call omits the user-defined server_context by default" do - subject = SubjectWithServerContext.new({ user_id: 42 }) - - subject.instrumented_method - - refute_includes subject.instrumentation_data_received.keys, :server_context - end - - test "#instrument_call exposes the user-defined server_context when instrument_server_context is enabled" do - subject = SubjectWithServerContext.new({ user_id: 42 }) - subject.configuration.instrument_server_context = true - - subject.instrumented_method - - assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) - end - - test "#instrument_call exposes the user-defined server_context to around_request" do - subject = SubjectWithServerContext.new({ user_id: 42 }) - subject.configuration.instrument_server_context = true - seen = nil - subject.configuration.around_request = ->(data, &handler) { - seen = data[:server_context] - handler.call - } - - subject.instrumented_method - - assert_equal({ user_id: 42 }, seen) - end - - test "#instrument_call reports the user-defined server_context as nil when the host does not set one" do - subject = SubjectWithServerContext.new(nil) - subject.configuration.instrument_server_context = true - - subject.instrumented_method - - assert_includes subject.instrumentation_data_received.keys, :server_context - assert_nil subject.instrumentation_data_received[:server_context] - end - - test "#instrument_call does not confuse the reporter context with the user-defined server_context" do - # `instrument_call(server_context:)` is the exception-reporter context, a - # different value from the user-defined hash. Enabling the flag must - # surface the latter, not the former. - subject = SubjectWithServerContext.new({ user_id: 42 }) - subject.configuration.instrument_server_context = true - - subject.instrumented_method_with_server_context({ request: "reporter-context" }) - - assert_equal({ user_id: 42 }, subject.instrumentation_data_received[:server_context]) - end - - test "#instrument_call skips server_context for hosts without the reader even when enabled" do - subject = Subject.new - subject.configuration.instrument_server_context = true - - subject.instrumented_method - - refute_includes subject.instrumentation_data_received.keys, :server_context - end - test "#instrument_call adds the method name to the instrumentation data" do subject = Subject.new diff --git a/test/mcp/protocol_deprecations_test.rb b/test/mcp/protocol_deprecations_test.rb deleted file mode 100644 index d7fbbd9..0000000 --- a/test/mcp/protocol_deprecations_test.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - class ProtocolDeprecationsTest < ActiveSupport::TestCase - test "roots, sampling, and logging are deprecated in 2026-07-28 and later" do - refute ProtocolDeprecations.deprecated_roots_sampling_logging?(nil) - refute ProtocolDeprecations.deprecated_roots_sampling_logging?("2025-11-25") - assert ProtocolDeprecations.deprecated_roots_sampling_logging?("2026-07-28") - assert ProtocolDeprecations.deprecated_roots_sampling_logging?("2027-01-01") - end - end -end diff --git a/test/mcp/request_envelope_test.rb b/test/mcp/request_envelope_test.rb deleted file mode 100644 index e7261be..0000000 --- a/test/mcp/request_envelope_test.rb +++ /dev/null @@ -1,145 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - class RequestEnvelopeTest < ActiveSupport::TestCase - test "exposes the reserved SEP-2575 meta key names" do - # The exact strings are wire vocabulary shared with other SDKs. - assert_equal "io.modelcontextprotocol/protocolVersion", RequestEnvelope::PROTOCOL_VERSION_META_KEY - assert_equal "io.modelcontextprotocol/clientInfo", RequestEnvelope::CLIENT_INFO_META_KEY - assert_equal "io.modelcontextprotocol/clientCapabilities", RequestEnvelope::CLIENT_CAPABILITIES_META_KEY - assert_equal "io.modelcontextprotocol/logLevel", RequestEnvelope::LOG_LEVEL_META_KEY - end - - test ".modern? returns true when the protocolVersion claim key is present" do - assert RequestEnvelope.modern?(modern_params) - assert RequestEnvelope.modern?({ _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }) - end - - test ".modern? returns true for string keys" do - params = { - "_meta" => { - "io.modelcontextprotocol/protocolVersion" => "2026-07-28", - "io.modelcontextprotocol/clientInfo" => { "name" => "c", "version" => "1" }, - "io.modelcontextprotocol/clientCapabilities" => {}, - }, - } - - assert RequestEnvelope.modern?(params) - end - - test ".modern? stays true for a claimed but incomplete envelope" do - # A claimed envelope must be validated by `parse!` (`-32602`), never silently served as legacy, - # matching the TypeScript and Python classifiers. - params = modern_params - params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") - - assert RequestEnvelope.modern?(params) - end - - test ".modern? returns false for legacy _meta entries such as progressToken" do - refute RequestEnvelope.modern?({ name: "echo", _meta: { progressToken: "token" } }) - end - - test ".modern? returns false without params or _meta" do - refute RequestEnvelope.modern?(nil) - refute RequestEnvelope.modern?({}) - refute RequestEnvelope.modern?({ name: "echo" }) - refute RequestEnvelope.modern?({ _meta: nil }) - end - - test ".parse! returns a frozen envelope with the triple and optional log level" do - params = modern_params - params[:_meta][:"io.modelcontextprotocol/logLevel"] = "warning" - - envelope = RequestEnvelope.parse!(params) - - assert_predicate envelope, :frozen? - assert_equal "2026-07-28", envelope.protocol_version - assert_equal({ name: "test_client", version: "1.0.0" }, envelope.client_info) - assert_equal({ elicitation: {} }, envelope.client_capabilities) - assert_equal "warning", envelope.log_level - end - - test ".parse! leaves log_level nil when absent" do - assert_nil RequestEnvelope.parse!(modern_params).log_level - end - - test ".parse! raises UnsupportedProtocolVersionError with the SEP-2575 data shape" do - params = modern_params(version: "2025-11-25") - - error = assert_raises(Server::UnsupportedProtocolVersionError) do - RequestEnvelope.parse!(params) - end - - assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, error.error_code - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] - assert_equal "2025-11-25", error.error_data[:requested] - end - - test ".parse! accepts an envelope without the optional clientInfo" do - # `clientInfo` became optional after the SEP was finalized (spec PR #3002); - # a conformant client configured not to identify itself must not be rejected. - params = modern_params - params[:_meta].delete(:"io.modelcontextprotocol/clientInfo") - - envelope = RequestEnvelope.parse!(params) - - assert_nil envelope.client_info - assert_equal "2026-07-28", envelope.protocol_version - assert_equal({ elicitation: {} }, envelope.client_capabilities) - end - - test ".parse! raises Invalid params naming the missing key when clientCapabilities is absent" do - params = modern_params - params[:_meta].delete(:"io.modelcontextprotocol/clientCapabilities") - - error = assert_raises(Server::RequestHandlerError) do - RequestEnvelope.parse!(params) - end - - assert_equal :invalid_params, error.error_type - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code - assert_includes error.message, "io.modelcontextprotocol/clientCapabilities" - refute_includes error.message, "io.modelcontextprotocol/protocolVersion" - end - - test ".parse! raises Invalid params when a required field has the wrong type" do - params = modern_params - params[:_meta][:"io.modelcontextprotocol/clientCapabilities"] = "not-a-hash" - - error = assert_raises(Server::RequestHandlerError) do - RequestEnvelope.parse!(params) - end - - assert_equal :invalid_params, error.error_type - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, error.error_code - end - - test ".parse! raises Invalid params when the optional clientInfo has the wrong type" do - params = modern_params - params[:_meta][:"io.modelcontextprotocol/clientInfo"] = "not-a-hash" - - error = assert_raises(Server::RequestHandlerError) do - RequestEnvelope.parse!(params) - end - - assert_equal :invalid_params, error.error_type - assert_includes error.message, "io.modelcontextprotocol/clientInfo" - end - - private - - def modern_params(version: "2026-07-28") - { - name: "echo", - _meta: { - "io.modelcontextprotocol/protocolVersion": version, - "io.modelcontextprotocol/clientInfo": { name: "test_client", version: "1.0.0" }, - "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, - }, - } - end - end -end diff --git a/test/mcp/server/input_required_result_test.rb b/test/mcp/server/input_required_result_test.rb deleted file mode 100644 index ba8ce43..0000000 --- a/test/mcp/server/input_required_result_test.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - class Server - class InputRequiredResultTest < ActiveSupport::TestCase - test "requires at least one of input_requests or request_state" do - assert_raises(ArgumentError) { InputRequiredResult.new } - assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: {}) } - assert_raises(ArgumentError) { InputRequiredResult.new(request_state: 42) } - end - - test "validates input_requests entries" do - assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: []) } - assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: { region: "not-a-hash" }) } - assert_raises(ArgumentError) do - InputRequiredResult.new(input_requests: { region: { method: "tools/call" } }) - end - assert_raises(ArgumentError) do - InputRequiredResult.new(input_requests: { region: { method: "elicitation/create", params: "x" } }) - end - end - - test "normalizes keys, tolerates string entry keys, and freezes" do - result = InputRequiredResult.new(input_requests: { - region: { "method" => "elicitation/create", "params" => { message: "Which region?" } }, - }) - - assert_predicate result, :frozen? - assert_equal ["region"], result.input_requests.keys - assert_equal "elicitation/create", result.input_requests["region"][:method] - assert_equal({ message: "Which region?" }, result.input_requests["region"][:params]) - end - - test "#to_h serializes the SEP-2322 wire shape" do - result = InputRequiredResult.new( - input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, - request_state: "opaque-state", - ) - - assert_equal( - { - resultType: "input_required", - inputRequests: { "region" => { method: "elicitation/create", params: { message: "Which region?" } } }, - requestState: "opaque-state", - }, - result.to_h, - ) - end - - test "#to_h omits absent fields" do - state_only = InputRequiredResult.new(request_state: "opaque-state") - - assert_equal({ resultType: "input_required", requestState: "opaque-state" }, state_only.to_h) - end - - test "#required_client_capabilities maps every embedded request kind" do - result = InputRequiredResult.new(input_requests: { - form: { method: "elicitation/create", params: { message: "?" } }, - url: { method: "elicitation/create", params: { mode: "url", url: "https://example.com" } }, - plain_sampling: { method: "sampling/createMessage", params: { messages: [] } }, - tool_sampling: { method: "sampling/createMessage", params: { messages: [], tools: [] } }, - roots: { method: "roots/list" }, - }) - - assert_equal( - { - elicitation: { form: {}, url: {} }, - sampling: { tools: {} }, - roots: {}, - }, - result.required_client_capabilities, - ) - end - - test "#missing_client_capabilities returns only undeclared branches" do - result = InputRequiredResult.new(input_requests: { - form: { method: "elicitation/create", params: { message: "?" } }, - roots: { method: "roots/list" }, - }) - - assert_equal( - { elicitation: { form: {} }, roots: {} }, - result.missing_client_capabilities({}), - ) - assert_equal( - { roots: {} }, - result.missing_client_capabilities({ elicitation: { form: {} } }), - ) - assert_empty result.missing_client_capabilities({ "elicitation" => { "form" => {} }, "roots" => {} }) - end - - test "#missing_client_capabilities treats a bare elicitation declaration as implying form" do - # 2025 back-compat rule shared with the TypeScript and Python SDKs; an explicit - # url-only declaration does not imply form support. - result = InputRequiredResult.new(input_requests: { - form: { method: "elicitation/create", params: { message: "?" } }, - }) - - assert_empty result.missing_client_capabilities({ elicitation: {} }) - assert_equal( - { elicitation: { form: {} } }, - result.missing_client_capabilities({ elicitation: { url: {} } }), - ) - end - end - end -end diff --git a/test/mcp/server/request_state_security_test.rb b/test/mcp/server/request_state_security_test.rb deleted file mode 100644 index 29b7d79..0000000 --- a/test/mcp/server/request_state_security_test.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - class Server - class RequestStateSecurityTest < ActiveSupport::TestCase - include ActiveSupport::Testing::TimeHelpers - - KEY = ("k" * 32).freeze - - test "validates constructor arguments" do - assert_raises(ArgumentError) { RequestStateSecurity.new(key: "short") } - assert_raises(ArgumentError) { RequestStateSecurity.new(key: KEY, ttl: 0) } - end - - test "seals and unseals a state bound to the originating request" do - security = RequestStateSecurity.new(key: KEY, audience: "my_server") - sealed = security.seal("plain-state", method: "tools/call", target: "my_tool", arguments_digest: "digest") - - assert sealed.start_with?("v1.") - refute_includes sealed, "plain-state" - assert_equal "plain-state", - security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest") - end - - test "rejects expired tokens" do - security = RequestStateSecurity.new(key: KEY, ttl: 60) - sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d") - - travel 120 do - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d") - end - end - end - - test "rejects claims mismatches fail-closed" do - security = RequestStateSecurity.new(key: KEY, audience: "my_server") - sealed = security.seal("state", method: "tools/call", target: "my_tool", arguments_digest: "digest") - - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal(sealed, method: "prompts/get", target: "my_tool", arguments_digest: "digest") - end - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal(sealed, method: "tools/call", target: "other_tool", arguments_digest: "digest") - end - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "other") - end - - other_audience = RequestStateSecurity.new(key: KEY, audience: "other_server") - assert_raises(RequestStateSecurity::InvalidStateError) do - other_audience.unseal(sealed, method: "tools/call", target: "my_tool", arguments_digest: "digest") - end - end - - test "rejects tampered and malformed tokens" do - security = RequestStateSecurity.new(key: KEY) - sealed = security.seal("state", method: "tools/call", target: "t", arguments_digest: "d") - - tampered = sealed.dup - tampered[-1] = tampered[-1] == "A" ? "B" : "A" - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal(tampered, method: "tools/call", target: "t", arguments_digest: "d") - end - - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal("not-a-token", method: "tools/call", target: "t", arguments_digest: "d") - end - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal("v1.!!!", method: "tools/call", target: "t", arguments_digest: "d") - end - assert_raises(RequestStateSecurity::InvalidStateError) do - security.unseal(nil, method: "tools/call", target: "t", arguments_digest: "d") - end - end - - test "rejects tokens sealed under a different key" do - sealed = RequestStateSecurity.new(key: KEY) - .seal("state", method: "tools/call", target: "t", arguments_digest: "d") - other = RequestStateSecurity.new(key: "x" * 32) - - assert_raises(RequestStateSecurity::InvalidStateError) do - other.unseal(sealed, method: "tools/call", target: "t", arguments_digest: "d") - end - end - end - end -end diff --git a/test/mcp/server/transports/stdio_transport_test.rb b/test/mcp/server/transports/stdio_transport_test.rb index 683e2ae..cdca209 100644 --- a/test/mcp/server/transports/stdio_transport_test.rb +++ b/test/mcp/server/transports/stdio_transport_test.rb @@ -7,7 +7,6 @@ module MCP module Transports class StdioTransportTest < ActiveSupport::TestCase include InstrumentationTestHelper - include InitializeParamsTestHelper setup do configuration = MCP::Configuration.new @@ -551,200 +550,6 @@ module MCP $stdout = original_stdout end end - - test "locks the legacy era on a successful initialize and rejects a later modern envelope" do - responses = run_transport_session([ - initialize_request(id: 1), - modern_tools_list_request(id: 2), - ]) - - refute responses[0].key?(:error) - assert_equal :legacy, session_era - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) - end - - test "initialize requesting 2026-07-28 is counter-offered 2025-11-25 and locks the legacy era" do - # Per the SEP-2575 era model, the handshake never lands on a modern version; the connection proceeds on - # the legacy lifecycle it selected, so a later modern envelope is still rejected. - responses = run_transport_session([ - initialize_request(id: 1, protocol_version: "2026-07-28"), - modern_tools_list_request(id: 2), - ]) - - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, responses[0].dig(:result, :protocolVersion) - assert_equal :legacy, session_era - assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code) - end - - test "locks the modern era on a successful server/discover and rejects a later initialize with -32601" do - responses = run_transport_session([ - { jsonrpc: "2.0", method: "server/discover", id: 1 }, - initialize_request(id: 2), - modern_tools_list_request(id: 3), - ]) - - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions) - assert_equal :modern, session_era - # SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection - # answers it with Method not found. - assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code) - refute responses[2].key?(:error) - end - - test "locks the modern era on a successful request carrying the modern envelope" do - responses = run_transport_session([modern_tools_list_request(id: 1)]) - - refute responses[0].key?(:error) - assert_equal :modern, session_era - end - - test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do - # The era locks only after a response succeeds, so this frame reaches the server unlocked. - # SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern. - responses = run_transport_session([ - { - jsonrpc: "2.0", - method: "resources/subscribe", - id: 1, - params: { - uri: "https://example.invalid/resource", - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - }, - }, - }, - ]) - - assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) - refute_equal :legacy, session_era - end - - test "does not lock an era when the era-distinctive request fails" do - # An unsupported envelope version fails with -32022, so the connection stays unlocked - # and a legacy initialize can still succeed afterwards. - responses = run_transport_session([ - modern_tools_list_request(id: 1, version: "2027-01-01"), - initialize_request(id: 2), - ]) - - assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[0].dig(:error, :code) - refute responses[1].key?(:error) - assert_equal :legacy, session_era - end - - test "requires the modern envelope after a modern era lock" do - responses = run_transport_session([ - modern_tools_list_request(id: 1), - { jsonrpc: "2.0", method: "tools/list", id: 2 }, - ]) - - refute responses[0].key?(:error) - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code) - end - - test "answers removed lifecycle methods with -32601 after a modern era lock" do - removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index| - { jsonrpc: "2.0", method: method, id: index + 2 } - end - responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests) - - refute responses[0].key?(:error) - responses[1..].each_with_index do |response, index| - method = Methods::MODERN_REMOVED_METHODS[index] - assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" - end - end - - test "subscriptions/listen is not served over stdio and answers -32601" do - # Like the Python SDK, the stream-pair transport does not serve the SEP-2575 notification subscription stream. - listen = { - jsonrpc: "2.0", - method: "subscriptions/listen", - id: "listen-1", - params: { - notifications: { toolsListChanged: true }, - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - }, - }, - } - - responses = run_transport_session([listen]) - - assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code) - end - - test "#send_request raises on a modern-locked session" do - run_transport_session([modern_tools_list_request(id: 1)]) - - error = assert_raises(RuntimeError) do - @transport.send_request("roots/list") - end - assert_match(/modern lifecycle/, error.message) - end - - private - - def initialize_request(id:, protocol_version: "2025-11-25") - { - jsonrpc: "2.0", - method: "initialize", - id: id, - params: initialize_params( - protocolVersion: protocol_version, - clientInfo: { name: "legacy_client", version: "1.0" }, - ), - } - end - - # `tools/list` stands in as the era-distinctive modern request: `ping` cannot, - # because SEP-2575 removes it from the modern lifecycle (-32601 there). - def modern_tools_list_request(id:, version: "2026-07-28") - { - jsonrpc: "2.0", - method: "tools/list", - id: id, - params: { - _meta: { - "io.modelcontextprotocol/protocolVersion": version, - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - }, - }, - } - end - - # Feeds the frames to a fresh transport session over swapped stdio and returns the parsed responses in order. - def run_transport_session(frames) - input = StringIO.new(frames.map { |frame| JSON.generate(frame) }.join("\n") + "\n") - output = StringIO.new - - original_stdin = $stdin - original_stdout = $stdout - - begin - $stdin = input - $stdout = output - - thread = Thread.new { @transport.open } - sleep(0.1) - @transport.close - thread.join - ensure - $stdin = original_stdin - $stdout = original_stdout - end - - output.string.each_line.map { |line| JSON.parse(line, symbolize_names: true) } - end - - def session_era - @transport.instance_variable_get(:@session).era - end end end end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index bf032b2..099686e 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -41,9 +41,7 @@ module MCP prompts: [], resources: [], ) - # Disable the listen keepalive by default so opening a listen stream in a test does not - # spawn a background timer thread; the keepalive is exercised by its own dedicated tests. - @transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: nil) + @transport = StreamableHTTPTransport.new(@server) end teardown do @@ -248,7 +246,7 @@ module MCP body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] assert_equal "123", body["id"] - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end test "rejects duplicate initialize with existing Mcp-Session-Id and preserves session" do @@ -371,7 +369,7 @@ module MCP response = @transport.handle_request(request) assert_equal 202, response[0] - refute response[1].key?("mcp-session-id"), "no session id should leak from an id-less init" + refute response[1].key?("Mcp-Session-Id"), "no session id should leak from an id-less init" assert_equal [], response[2] assert_equal({}, @transport.instance_variable_get(:@sessions)) end @@ -662,8 +660,8 @@ module MCP assert_equal 200, response[0] body = JSON.parse(response[2][0]) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") - refute response[1].key?("mcp-session-id") + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + refute response[1].key?("Mcp-Session-Id") end test "allows server/discover POST without session ID in stateless mode" do @@ -680,7 +678,7 @@ module MCP assert_equal 200, response[0] body = JSON.parse(response[2][0]) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") end test "rejects duplicate SSE connection with 409" do @@ -1842,26 +1840,7 @@ module MCP assert_equal "text/event-stream", response[1]["content-type"] end - test "POST initialize request ignores MCP-Protocol-Version header among legacy versions" do - # Per SEP-2575 header-primary routing, stable-only header values stay on the legacy path - # where `initialize` treats the body `params.protocolVersion` as authoritative. - # An unknown header value routes to the modern path (see the test below), and under - # the dual-era 2026-07-28 header a sessionless `initialize` stays legacy as well. - request = create_rack_request( - "POST", - "/", - { - "CONTENT_TYPE" => "application/json", - "HTTP_MCP_PROTOCOL_VERSION" => "2024-11-05", - }, - { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, - ) - - response = @transport.handle_request(request) - assert_equal 200, response[0] - end - - test "POST initialize request with a non-legacy MCP-Protocol-Version header is rejected as modern traffic" do + test "POST initialize request ignores MCP-Protocol-Version header" do request = create_rack_request( "POST", "/", @@ -1873,10 +1852,7 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 400, response[0] - body = JSON.parse(response[2][0]) - assert_equal(-32022, body.dig("error", "code")) - assert_equal "1900-01-01", body.dig("error", "data", "requested") + assert_equal 200, response[0] end test "POST initialize request negotiates body protocolVersion when header is an older supported version" do @@ -1902,9 +1878,7 @@ module MCP response = @transport.handle_request(request) assert_equal 200, response[0] body = JSON.parse(response[2][0]) - # The body's requested version drives negotiation (not the older header); - # the default modern request is counter-offered the latest handshake version. - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, body["result"]["protocolVersion"] + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, body["result"]["protocolVersion"] end test "POST initialize request negotiates body protocolVersion when header is a newer supported version" do @@ -1958,13 +1932,12 @@ module MCP assert_equal 400, response[0] assert_equal({ "content-type" => "application/json" }, response[1]) - # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic and - # receives `-32022` with the supported list instead of the legacy `-32600` message. body = JSON.parse(response[2][0]) assert_equal "2.0", body["jsonrpc"] - assert_equal(-32022, body["error"]["code"]) - assert_equal "1999-01-01", body.dig("error", "data", "requested") - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported") + assert_nil body["id"] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] + assert_includes body["error"]["message"], "1999-01-01" + assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION end test "POST request with malformed MCP-Protocol-Version returns 400" do @@ -1992,8 +1965,7 @@ module MCP assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_equal(-32022, body["error"]["code"]) - assert_equal "not-a-version", body.dig("error", "data", "requested") + assert_includes body["error"]["message"], "not-a-version" end test "POST request with supported MCP-Protocol-Version succeeds" do @@ -2021,34 +1993,6 @@ module MCP assert_equal 200, response[0] end - test "POST request with the 2026-07-28 MCP-Protocol-Version header succeeds" do - init_request = create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, - ) - init_response = @transport.handle_request(init_request) - # The handshake never negotiates a modern version (SEP-2575 era model); - # the session proceeds on the counter-offered 2025-11-25. - assert_equal "2025-11-25", JSON.parse(init_response[2][0])["result"]["protocolVersion"] - session_id = init_response[1]["mcp-session-id"] - - request = create_rack_request( - "POST", - "/", - { - "CONTENT_TYPE" => "application/json", - "HTTP_MCP_SESSION_ID" => session_id, - "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", - }, - { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, - ) - - response = @transport.handle_request(request) - assert_equal 200, response[0] - end - test "POST request without MCP-Protocol-Version header succeeds" do init_request = create_rack_request( "POST", @@ -2086,10 +2030,10 @@ module MCP ) response = @transport.send(:validate_protocol_version_header, request) - assert_equal(400, response[0]) + assert_equal 400, response[0] body = JSON.parse(response[2][0]) - assert_includes(body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION) + assert_includes body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION ensure MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, original_versions) @@ -2151,9 +2095,7 @@ module MCP assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] end - test "GET request with a non-legacy MCP-Protocol-Version returns 405" do - # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic, - # and the modern lifecycle removed the GET listening stream. + test "GET request with unsupported MCP-Protocol-Version returns 400" do init_request = create_rack_request( "POST", "/", @@ -2173,7 +2115,10 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 405, response[0] + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] end test "GET request without MCP-Protocol-Version header succeeds" do @@ -2196,9 +2141,7 @@ module MCP assert_equal 200, response[0] end - test "DELETE request with a non-legacy MCP-Protocol-Version returns 405" do - # Per SEP-2575 header-primary routing, a non-legacy version is modern traffic, - # and the modern lifecycle has no DELETE (there is no session to terminate). + test "DELETE request with unsupported MCP-Protocol-Version returns 400" do init_request = create_rack_request( "POST", "/", @@ -2218,10 +2161,13 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 405, response[0] + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] end - test "DELETE request with a non-legacy MCP-Protocol-Version returns 405 in stateless mode" do + test "DELETE request with unsupported MCP-Protocol-Version returns 400 in stateless mode" do stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) request = create_rack_request( @@ -2231,11 +2177,10 @@ module MCP ) response = stateless_transport.handle_request(request) - assert_equal 405, response[0] + assert_equal 400, response[0] end - test "DELETE request with an unknown session and a non-legacy MCP-Protocol-Version returns 405" do - # Header-primary routing (SEP-2575) decides the era before any session validation. + test "DELETE request validates session before MCP-Protocol-Version" do request = create_rack_request( "DELETE", "/", @@ -2246,7 +2191,7 @@ module MCP ) response = @transport.handle_request(request) - assert_equal 405, response[0] + assert_equal 404, response[0] end test "stateless mode allows requests without session IDs, responding with no session ID" do @@ -2943,18 +2888,6 @@ module MCP assert_equal("Stateless mode does not support server-to-client requests.", error.message) end - test "send_request refuses to run at all in the modern lifecycle" do - # SEP-2575 removes server-initiated requests, so a modern handler reaching for elicitation or sampling - # is told why rather than being sent down a path that fails on an unrelated session lookup. - modern_session = ServerSession.new(server: @server, transport: @transport, session_id: "modern-1", era: :modern) - - error = assert_raises(RuntimeError) do - @transport.send_request("elicitation/create", { message: "hi" }, session_id: "modern-1", server_session: modern_session) - end - - assert_equal("Server-initiated requests are not available in the modern lifecycle (SEP-2575).", error.message) - end - test "send_request raises error when session_id is not provided" do error = assert_raises(RuntimeError) do @transport.send_request("sampling/createMessage", { "messages" => [] }) @@ -3057,89 +2990,6 @@ module MCP assert_equal "Hi there", result[:content][:text] end - test "send_request times out when the client never answers" do - # The spec asks implementations to bound every sent request so an unanswered one cannot - # exhaust the sender's resources. - transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05) - session_id = open_sse_session(transport) - - error = assert_raises(MCP::Server::RequestTimeoutError) do - transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id) - end - - assert_equal 0.05, error.timeout - assert_includes error.message, "sampling/createMessage" - - # Left uncaught in a handler, the originating request is answered with a timeout code rather than - # a generic internal error, so the peer that failed to answer can tell the two apart. - assert_equal(-32001, error.error_code) - ensure - transport.close - end - - test "send_request timeout is overridable per request" do - transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 60) - session_id = open_sse_session(transport) - - error = assert_raises(MCP::Server::RequestTimeoutError) do - transport.send_request("ping", nil, session_id: session_id, timeout: 0.05) - end - - assert_equal 0.05, error.timeout - ensure - transport.close - end - - test "send_request tells the peer it gave up when the timeout fires" do - # These requests only happen on 2025-11-25 and earlier connections, whose spec asks the sender - # to issue a cancellation notification for the request it stops waiting on. - transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05) - io = StringIO.new - session_id = open_sse_session(transport, io: io) - session = transport.instance_variable_get(:@sessions)[session_id][:server_session] - - assert_raises(MCP::Server::RequestTimeoutError) do - transport.send_request("ping", nil, session_id: session_id, server_session: session) - end - - events = io.string.lines.grep(/\Adata: /).map { |line| JSON.parse(line.sub("data: ", "")) } - cancellation = events.find { |event| event["method"] == "notifications/cancelled" } - refute_nil cancellation, "expected the peer to be told the request was abandoned" - assert_match(/Timed out after 0.05 seconds/, cancellation.dig("params", "reason")) - ensure - transport.close - end - - test "send_request returns the response when it arrives before the timeout" do - transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 30) - io = StringIO.new - session_id = open_sse_session(transport, io: io) - - result_queue = Queue.new - Thread.new do - result_queue.push(transport.send_request("ping", nil, session_id: session_id)) - end - wait_until { io.string.include?("ping") } - - request_id = JSON.parse(io.string.lines.grep(/\Adata: /).first.sub("data: ", ""))["id"] - transport.handle_request(create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, - { jsonrpc: "2.0", id: request_id, result: { ok: true } }.to_json, - )) - - assert_equal({ ok: true }, result_queue.pop) - ensure - transport.close - end - - test "server_to_client_request_timeout rejects a non-positive value" do - assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0) } - assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: -1) } - assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: nil) } - end - test "send_request with related_request_id rides the originating POST stream, not the GET stream" do # Per SEP-2260, server-to-client requests associated with a client request are delivered on # that request's POST SSE response stream rather than the standalone GET stream. @@ -3431,110 +3281,6 @@ module MCP assert_equal "ok", result[:content][:text] end - test "refuses a POST reusing an in-flight request id and keeps the original stream" do - server = Server.new(name: "test", tools: [], prompts: [], resources: []) - transport = StreamableHTTPTransport.new(server) - server.transport = transport - - gate = Queue.new - server.define_tool(name: "victim_tool") do |server_context:| - server_context.report_progress(1, message: "first-frame") - gate.pop - server_context.report_progress(2, message: "second-frame") - Tool::Response.new([{ type: "text", text: "done" }]) - end - - session_id, server_session = start_session(transport) - - victim = transport.handle_request(colliding_tool_call(session_id, "req-1")) - victim_stream = TestStream.new - victim_thread = Thread.new { victim[2].call(victim_stream) } - sleep(0.01) until server_session.lookup_in_flight("req-1") - - attacker = transport.handle_request(colliding_tool_call(session_id, "req-1")) - - assert_equal 409, attacker[0] - assert_equal( - JsonRpcHandler::ErrorCode::INVALID_REQUEST, - JSON.parse(attacker[2][0]).dig("error", "code"), - ) - - gate << :go - victim_thread.join - - # The registration survived the refusal, so the frame emitted after it still reaches - # the request that asked for it. - assert_includes victim_stream.string, "first-frame" - assert_includes victim_stream.string, "second-frame" - end - - test "a finishing request leaves a stream another request registered under the same id" do - server = Server.new(name: "test", tools: [], prompts: [], resources: []) - transport = StreamableHTTPTransport.new(server) - server.transport = transport - - gate = Queue.new - server.define_tool(name: "victim_tool") do |server_context:| - gate.pop - Tool::Response.new([{ type: "text", text: "done" }]) - end - - session_id, server_session = start_session(transport) - - victim = transport.handle_request(colliding_tool_call(session_id, "req-1")) - victim_stream = TestStream.new - victim_thread = Thread.new { victim[2].call(victim_stream) } - sleep(0.01) until server_session.lookup_in_flight("req-1") - - # Stand in for a stream that won the registration in a race the pre-dispatch refusal normally prevents. - # Finishing the other request must not unregister it. - foreign_stream = TestStream.new - sessions = transport.instance_variable_get(:@sessions) - sessions[session_id][:post_request_streams]["req-1"] = foreign_stream - - gate << :go - victim_thread.join - - assert_same foreign_stream, sessions[session_id][:post_request_streams]["req-1"] - end - - test "a broken request-scoped stream drops only itself, even when it is not the registered one" do - server = Server.new(name: "test", tools: [], prompts: [], resources: []) - transport = StreamableHTTPTransport.new(server) - server.transport = transport - - session_id, = start_session(transport) - sessions = transport.instance_variable_get(:@sessions) - registered = TestStream.new - sessions[session_id][:post_request_streams] = { "req-1" => registered } - - transport.send(:drop_broken_stream, session_id, TestStream.new, "req-1") - - assert sessions.key?(session_id), "a request-scoped failure must not tear down the session" - assert_same registered, sessions[session_id][:post_request_streams]["req-1"] - end - - test "allows a request id to be reused once the earlier request has finished" do - server = Server.new(name: "test", tools: [], prompts: [], resources: []) - transport = StreamableHTTPTransport.new(server) - server.transport = transport - - server.define_tool(name: "victim_tool") do |server_context:| - Tool::Response.new([{ type: "text", text: "done" }]) - end - - session_id, = start_session(transport) - - 2.times do - response = transport.handle_request(colliding_tool_call(session_id, "req-1")) - - assert_equal 200, response[0] - stream = TestStream.new - response[2].call(stream) - assert_includes stream.string, "done" - end - end - test "JSON response mode returns accepted when cancellation suppresses response" do server = Server.new(name: "test", tools: [], prompts: [], resources: []) transport = StreamableHTTPTransport.new(server, enable_json_response: true) @@ -5496,831 +5242,6 @@ module MCP transport.close end - test "modern POST serves a single sessionless JSON exchange" do - @server.define_tool(name: "echo_tool") do - Tool::Response.new([{ type: "text", text: "ok" }]) - end - - response = @transport.handle_request(modern_rack_request( - modern_body("tools/call", { name: "echo_tool", arguments: {} }), - headers: { "HTTP_MCP_METHOD" => "tools/call", "HTTP_MCP_NAME" => "echo_tool" }, - )) - - assert_equal 200, response[0] - refute response[1].key?("mcp-session-id") - body = JSON.parse(response[2][0]) - assert_equal "ok", body.dig("result", "content", 0, "text") - end - - test "modern POST with an unsupported header version returns 400 with -32022 and the supported list" do - response = @transport.handle_request(modern_rack_request( - modern_body("ping", {}, version: "2027-01-01"), - version: "2027-01-01", - )) - - assert_equal 400, response[0] - body = JSON.parse(response[2][0]) - assert_equal(-32022, body.dig("error", "code")) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported") - assert_equal "2027-01-01", body.dig("error", "data", "requested") - end - - test "modern POST with a header/body version mismatch returns 400 with -32020" do - # Header names the supported modern version; the body envelope claims another. - response = @transport.handle_request(modern_rack_request( - modern_body("ping", {}, version: "2027-01-01"), - )) - - assert_equal 400, response[0] - assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "modern POST with an Mcp-Method header mismatch returns 400 with -32020" do - response = @transport.handle_request(modern_rack_request( - modern_body("ping", {}), - headers: { "HTTP_MCP_METHOD" => "tools/call" }, - )) - - assert_equal 400, response[0] - assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "modern POST without the Mcp-Method header returns 400 with -32020" do - # The header is required on every modern POST; a missing required standard header is - # a validation failure, matching the TypeScript SDK's enforcement. - response = @transport.handle_request(modern_rack_request( - modern_body("ping", {}), - method_header: nil, - )) - - assert_equal 400, response[0] - body = JSON.parse(response[2][0]) - assert_equal(-32020, body.dig("error", "code")) - assert_includes body.dig("error", "message"), "Mcp-Method" - end - - test "modern POST without the Mcp-Name header on a name-bearing method returns 400 with -32020" do - response = @transport.handle_request(modern_rack_request( - modern_body("tools/call", { name: "some_tool", arguments: {} }), - name_header: nil, - )) - - assert_equal 400, response[0] - body = JSON.parse(response[2][0]) - assert_equal(-32020, body.dig("error", "code")) - assert_includes body.dig("error", "message"), "Mcp-Name" - end - - test "modern POST decodes the base64 Mcp-Name sentinel and enforces the match" do - # `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives - # wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`. - uri = "file:///日本語.txt" - encoded = "=?base64?#{[uri].pack("m0")}?=" - - matched = @transport.handle_request(modern_rack_request( - modern_body("resources/read", { uri: uri }), - headers: { "HTTP_MCP_NAME" => encoded }, - )) - mismatched = @transport.handle_request(modern_rack_request( - modern_body("resources/read", { uri: "file:///other.txt" }), - headers: { "HTTP_MCP_NAME" => encoded }, - )) - - assert_equal 200, matched[0] - assert_equal 400, mismatched[0] - assert_equal(-32020, JSON.parse(mismatched[2][0]).dig("error", "code")) - end - - test "modern POST rejects an Mcp-Session-Id header" do - response = @transport.handle_request(modern_rack_request( - modern_body("ping", {}), - headers: { "HTTP_MCP_SESSION_ID" => "some-session" }, - )) - - assert_equal 400, response[0] - assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "modern GET and DELETE are not allowed" do - get_request = create_rack_request( - "GET", - "/", - { "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, - ) - delete_request = create_rack_request( - "DELETE", - "/", - { "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, - ) - - assert_equal 405, @transport.handle_request(get_request)[0] - assert_equal 405, @transport.handle_request(delete_request)[0] - end - - test "modern POST maps an unknown method to 404 with -32601" do - response = @transport.handle_request(modern_rack_request(modern_body("no/such_method", {}))) - - assert_equal 404, response[0] - assert_equal(-32601, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "modern POST requires the _meta envelope" do - response = @transport.handle_request(modern_rack_request( - { jsonrpc: "2.0", method: "tools/list", id: 1 }.to_json, - )) - - assert_equal 400, response[0] - assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do - body = { - jsonrpc: "2.0", - method: "tools/list", - id: 1, - params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, - }.to_json - - response = @transport.handle_request(modern_rack_request(body)) - - assert_equal 400, response[0] - assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "modern POST serves an envelope without the optional clientInfo" do - body = { - jsonrpc: "2.0", - method: "tools/list", - id: 1, - params: { - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": {}, - }, - }, - }.to_json - - response = @transport.handle_request(modern_rack_request(body)) - - assert_equal 200, response[0] - assert JSON.parse(response[2][0]).key?("result") - end - - test "modern POST answers removed lifecycle methods with 404 and -32601" do - MCP::Methods::MODERN_REMOVED_METHODS.each do |method| - response = @transport.handle_request(modern_rack_request(modern_body(method, {}))) - - assert_equal 404, response[0], "expected 404 for #{method}" - body = JSON.parse(response[2][0]) - assert_equal(-32601, body.dig("error", "code"), "expected -32601 for #{method}") - assert_equal 1, body["id"], "expected the request id to be preserved for #{method}" - end - end - - test "modern POST maps a missing client capability to 400 with -32021" do - @server.define_tool(name: "guarded_tool") do |server_context:| - server_context.require_client_capability!(:elicitation, :form) - Tool::Response.new([{ type: "text", text: "ok" }]) - end - - response = @transport.handle_request(modern_rack_request( - modern_body("tools/call", { name: "guarded_tool", arguments: {} }), - headers: { "HTTP_MCP_NAME" => "guarded_tool" }, - )) - - assert_equal 400, response[0] - body = JSON.parse(response[2][0]) - assert_equal(-32021, body.dig("error", "code")) - assert_equal({ "elicitation" => { "form" => {} } }, body.dig("error", "data", "requiredCapabilities")) - end - - test "modern POST streams handler notifications ahead of the final response" do - @server.define_tool(name: "progress_tool") do |server_context:| - server_context.report_progress(50, total: 100) - Tool::Response.new([{ type: "text", text: "done" }]) - end - - body = { - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { - name: "progress_tool", - arguments: {}, - _meta: { - progressToken: "tok-1", - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - }, - }, - }.to_json - - response = @transport.handle_request(modern_rack_request(body)) - - assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["content-type"] - frames = response[2][0].split("\n\n").reject(&:empty?).map do |chunk| - JSON.parse(chunk.sub(/\Aevent: message\ndata: /, "")) - end - assert_equal "notifications/progress", frames.first["method"] - assert_equal "tok-1", frames.first.dig("params", "progressToken") - assert frames.last.key?("result"), "expected the final frame to carry the result" - assert_equal 1, frames.last["id"] - end - - test "modern POST bounds the buffered notifications per request" do - # The sink holds notifications in memory until the handler returns, so a handler - # emitting in proportion to client input must not grow one request without bound. - overflow = 5 - delivery_results = [] - @server.define_tool(name: "flood_tool") do |server_context:| - (StreamableHTTPTransport::MAX_MODERN_REQUEST_NOTIFICATIONS + overflow).times do |i| - delivery_results << server_context.notify_log_message(data: "message #{i}", level: "info") - end - Tool::Response.new([{ type: "text", text: "done" }]) - end - - body = { - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { - name: "flood_tool", - arguments: {}, - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - "io.modelcontextprotocol/logLevel": "info", - }, - }, - }.to_json - - response = @transport.handle_request(modern_rack_request(body)) - - assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["content-type"] - frames = response[2][0].split("\n\n").reject(&:empty?) - assert_equal StreamableHTTPTransport::MAX_MODERN_REQUEST_NOTIFICATIONS + 1, frames.size - assert_equal overflow, delivery_results.count { |result| result == false } - end - - test "modern POST without handler notifications keeps the single JSON exchange" do - response = @transport.handle_request(modern_rack_request(modern_body("tools/list", {}))) - - assert_equal 200, response[0] - assert_equal "application/json", response[1]["content-type"] - end - - test "modern POST suppresses log notifications without the envelope logLevel" do - # SEP-2575: log delivery is authorized per request via the `_meta` logLevel; - # the server-wide level does not apply to modern requests. - @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") - @server.define_tool(name: "logging_tool") do |server_context:| - server_context.notify_log_message(data: "hi", level: "info") - Tool::Response.new([{ type: "text", text: "done" }]) - end - - response = @transport.handle_request(modern_rack_request( - modern_body("tools/call", { name: "logging_tool", arguments: {} }), - )) - - assert_equal 200, response[0] - assert_equal "application/json", response[1]["content-type"] - refute_includes response[2][0], "notifications/message" - end - - test "modern POST delivers log notifications when the envelope carries logLevel" do - @server.define_tool(name: "logging_tool") do |server_context:| - server_context.notify_log_message(data: "hi", level: "info") - Tool::Response.new([{ type: "text", text: "done" }]) - end - - body = { - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { - name: "logging_tool", - arguments: {}, - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - "io.modelcontextprotocol/logLevel": "info", - }, - }, - }.to_json - - response = @transport.handle_request(modern_rack_request(body)) - - assert_equal 200, response[0] - assert_equal "text/event-stream", response[1]["content-type"] - assert_includes response[2][0], "notifications/message" - end - - test "modern POST treats an unrecognized envelope logLevel as absent" do - # Matching the Python SDK, an unrecognized level reads as absent: - # delivery stays off instead of erroring on every log call. - reported = [] - configuration = MCP::Configuration.new(exception_reporter: ->(e, _ctx) { reported << e }) - server = Server.new(name: "log_level_test", configuration: configuration) - transport = StreamableHTTPTransport.new(server, dns_rebinding_protection: false) - server.define_tool(name: "logging_tool") do |server_context:| - server_context.notify_log_message(data: "hi", level: "info") - Tool::Response.new([{ type: "text", text: "done" }]) - end - - body = { - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { - name: "logging_tool", - arguments: {}, - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - "io.modelcontextprotocol/logLevel": "bogus", - }, - }, - }.to_json - - response = transport.handle_request(modern_rack_request(body)) - - assert_equal 200, response[0] - assert_equal "application/json", response[1]["content-type"] - refute_includes response[2][0], "notifications/message" - assert_empty reported - end - - test "modern POST serves server/discover carrying the envelope" do - response = @transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) - - assert_equal 200, response[0] - refute response[1].key?("mcp-session-id") - body = JSON.parse(response[2][0]) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") - end - - test "modern POST rejects an envelope-less server/discover with 400 and -32602" do - # The 2026-07-28 conformance requirements validate the `_meta` envelope on `server/discover` like - # on every other modern request (SEP-2575). - response = @transport.handle_request(modern_rack_request( - { jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json, - )) - - assert_equal 400, response[0] - assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "legacy POST rejects a modern envelope body without the modern header as -32020" do - # Without header-primary routing evidence, the body-level triple would silently fall through - # the legacy path via the header default. - session_id = initialize_test_session - - response = @transport.handle_request(create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, - modern_body("ping", {}), - )) - - assert_equal 400, response[0] - assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "sessionless POST initialize with the dual-era header stays legacy and is counter-offered" do - # The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is - # the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle - # and is counter-offered the latest handshake version (SEP-2575 era model). - request = create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, - { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json, - ) - - response = @transport.handle_request(request) - assert_equal 200, response[0] - refute_nil response[1]["mcp-session-id"] - assert_equal "2025-11-25", JSON.parse(response[2][0]).dig("result", "protocolVersion") - end - - test "session-bound POST with the dual-era header stays on the legacy path" do - # A deliberately lenient reading. The header names a version the handshake refused to - # negotiate, so it disagrees with the session; the session wins and the request is served - # at the negotiated revision, unstamped. Rejecting instead would read the spec's - # "invalid or unsupported MCP-Protocol-Version MUST be 400" as covering a version this - # server does in fact support (through the modern lifecycle), and would break a client - # over what is a SHOULD on its side: sending the negotiated version rather than its own. - session_id = initialize_test_session - - response = @transport.handle_request(create_rack_request( - "POST", - "/", - { - "CONTENT_TYPE" => "application/json", - "HTTP_MCP_SESSION_ID" => session_id, - "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", - }, - { jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json, - )) - - assert_equal 200, response[0] - - io = StringIO.new - response[2].call(io) - body = sse_events(io).first - - # No `resultType`: the header claim does not move the session onto the modern wire. - refute(body["result"].key?("resultType")) - end - - test "session-bound DELETE with the dual-era header terminates the legacy session" do - session_id = initialize_test_session - - response = @transport.handle_request(create_rack_request( - "DELETE", - "/", - { - "HTTP_MCP_SESSION_ID" => session_id, - "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", - }, - )) - - assert_equal 200, response[0] - end - - test "session-bound POST with the dual-era header carrying the modern envelope is rejected as -32600" do - # The session already negotiated the legacy lifecycle, and a connection can never change eras - # (mirroring the stdio era lock). - session_id = initialize_test_session - - response = @transport.handle_request(create_rack_request( - "POST", - "/", - { - "CONTENT_TYPE" => "application/json", - "HTTP_MCP_SESSION_ID" => session_id, - "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28", - }, - modern_body("ping", {}), - )) - - assert_equal 400, response[0] - body = JSON.parse(response[2][0]) - assert_equal(-32600, body.dig("error", "code")) - assert_includes body.dig("error", "message"), "legacy lifecycle" - end - - test "modern POST larger than max_request_bytes returns 413" do - transport = StreamableHTTPTransport.new(@server, max_request_bytes: 64) - request = create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" }, - modern_body("ping", { filler: "A" * 200 }), - ) - - response = transport.handle_request(request) - assert_equal(413, response[0]) - ensure - transport.close - end - - test "subscriptions/listen opens an SSE stream and acknowledges the honored subset first" do - # `resourceSubscriptions` honoring requires the `resources.subscribe` capability flag, - # which the server defaults do not declare. - server = Server.new( - name: "listen_test", - capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } }, - ) - transport = StreamableHTTPTransport.new(server) - - io = open_listen_stream( - id: "listen-1", - notifications: { toolsListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, - transport: transport, - ) - - events = sse_events(io) - assert_equal(1, events.size) - ack = events[0] - assert_equal("notifications/subscriptions/acknowledged", ack["method"]) - assert_equal( - { "toolsListChanged" => true, "resourceSubscriptions" => ["file:///a.txt"] }, - ack.dig("params", "notifications"), - ) - assert_equal("listen-1", ack.dig("params", "_meta", "io.modelcontextprotocol/subscriptionId")) - ensure - transport.close - end - - test "SSE responses disable proxy buffering" do - # The spec asks servers to send `X-Accel-Buffering: no` on SSE streams so a reverse proxy - # delivers events as they are written; on a listen stream, buffering would also swallow - # the keepalive frames that detect a dropped peer. - listen = @transport.handle_request(modern_rack_request( - modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), - )) - - session_id = initialize_test_session - get_stream = @transport.handle_request(create_rack_request("GET", "/", { "HTTP_MCP_SESSION_ID" => session_id })) - - assert_equal("no", listen[1]["x-accel-buffering"]) - assert_equal("no", get_stream[1]["x-accel-buffering"]) - end - - test "subscriptions/listen acknowledgement omits notification types the server does not support" do - server = Server.new(name: "listen_test", capabilities: { tools: { listChanged: true } }) - transport = StreamableHTTPTransport.new(server) - - io = open_listen_stream( - id: "listen-1", - notifications: { toolsListChanged: true, promptsListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, - transport: transport, - ) - - ack = sse_events(io)[0] - assert_equal({ "toolsListChanged" => true }, ack.dig("params", "notifications")) - ensure - transport.close - end - - test "subscriptions/listen requires a notifications filter object" do - response = @transport.handle_request(modern_rack_request( - modern_listen_body(id: "listen-1", params: {}), - )) - - assert_equal 400, response[0] - assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "subscriptions/listen requires the modern _meta envelope" do - response = @transport.handle_request(modern_rack_request( - { jsonrpc: "2.0", method: "subscriptions/listen", id: "listen-1", params: { notifications: {} } }.to_json, - )) - - assert_equal 400, response[0] - assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code")) - end - - test "subscriptions/listen delivers only opted-in notifications with the subscriptionId" do - io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - - @server.notify_tools_list_changed - # Not opted in: MUST NOT be delivered. - @server.notify_prompts_list_changed - - events = sse_events(io) - assert_equal 2, events.size - assert_equal "notifications/subscriptions/acknowledged", events[0]["method"] - assert_equal "notifications/tools/list_changed", events[1]["method"] - assert_equal "listen-1", events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") - end - - test "subscriptions/listen delivers resource updates only for subscribed URIs" do - server = Server.new( - name: "listen_test", - capabilities: { resources: { listChanged: true, subscribe: true } }, - ) - transport = StreamableHTTPTransport.new(server) - - io = open_listen_stream( - id: "listen-1", - notifications: { resourceSubscriptions: ["file:///subscribed.txt"] }, - transport: transport, - ) - - # `**{}` keeps the params Hash positional on Ruby 2.7, matching the other `send_notification` tests. - transport.send_notification("notifications/resources/updated", { uri: "file:///subscribed.txt" }, **{}) - transport.send_notification("notifications/resources/updated", { uri: "file:///other.txt" }, **{}) - - events = sse_events(io) - assert_equal(2, events.size) - assert_equal("notifications/resources/updated", events[1]["method"]) - assert_equal("file:///subscribed.txt", events[1].dig("params", "uri")) - assert_equal("listen-1", events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId")) - ensure - transport.close - end - - test "notifications are delivered only after the listen acknowledgement" do - # Inject an entry in the registered-but-not-yet-acknowledged state: the window between - # the registry insert and the acknowledgement write, which happens outside the lock. - io = StringIO.new - @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { - stream: io, filter: { toolsListChanged: true }, active: false, write_mutex: Mutex.new - } - - @server.notify_tools_list_changed - - assert_empty sse_events(io) - - @transport.send(:activate_listen_subscription, "listen-1") - @server.notify_tools_list_changed - - assert_equal ["notifications/tools/list_changed"], sse_events(io).map { |event| event["method"] } - end - - test "a delivery racing the graceful teardown cannot write after the final result" do - io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - entry = @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] - - @transport.close - - # Simulate an in-flight delivery that snapshotted the entry before teardown cleared - # the registry: the closed flag set under the write mutex makes it a no-op. - @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = entry - @server.notify_tools_list_changed - - events = sse_events(io) - - assert_equal "complete", events.last.dig("result", "resultType") - refute(events.any? { |event| event["method"] == "notifications/tools/list_changed" }) - end - - test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do - first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true }) - - @server.notify_tools_list_changed - - first_events = sse_events(first) - second_events = sse_events(second) - assert_equal "listen-1", first_events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") - assert_equal "listen-2", second_events[1].dig("params", "_meta", "io.modelcontextprotocol/subscriptionId") - end - - test "subscriptions/listen closes gracefully with a SubscriptionsListenResult on transport close" do - io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - - @transport.close - - events = sse_events(io) - result = events.last - assert_equal "listen-1", result["id"] - assert_equal "listen-1", result.dig("result", "_meta", "io.modelcontextprotocol/subscriptionId") - # `SubscriptionsListenResult` is a 2026-07-28 result, so it carries the REQUIRED `resultType`. - assert_equal "complete", result.dig("result", "resultType") - assert_predicate io, :closed? - - # The subscription is gone: further notifications are not delivered anywhere. - @server.notify_tools_list_changed - assert_equal events, sse_events(io) - end - - test "subscriptions/listen honoring reads the capability flags, not capability presence" do - # A server declaring `tools` without `listChanged: true` promises no - # list-changed delivery, so the acknowledgement omits the type. - server = Server.new(name: "listen_test", capabilities: { tools: {}, resources: { listChanged: true } }) - transport = StreamableHTTPTransport.new(server) - - io = open_listen_stream( - id: "listen-1", - notifications: { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["file:///a.txt"] }, - transport: transport, - ) - - ack = sse_events(io)[0] - assert_equal({ "resourcesListChanged" => true }, ack.dig("params", "notifications")) - ensure - transport.close - end - - test "a buffered read of the listen stream body raises guidance instead of NoMethodError" do - response = @transport.handle_request(modern_rack_request( - modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), - )) - - error = assert_raises(RuntimeError) { response[2].first } - assert_includes error.message, "serve_subscriptions_listen: false" - - # The cited page is a published-URL contract, like the kwarg name above. - assert_includes error.message, "https://ruby.sdk.modelcontextprotocol.io/server/transports/" - - # Responding to `each` would make Rack classify the body as enumerable and break streaming. - refute_respond_to response[2], :each - end - - test "subscriptions/listen is refused as unimplemented when the transport does not serve it" do - transport = StreamableHTTPTransport.new(@server, serve_subscriptions_listen: false) - - status, headers, body = transport.handle_request(modern_rack_request( - modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), - )) - - assert_equal 404, status - assert_equal "application/json", headers["content-type"] - - # The documented Rails controller pattern renders `body.first`, so the refusal must be an Array body, - # never the streaming Proc. - assert_kind_of Array, body - assert_equal(-32601, JSON.parse(body.first).dig("error", "code")) - ensure - transport.close - end - - test "discover stops advertising subscription capabilities when listen is not served" do - server = Server.new( - name: "listen_test", - capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } }, - ) - transport = StreamableHTTPTransport.new(server, serve_subscriptions_listen: false) - - response = transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) - capabilities = JSON.parse(response[2].first).dig("result", "capabilities") - - assert_nil capabilities.dig("tools", "listChanged") - assert_nil capabilities.dig("resources", "listChanged") - assert_nil capabilities.dig("resources", "subscribe") - ensure - transport.close - end - - test "subscriptions/listen past the concurrent stream cap is rejected with 503" do - transport = StreamableHTTPTransport.new(@server, max_listen_subscriptions: 1) - open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) - - response = transport.handle_request(modern_rack_request( - modern_listen_body(id: "listen-2", params: { notifications: { toolsListChanged: true } }), - )) - - assert_equal(503, response[0]) - body = JSON.parse(response[2][0]) - assert_equal("listen-2", body["id"]) - assert_includes(body.dig("error", "message"), "maximum concurrent subscriptions/listen streams") - ensure - transport.close - end - - test "subscriptions/listen rejects a duplicate subscription id by closing the new stream" do - open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - - duplicate = StringIO.new - response = @transport.handle_request(modern_rack_request( - modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }), - )) - response[2].call(duplicate) - - assert_predicate duplicate, :closed? - end - - test "listen keepalive writes a comment frame outside the mutex" do - # Register a subscription backed by a mutex-probing stream, then invoke the ping directly. - mutex = @transport.instance_variable_get(:@mutex) - writes = { total: 0, under_mutex: 0 } - ping = nil - stream = Object.new - stream.define_singleton_method(:write) do |data| - writes[:total] += 1 - mutex.try_lock ? mutex.unlock : writes[:under_mutex] += 1 - ping = data - end - stream.define_singleton_method(:flush) {} - @transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = { stream: stream, filter: {}, write_mutex: Mutex.new } - - @transport.send(:send_listen_keepalive_ping, "listen-1") - - assert_equal 1, writes[:total] - assert_equal 0, writes[:under_mutex], "keepalive write must not hold @mutex" - assert_match(/\A: ping /, ping) - end - - test "listen keepalive frees the slot and closes the stream when the peer is gone" do - transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 0.01) - io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport) - io.define_singleton_method(:write) { |_data| raise Errno::ECONNRESET } - - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2 - until transport.instance_variable_get(:@listen_subscriptions).empty? - flunk("keepalive did not free the slot for a dead peer") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline - sleep(0.01) - end - - assert_predicate io, :closed? - ensure - transport.close - end - - test "listen keepalive is not started when the interval is nil" do - # The set of threads, not their count: `Thread.list` is process-wide, so a thread another test left running - # that finishes between the two samples moves the count in the direction this assertion does not care about. - # Only a thread that appears is evidence of a keepalive timer. - before = Thread.list - open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }) - - assert_empty(Thread.list - before, "a nil interval must not spawn a keepalive thread") - end - - test "listen_keepalive_interval rejects a non-positive value" do - assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, listen_keepalive_interval: 0) } - assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, listen_keepalive_interval: -1) } - end - private def initialize_test_session(id: "init") @@ -6333,36 +5254,6 @@ module MCP @transport.handle_request(init_request)[1]["mcp-session-id"] end - # Polls until the block is truthy; the work being awaited happens on another thread. - def wait_until(timeout: 5) - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout - until yield - flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline - sleep(0.01) - end - end - - # Initializes a session on `transport` and attaches a GET SSE stream to it, so - # server-to-client requests have somewhere to go. Returns the session id. - def open_sse_session(transport, io: StringIO.new) - session_id = transport.handle_request(create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, - ))[1]["mcp-session-id"] - - get_response = transport.handle_request(create_rack_request( - "GET", - "/", - { "HTTP_MCP_SESSION_ID" => session_id }, - )) - get_response[2].call(io) if get_response[2].is_a?(Proc) - wait_until { transport.instance_variable_get(:@sessions).dig(session_id, :get_sse_stream) } - - session_id - end - # Installs a stream that records, on every write, whether `@mutex` was held at that moment. # `mutex.try_lock` fails while the transport still holds the lock, so a nonzero `writes[:under_mutex]` # means a blocking write is being performed under the lock. @@ -6391,36 +5282,6 @@ module MCP writes end - # Initializes `transport` and returns its session id together with the `ServerSession`, - # which the in-flight request id tests poll to know when a request has really started. - def start_session(transport) - request = create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json" }, - { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, - ) - session_id = transport.handle_request(request)[1]["mcp-session-id"] - - [session_id, transport.instance_variable_get(:@sessions)[session_id][:server_session]] - end - - # A `tools/call` for `victim_tool` under a caller-chosen request id, with a progress token so - # the tool's `report_progress` has somewhere to go. - def colliding_tool_call(session_id, request_id) - create_rack_request( - "POST", - "/", - { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, - { - jsonrpc: "2.0", - id: request_id, - method: "tools/call", - params: { name: "victim_tool", arguments: {}, _meta: { progressToken: "tok" } }, - }.to_json, - ) - end - def create_rack_request(method, path, headers, body = nil) default_accept = case method when "POST" @@ -6449,86 +5310,6 @@ module MCP Rack::Request.new(env) end - - # Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header. - # Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST - # and `Mcp-Name` on a name-bearing method whose body carries a target name, so both default to - # the body's values. Pass `method_header: nil` / `name_header: nil` to omit one - # (for tests of the requirement itself) or a String to send a specific value. - def modern_rack_request(body, version: "2026-07-28", headers: {}, method_header: :from_body, - name_header: :from_body) - parsed = begin - JSON.parse(body) - rescue JSON::ParserError - nil - end - parsed = nil unless parsed.is_a?(Hash) - - derived_method = method_header == :from_body ? parsed&.dig("method") : method_header - derived_name = if name_header == :from_body - if StreamableHTTPTransport::NAME_BEARING_METHODS.include?(parsed&.dig("method")) - parsed.dig("params", "name") || parsed.dig("params", "uri") - end - else - name_header - end - - base_headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version } - base_headers["HTTP_MCP_METHOD"] = derived_method if derived_method - base_headers["HTTP_MCP_NAME"] = derived_name if derived_name - create_rack_request("POST", "/", base_headers.merge(headers), body) - end - - # Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope. - def modern_body(method, params, version: "2026-07-28", capabilities: {}) - { - jsonrpc: "2.0", - method: method, - id: 1, - params: params.merge( - _meta: { - "io.modelcontextprotocol/protocolVersion": version, - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": capabilities, - }, - ), - }.to_json - end - - def modern_listen_body(id:, params:) - { - jsonrpc: "2.0", - method: "subscriptions/listen", - id: id, - params: params.merge( - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": {}, - }, - ), - }.to_json - end - - # Opens a `subscriptions/listen` stream on the modern path and returns the StringIO - # backing the SSE stream (already carrying the acknowledgement event). - def open_listen_stream(id:, notifications:, transport: @transport) - response = transport.handle_request(modern_rack_request( - modern_listen_body(id: id, params: { notifications: notifications }), - )) - - assert_equal(200, response[0]) - assert_equal("text/event-stream", response[1]["content-type"]) - - io = StringIO.new - response[2].call(io) - io - end - - # Parses every `data:` event written to an SSE StringIO. - def sse_events(io) - io.string.scan(/^data: (.+)$/).map { |match| JSON.parse(match[0]) } - end end end end diff --git a/test/mcp/server_cancellation_test.rb b/test/mcp/server_cancellation_test.rb index 9fc28cf..9b1908a 100644 --- a/test/mcp/server_cancellation_test.rb +++ b/test/mcp/server_cancellation_test.rb @@ -5,7 +5,6 @@ require "test_helper" module MCP class ServerCancellationTest < ActiveSupport::TestCase include InstrumentationTestHelper - include InitializeParamsTestHelper class MockTransport < Transport attr_reader :requests, :notifications, :cancelled_request_ids @@ -305,7 +304,7 @@ module MCP test "initialize request cannot be cancelled" do init_params = { - protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, clientInfo: { name: "test", version: "1.0" }, capabilities: {}, } @@ -334,74 +333,6 @@ module MCP assert_includes @mock_transport.cancelled_request_ids, "req-9" end - test "register_in_flight refuses an id that is already in flight" do - first = @session.register_in_flight("req-1") - - assert first - assert_nil @session.register_in_flight("req-1"), "a second live request cannot share the id" - assert_same first, @session.lookup_in_flight("req-1"), "the first registration must survive" - end - - test "unregister_in_flight leaves a registration it does not own" do - owner = @session.register_in_flight("req-1") - @session.unregister_in_flight("req-1", cancellation: Cancellation.new(request_id: "req-1")) - - assert_same owner, @session.lookup_in_flight("req-1") - - @session.unregister_in_flight("req-1", cancellation: owner) - - assert_nil @session.lookup_in_flight("req-1") - end - - test "in_flight? reports whether an id is registered" do - refute @session.in_flight?("req-1") - - @session.register_in_flight("req-1") - - assert @session.in_flight?("req-1") - end - - test "a request reusing an in-flight id is answered with Invalid Request" do - @server.define_tool(name: "slow") do |server_context:| - sleep(0.2) - Tool::Response.new([{ type: "text", text: "ok" }]) - end - - request = { - jsonrpc: "2.0", - id: "req-1", - method: Methods::TOOLS_CALL, - params: { name: "slow", arguments: {} }, - } - - in_flight = Thread.new { @session.handle(request) } - sleep(0.01) until @session.lookup_in_flight("req-1") - - duplicate = @session.handle(request) - - assert_equal( - JsonRpcHandler::ErrorCode::INVALID_REQUEST, - duplicate.dig(:error, :code) || duplicate.dig("error", "code"), - ) - in_flight.join - end - - test "a refused duplicate initialize leaves an in-flight registration under its reused id" do - @session.handle(jsonrpc: "2.0", id: "init", method: Methods::INITIALIZE, params: initialize_params) - - owner = @session.register_in_flight("req-1") - - # `initialize` bypasses the duplicate-id refusal (it is never in flight itself), so its - # rejection path is the one place a reused id reaches a handler while the id is still live. - duplicate = @session.handle(jsonrpc: "2.0", id: "req-1", method: Methods::INITIALIZE, params: initialize_params) - - assert_equal( - JsonRpcHandler::ErrorCode::INVALID_REQUEST, - duplicate.dig(:error, :code) || duplicate.dig("error", "code"), - ) - assert_same owner, @session.lookup_in_flight("req-1"), "the registration must survive the refused initialize" - end - test "parent cancellation propagates to nested server-to-client requests" do @session.instance_variable_set(:@client_capabilities, { elicitation: {} }) diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 10089c5..9125805 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -162,41 +162,6 @@ module MCP assert_equal({}, result) end - test "ServerContext forwards a per-request timeout to the notification target" do - notification_target = mock - notification_target.expects(:ping).with(related_request_id: nil, timeout: 12).returns({}) - notification_target.expects(:list_roots).with(related_request_id: nil, timeout: 34).returns({ roots: [] }) - notification_target.expects(:create_form_elicitation).with( - message: "Approve?", - requested_schema: { type: "object", properties: {} }, - related_request_id: nil, - timeout: 56, - ).returns({ action: "accept" }) - - progress = Progress.new(notification_target: notification_target, progress_token: nil) - server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target) - - server_context.ping(timeout: 12) - server_context.list_roots(timeout: 34) - server_context.create_form_elicitation( - message: "Approve?", - requested_schema: { type: "object", properties: {} }, - timeout: 56, - ) - end - - test "ServerContext omits timeout entirely when the caller does not ask for one" do - # A notification target predating the keyword (a custom object standing in for a session) keeps working, - # and the transport applies its own default. - notification_target = mock - notification_target.expects(:ping).with(related_request_id: nil).returns({}) - - progress = Progress.new(notification_target: notification_target, progress_token: nil) - server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target) - - server_context.ping - end - test "ServerContext#ping raises NoMethodError when notification_target does not respond" do notification_target = mock context = mock @@ -346,71 +311,6 @@ module MCP assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") } end - test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do - # Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send - # any `notifications/message` for the request. - notification_target = mock - notification_target.expects(:notify_log_message).never - server_context = build_modern_server_context(notification_target, log_level: nil) - - server_context.notify_log_message(data: "test", level: "error") - end - - test "ServerContext#notify_log_message drops messages below the modern request logLevel" do - notification_target = mock - notification_target.expects(:notify_log_message).never - server_context = build_modern_server_context(notification_target, log_level: "error") - - server_context.notify_log_message(data: "test", level: "info") - end - - test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do - notification_target = mock - notification_target.expects(:notify_log_message).with( - data: "test", level: "error", logger: nil, related_request_id: nil, - ).once - server_context = build_modern_server_context(notification_target, log_level: "warning") - - server_context.notify_log_message(data: "test", level: "error") - end - - test "ServerContext exposes envelope data and falls back to the session on legacy requests" do - notification_target = mock - notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" }) - notification_target.stubs(:client_capabilities).returns({ roots: {} }) - progress = Progress.new(notification_target: notification_target, progress_token: nil) - - legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target) - modern_context = build_modern_server_context(notification_target) - - refute_predicate legacy_context, :modern? - assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info) - assert_equal({ roots: {} }, legacy_context.client_capabilities) - assert_nil legacy_context.protocol_version - - assert_predicate modern_context, :modern? - assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info) - assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities) - assert_equal "2026-07-28", modern_context.protocol_version - end - - test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do - envelope = RequestEnvelope.new( - protocol_version: "2026-07-28", - client_info: { name: "c", version: "1" }, - client_capabilities: { "elicitation" => { "form" => {} } }, - ) - progress = Progress.new(notification_target: nil, progress_token: nil) - server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope) - - assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) } - assert_raises(ArgumentError) { server_context.require_client_capability! } - error = assert_raises(Server::MissingRequiredClientCapabilityError) do - server_context.require_client_capability!(:sampling) - end - assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data) - end - test "ServerContext#notify_resources_updated delegates to notification_target" do notification_target = mock notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once @@ -580,7 +480,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_equal "Internal error calling tool tool_with_required_context", response[:error][:data] + assert_match(/Internal error calling tool tool_with_required_context: /, response[:error][:data]) end test "call_tool_with_args correctly detects server_context parameter presence" do @@ -1100,18 +1000,5 @@ module MCP assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World", response[:result][:messages][0][:content][:text] end - - private - - def build_modern_server_context(notification_target, log_level: nil) - envelope = RequestEnvelope.new( - protocol_version: "2026-07-28", - client_info: { name: "modern_client", version: "2.0" }, - client_capabilities: { elicitation: { form: {} } }, - log_level: log_level, - ) - progress = Progress.new(notification_target: notification_target, progress_token: nil) - ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope) - end end end diff --git a/test/mcp/server_input_required_legacy_shim_test.rb b/test/mcp/server_input_required_legacy_shim_test.rb deleted file mode 100644 index ce0f4b4..0000000 --- a/test/mcp/server_input_required_legacy_shim_test.rb +++ /dev/null @@ -1,337 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - # The SEP-2322 dual-era authoring shim: a handler returning an `InputRequiredResult` - # on the legacy wire is fulfilled through real server-to-client requests and re-run, - # so handlers written in the 2026 style serve pre-2026 clients too. - class ServerInputRequiredLegacyShimTest < ActiveSupport::TestCase - # Answers every server-to-client request like a cooperative client and records what was sent. - class AnsweringTransport - attr_reader :sent - - def initialize(answers = {}) - @answers = answers - @sent = [] - end - - def send_request(method, params = nil, related_request_id: nil) - @sent << { method: method, params: params, related_request_id: related_request_id } - @answers.fetch(method) do - case method - when Methods::ELICITATION_CREATE - { action: "accept", content: { name: "Koichi" } } - when Methods::ROOTS_LIST - { roots: [{ uri: "file:///workspace", name: "workspace" }] } - when Methods::SAMPLING_CREATE_MESSAGE - { role: "assistant", content: { type: "text", text: "sampled" }, model: "test-model" } - end - end - end - - def send_notification(method, params = nil, session_id: nil, related_request_id: nil) - true - end - end - - # Raises like the bundled transports when the client answers a server-to-client request - # with a JSON-RPC error. - class RejectingTransport - def send_request(method, params = nil, related_request_id: nil) - raise StandardError, "Client returned an error for #{method} request (code: -1): User rejected" - end - - def send_notification(method, params = nil, session_id: nil, related_request_id: nil) - true - end - end - - setup do - @greeting_tool = Tool.define(name: "greeter") do |server_context:| - answer = server_context.input_response("who") - if answer - Tool::Response.new([{ type: "text", text: "Hello, #{answer.dig(:content, :name)}!" }]) - else - Server::InputRequiredResult.new( - input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, - request_state: "round-1", - ) - end - end - end - - test "a legacy tools/call is fulfilled through a real elicitation and completes" do - transport = AnsweringTransport.new - session = legacy_session(tools: [@greeting_tool], transport: transport) - - response = session.handle(tool_call_request) - - assert_equal "Hello, Koichi!", response.dig(:result, :content, 0, :text) - refute response[:result].key?(:resultType) - - sent = transport.sent.first - assert_equal Methods::ELICITATION_CREATE, sent[:method] - assert_equal({ message: "Who?" }, sent[:params]) - - # SEP-2260: the fulfilment request stays associated with the originating request. - assert_equal 1, sent[:related_request_id] - end - - test "the handler re-runs with the collected responses and the raw requestState" do - seen = [] - tool = Tool.define(name: "stateful") do |server_context:| - seen << { responses: server_context.input_responses, state: server_context.request_state } - if server_context.request_state == "opaque-state" - Tool::Response.new([{ type: "text", text: "resumed" }]) - else - Server::InputRequiredResult.new( - input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, - request_state: "opaque-state", - ) - end - end - session = legacy_session(tools: [tool], transport: AnsweringTransport.new) - - response = session.handle(tool_call_request(name: "stateful")) - - assert_equal "resumed", response.dig(:result, :content, 0, :text) - assert_equal 2, seen.size - assert_nil seen.first[:responses] - assert_equal "opaque-state", seen.last[:state] - assert_equal({ action: "accept", content: { name: "Koichi" } }, seen.last[:responses]["k"]) - end - - test "the shim bypasses requestState sealing" do - seen_state = nil - tool = Tool.define(name: "sealed") do |server_context:| - if server_context.request_state - seen_state = server_context.request_state - Tool::Response.new([{ type: "text", text: "done" }]) - else - Server::InputRequiredResult.new( - input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, - request_state: "plain-state", - ) - end - end - security = Server::RequestStateSecurity.new(key: "k" * 32) - session = legacy_session(tools: [tool], transport: AnsweringTransport.new, request_state_security: security) - - response = session.handle(tool_call_request(name: "sealed")) - - assert_equal "done", response.dig(:result, :content, 0, :text) - - # In-process replay round-trips the handler's own value; sealing is wire hardening. - assert_equal "plain-state", seen_state - end - - test "prompts/get and resources/read are shimmed too" do - prompt = Prompt.define(name: "greeting_prompt") do |_args, server_context:| - if server_context.input_response("who") - Prompt::Result.new(messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))]) - else - Server::InputRequiredResult.new( - input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, - ) - end - end - server = Server.new(name: "shim_test", prompts: [prompt], resources: []) - server.resources_read_handler do |params| - # `server_context` is unavailable in this handler shape, so park once via the params-visible retry fields instead. - if params[:inputResponses] - [{ uri: params[:uri], mimeType: "text/plain", text: "read" }] - else - Server::InputRequiredResult.new( - input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } }, - ) - end - end - transport = AnsweringTransport.new - session = ServerSession.new(server: server, transport: transport, session_id: "legacy") - session.store_client_info(client: { name: "legacy" }, capabilities: { elicitation: { form: {} } }) - - prompt_response = session.handle( - { jsonrpc: "2.0", id: 10, method: Methods::PROMPTS_GET, params: { name: "greeting_prompt" } }, - ) - assert_equal "hi", prompt_response.dig(:result, :messages, 0, :content, :text) - - read_response = session.handle( - { jsonrpc: "2.0", id: 11, method: Methods::RESOURCES_READ, params: { uri: "file:///a.txt" } }, - ) - assert_equal "read", read_response.dig(:result, :contents, 0, :text) - end - - test "multiple rounds collect each round's answers" do - rounds = [] - tool = Tool.define(name: "two_rounds") do |server_context:| - rounds << server_context.input_responses&.keys - if server_context.input_response("second") - Tool::Response.new([{ type: "text", text: "done" }]) - elsif server_context.input_response("first") - Server::InputRequiredResult.new( - input_requests: { "second" => { method: Methods::ELICITATION_CREATE, params: { message: "2?" } } }, - request_state: "after-first", - ) - else - Server::InputRequiredResult.new( - input_requests: { "first" => { method: Methods::ELICITATION_CREATE, params: { message: "1?" } } }, - ) - end - end - session = legacy_session(tools: [tool], transport: AnsweringTransport.new) - - response = session.handle(tool_call_request(name: "two_rounds")) - - assert_equal "done", response.dig(:result, :content, 0, :text) - assert_equal [nil, ["first"], ["second"]], rounds - end - - test "a url-mode elicitation leg gains the elicitationId the legacy wire requires" do - # The 2026-07-28 in-band shape has no `elicitationId` (correlation rides `requestState`), - # but the 2025-11-25 wire requires the field on URL-mode elicitation requests. - transport = AnsweringTransport.new - tool = Tool.define(name: "url_asker") do |server_context:| - if server_context.input_response("auth") - Tool::Response.new([{ type: "text", text: "authed" }]) - else - Server::InputRequiredResult.new( - input_requests: { - "auth" => { - method: Methods::ELICITATION_CREATE, - params: { mode: "url", url: "https://example.com/auth", message: "Sign in" }, - }, - }, - ) - end - end - session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } }) - - response = session.handle(tool_call_request(name: "url_asker")) - - assert_equal "authed", response.dig(:result, :content, 0, :text) - sent = transport.sent.first - assert_equal "https://example.com/auth", sent[:params][:url] - refute_nil sent[:params][:elicitationId] - end - - test "a handler-supplied elicitationId rides the leg unchanged" do - transport = AnsweringTransport.new - tool = Tool.define(name: "url_asker") do |server_context:| - if server_context.input_response("auth") - Tool::Response.new([{ type: "text", text: "authed" }]) - else - Server::InputRequiredResult.new( - input_requests: { - "auth" => { - method: Methods::ELICITATION_CREATE, - params: { mode: "url", url: "https://example.com/auth", elicitationId: "mine" }, - }, - }, - ) - end - end - session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } }) - - session.handle(tool_call_request(name: "url_asker")) - - assert_equal "mine", transport.sent.first[:params][:elicitationId] - end - - test "a client answering a fulfilment leg with an error fails the original request" do - session = legacy_session(tools: [@greeting_tool], transport: RejectingTransport.new) - - response = session.handle(tool_call_request) - - assert_equal(-32603, response.dig(:error, :code)) - end - - test "a handler that never completes fails after the round cap" do - tool = Tool.define(name: "greedy") do |server_context:| - round = (server_context.request_state || "0").to_i + 1 - Server::InputRequiredResult.new( - input_requests: { "k#{round}" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } }, - request_state: round.to_s, - ) - end - session = legacy_session(tools: [tool], transport: AnsweringTransport.new) - - response = session.handle(tool_call_request(name: "greedy")) - - assert_equal(-32603, response.dig(:error, :code)) - assert_match(/#{Server::LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds/, response.dig(:error, :message)) - end - - test "embedded requests exceeding the declared capabilities fail without contacting the client" do - transport = AnsweringTransport.new - session = legacy_session(tools: [@greeting_tool], transport: transport, capabilities: {}) - - response = session.handle(tool_call_request) - - assert_equal(-32603, response.dig(:error, :code)) - assert_match(/elicitation/, response.dig(:error, :message)) - assert_empty transport.sent - end - - test "opting out restores the strict legacy rejection" do - session = legacy_session( - tools: [@greeting_tool], - transport: AnsweringTransport.new, - input_required_legacy_shim: false, - ) - - response = session.handle(tool_call_request) - - assert_equal(-32603, response.dig(:error, :code)) - end - - test "a session-less legacy request is still rejected" do - server = Server.new(name: "shim_test", tools: [@greeting_tool]) - - response = server.handle(tool_call_request) - - assert_equal(-32603, response.dig(:error, :code)) - end - - test "modern requests keep the input_required result untouched" do - session = legacy_session(tools: [@greeting_tool], transport: transport = AnsweringTransport.new) - - response = session.handle({ - jsonrpc: "2.0", - id: 1, - method: Methods::TOOLS_CALL, - params: { - name: "greeter", - arguments: {}, - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { name: "modern", version: "1" }, - "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, - }, - }, - }) - - assert_equal "input_required", response.dig(:result, :resultType) - assert_empty transport.sent - end - - private - - def legacy_session(tools:, transport:, capabilities: { elicitation: { form: {} } }, - input_required_legacy_shim: true, request_state_security: nil) - server = Server.new( - name: "shim_test", - tools: tools, - input_required_legacy_shim: input_required_legacy_shim, - request_state_security: request_state_security, - ) - session = ServerSession.new(server: server, transport: transport, session_id: "legacy") - session.store_client_info(client: { name: "legacy" }, capabilities: capabilities) - session - end - - def tool_call_request(name: "greeter") - { jsonrpc: "2.0", id: 1, method: Methods::TOOLS_CALL, params: { name: name, arguments: {} } } - end - end -end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index ffeb3a8..74a89cc 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -5,7 +5,6 @@ require "test_helper" module MCP class ServerNotificationTest < ActiveSupport::TestCase include InstrumentationTestHelper - include DeprecationWarningTestHelper class MockTransport < Transport attr_reader :notifications @@ -75,104 +74,6 @@ module MCP assert_equal({ "data" => { error: "Connection Failed" }, "level" => "error" }, @mock_transport.notifications.first[:params]) end - test "#notify_log_message does not warn when no protocol version has been established" do - @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_no_deprecation_warning do - @server.notify_log_message(data: { error: "Connection Failed" }, level: "error") - end - - assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] - end - - test "#notify_log_message does not warn on any server configuration" do - # A modern pin is rejected at configuration time and the handshake never lands on - # a deprecating revision, so no server-side path can reach the SEP-2577 warnings; - # the notification itself still goes out (or is suppressed below the configured level). - server = Server.new(name: "test_server", version: "1.0.0") - mock_transport = MockTransport.new(server) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_no_deprecation_warning do - server.notify_log_message(data: { error: "Connection Failed" }, level: "error") - end - - assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] - - assert_no_deprecation_warning do - server.notify_log_message(data: { message: "test" }, level: "info") - end - - assert_equal 1, mock_transport.notifications.size - end - - test "#notify_log_message does not warn when configured protocol version is older" do - server = Server.new( - name: "test_server", - version: "1.0.0", - configuration: Configuration.new(protocol_version: "2025-11-25"), - ) - MockTransport.new(server) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") - - assert_no_deprecation_warning do - server.notify_log_message(data: { error: "Connection Failed" }, level: "error") - end - end - - test "ServerSession#notify_log_message does not warn after a modern initialize request is counter-offered" do - # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), where logging - # is not deprecated; the below-configured-level suppression still applies. - server = Server.new(name: "test_server", version: "1.0.0") - mock_transport = MockTransport.new(server) - session = ServerSession.new(server: server, transport: mock_transport) - server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2026-07-28", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }, - session: session, - ) - session.configure_logging(MCP::LoggingMessageNotification.new(level: "error")) - - assert_no_deprecation_warning do - session.notify_log_message(data: { message: "test" }, level: "info") - end - - assert_empty mock_transport.notifications - end - - test "ServerSession#notify_log_message on a modern-era session ignores the server-wide level" do - # SEP-2575: on the modern lifecycle, log delivery is authorized per request via - # the envelope's logLevel; the server-wide level MUST NOT apply. - server = Server.new(name: "test_server", version: "1.0.0") - mock_transport = MockTransport.new(server) - server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug") - session = ServerSession.new(server: server, transport: mock_transport, era: :modern) - - session.notify_log_message(data: { message: "test" }, level: "error") - - assert_empty mock_transport.notifications - end - - test "ServerSession#notify_log_message on a modern-era session honors a per-request level" do - server = Server.new(name: "test_server", version: "1.0.0") - mock_transport = MockTransport.new(server) - session = ServerSession.new(server: server, transport: mock_transport, era: :modern) - session.configure_logging(MCP::LoggingMessageNotification.new(level: "info")) - - session.notify_log_message(data: { message: "test" }, level: "error") - - assert_equal 1, mock_transport.notifications.length - assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method] - end - test "#notify_log_message sends notification with logger through transport" do @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") @server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger") diff --git a/test/mcp/server_roots_test.rb b/test/mcp/server_roots_test.rb index 39c4e8f..f8f31f9 100644 --- a/test/mcp/server_roots_test.rb +++ b/test/mcp/server_roots_test.rb @@ -4,8 +4,6 @@ require "test_helper" module MCP class ServerRootsTest < ActiveSupport::TestCase - include DeprecationWarningTestHelper - class MockTransport < Transport attr_reader :requests @@ -64,38 +62,6 @@ module MCP assert callback_called end - test "notifications/roots/list_changed does not warn after a modern initialize request is counter-offered" do - # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), - # where roots is not deprecated. - server = Server.new(name: "test", version: "1.0") - server.handle({ - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2026-07-28", - capabilities: { roots: { listChanged: true } }, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }) - - assert_no_deprecation_warning do - server.handle({ - jsonrpc: "2.0", - method: "notifications/roots/list_changed", - }) - end - end - - test "notifications/roots/list_changed does not warn when negotiated protocol version is older" do - assert_no_deprecation_warning do - @server.handle({ - jsonrpc: "2.0", - method: "notifications/roots/list_changed", - }) - end - end - test "notifications/roots/list_changed is handled as no-op by default" do result = @server.handle({ jsonrpc: "2.0", @@ -178,22 +144,6 @@ module MCP assert_silent { session.list_roots(related_request_id: "req-1") } end - test "ServerSession#list_roots raises without a warning when the client lacks roots" do - # A modern pin is rejected at configuration time and the handshake never lands on - # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. - session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) - session.store_client_info(client: { name: "test-client" }, capabilities: {}) - - error = nil - assert_no_deprecation_warning do - error = assert_raises(RuntimeError) do - session.list_roots(related_request_id: "req-1") - end - end - - assert_equal("Client does not support roots.", error.message) - end - test "ServerSession#client_capabilities falls back to server global capabilities" do transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server) @@ -269,33 +219,5 @@ module MCP end assert_equal("No active stream for roots/list request.", error.message) end - - test "ServerSession#list_roots does not warn after a modern initialize request is counter-offered" do - # `initialize` asking for 2026-07-28 lands on 2025-11-25 (SEP-2575 era model), - # where roots is not deprecated; the request itself still goes out. - server = Server.new(name: "test", version: "1.0") - transport = MockTransport.new(server) - - session = ServerSession.new(server: server, transport: transport, session_id: "s1") - server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2026-07-28", - capabilities: { roots: {} }, - clientInfo: { name: "http-client", version: "1.0" }, - }, - }, - session: session, - ) - - assert_no_deprecation_warning do - session.list_roots(related_request_id: "req-1") - end - - assert_equal Methods::ROOTS_LIST, transport.requests.first[:method] - end end end diff --git a/test/mcp/server_sampling_test.rb b/test/mcp/server_sampling_test.rb index c5d0d59..9c6985b 100644 --- a/test/mcp/server_sampling_test.rb +++ b/test/mcp/server_sampling_test.rb @@ -5,7 +5,6 @@ require "test_helper" module MCP class ServerSamplingTest < ActiveSupport::TestCase include InstrumentationTestHelper - include DeprecationWarningTestHelper class MockTransport < Transport attr_reader :requests @@ -64,33 +63,6 @@ module MCP assert_equal "Response from LLM", result[:content][:text] end - test "create_sampling_message does not warn on a modern-era session" do - # A modern pin is rejected at configuration time and the handshake never lands on - # a deprecating revision, so no server-side path can reach the SEP-2577 warnings. - session = ServerSession.new(server: @server, transport: @mock_transport, era: :modern) - session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: {} }) - - assert_no_deprecation_warning do - session.create_sampling_message( - related_request_id: "req-1", - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - end - - test "create_sampling_message does not warn when session negotiated protocol version is older" do - @session.mark_initialized!(protocol_version: "2025-11-25") - - assert_no_deprecation_warning do - @session.create_sampling_message( - related_request_id: "req-1", - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - end - end - test "create_sampling_message sends all optional params" do @session.create_sampling_message( related_request_id: "req-1", diff --git a/test/mcp/server_session_request_timeout_test.rb b/test/mcp/server_session_request_timeout_test.rb deleted file mode 100644 index f3ef1e6..0000000 --- a/test/mcp/server_session_request_timeout_test.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module MCP - # Every server-to-client request accepts a per-request deadline, which the spec asks SDKs to offer - # ("SDKs and other middleware SHOULD allow these timeouts to be configured on a per-request basis"). - # `ServerSession` only carries the value; the transport is what waits on it. - class ServerSessionRequestTimeoutTest < ActiveSupport::TestCase - # Declares `timeout:`, as the bundled Streamable HTTP transport does. - class TimeoutAwareTransport < Transport - attr_reader :requests - - def initialize(server) - super - @requests = [] - end - - def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil) - @requests << { method: method, timeout: timeout } - {} - end - - def send_response(response); end - def send_notification(method, params = nil, **_kwargs); end - def open; end - def close; end - def handle_request(request); end - end - - # Implements only the abstract `send_request(method, params = nil)` contract, as `StdioTransport` does. - class TimeoutUnawareTransport < TimeoutAwareTransport - def send_request(method, params = nil) - @requests << { method: method, timeout: :not_passed } - {} - end - end - - setup do - @server = Server.new(name: "test_server", version: "1.0.0") - @transport = TimeoutAwareTransport.new(@server) - @session = ServerSession.new(server: @server, transport: @transport, session_id: "session-1") - @session.instance_variable_set( - :@client_capabilities, - { roots: {}, sampling: {}, elicitation: { url: {} } }, - ) - end - - test "every server-to-client request carries its timeout down to the transport" do - @session.ping(related_request_id: "req-1", timeout: 11) - @session.list_roots(related_request_id: "req-1", timeout: 22) - @session.create_sampling_message( - related_request_id: "req-1", - timeout: 33, - messages: [{ role: "user", content: { type: "text", text: "Hello" } }], - max_tokens: 100, - ) - @session.create_form_elicitation( - message: "Approve?", - requested_schema: { type: "object", properties: {} }, - related_request_id: "req-1", - timeout: 44, - ) - @session.create_url_elicitation( - message: "Approve?", - url: "https://example.com/approve", - elicitation_id: "elicit-1", - related_request_id: "req-1", - timeout: 55, - ) - - assert_equal( - [ - { method: "ping", timeout: 11 }, - { method: "roots/list", timeout: 22 }, - { method: "sampling/createMessage", timeout: 33 }, - { method: "elicitation/create", timeout: 44 }, - { method: "elicitation/create", timeout: 55 }, - ], - @transport.requests, - ) - end - - test "an omitted timeout leaves the transport to apply its own default" do - @session.ping(related_request_id: "req-1") - - assert_equal [{ method: "ping", timeout: nil }], @transport.requests - end - - test "a transport that does not declare timeout keeps its existing signature" do - transport = TimeoutUnawareTransport.new(@server) - session = ServerSession.new(server: @server, transport: transport, session_id: "session-1") - - session.ping(related_request_id: "req-1", timeout: 11) - - assert_equal [{ method: "ping", timeout: :not_passed }], transport.requests - end - end -end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 80162e5..18240b0 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -6,7 +6,6 @@ module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper include InitializeParamsTestHelper - include DeprecationWarningTestHelper setup do @tool = Tool.define( name: "test_tool", @@ -129,48 +128,20 @@ module MCP response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) result = response[:result] - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions] - # Without a `subscriptions/listen`-serving transport, the `listChanged` flags are - # stripped from the advertised capabilities (see the dedicated tests below). - assert_equal @server.capabilities.keys, result[:capabilities].keys - # Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp, - # not a top-level `serverInfo` field. - assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name) - assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version) - refute result.key?(:serverInfo) + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, result[:supportedVersions] + assert_equal @server.capabilities, result[:capabilities] + assert_equal @server_name, result.dig(:serverInfo, :name) + assert_equal "1.2.3", result.dig(:serverInfo, :version) assert_equal "Optional instructions for the client", result[:instructions] end - test "#handle server/discover strips listChanged and subscribe flags without a listen-serving transport" do - server = Server.new(name: "discover_test", capabilities: { - tools: { listChanged: true }, - resources: { listChanged: true, subscribe: true }, - logging: {}, - }) - - result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] - - assert_equal({ tools: {}, resources: {}, logging: {} }, result[:capabilities]) - end - - test "#handle server/discover keeps listChanged flags when the transport serves subscriptions/listen" do - server = Server.new(name: "discover_test", capabilities: { tools: { listChanged: true } }) - transport = mock - transport.stubs(:serves_subscriptions_listen?).returns(true) - server.transport = transport - - result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] - - assert_equal({ tools: { listChanged: true } }, result[:capabilities]) - end - test "#handle server/discover responds before initialize and regardless of capabilities" do # Per SEP-2575, discovery is sessionless: no prior `initialize`, no capability gate. server = Server.new(name: "discover_test", capabilities: {}) response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions) + assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions) end test "#handle server/discover does not mark the session initialized" do @@ -195,520 +166,12 @@ module MCP refute response[:result].key?(:instructions) end - test "#handle server/discover always carries the required cache hints with spec defaults" do - # Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope` - # are REQUIRED on `DiscoverResult`. - server = Server.new(name: "discover_test") - - result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] - - assert_equal 0, result[:ttlMs] - assert_equal "private", result[:cacheScope] - end - - test "#handle server/discover reuses the configured SEP-2549 cache hints" do - server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public") - - result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] - - assert_equal 3_600_000, result[:ttlMs] - assert_equal "public", result[:cacheScope] - end - test "#define_custom_method raises for server/discover" do assert_raises(Server::MethodAlreadyDefinedError) do @server.define_custom_method(method_name: "server/discover") { {} } end end - test "UnsupportedProtocolVersionError surfaces as -32022 with the SEP-2575 data shape" do - server = Server.new(name: "error_test", tools: [TestTool]) - server.define_tool(name: "unsupported_version_tool") do - raise Server::UnsupportedProtocolVersionError, "1900-01-01" - end - - response = server.handle({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "unsupported_version_tool" }, - }) - - assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) - assert_equal "Unsupported protocol version", response.dig(:error, :message) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) - assert_equal "1900-01-01", response.dig(:error, :data, :requested) - end - - test "UnsupportedProtocolVersionError reports an unknown requested version" do - error = Server::UnsupportedProtocolVersionError.new(nil) - - assert_equal "unknown", error.error_data[:requested] - end - - test "UnsupportedProtocolVersionError accepts a symbol-keyed request Hash on every Ruby version" do - # On Ruby 2.7, a keyword parameter in `initialize` would make the trailing symbol-keyed Hash split - # into keywords and raise an ArgumentError. - request = { name: "echo", arguments: {}, _meta: {} } - - error = Server::UnsupportedProtocolVersionError.new("1900-01-01", request) - - assert_equal "1900-01-01", error.error_data[:requested] - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] - end - - test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do - server = Server.new(name: "error_test", tools: [TestTool]) - server.define_tool(name: "missing_capability_tool") do - raise Server::MissingRequiredClientCapabilityError, { elicitation: {} } - end - - response = server.handle({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "missing_capability_tool" }, - }) - - assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) - assert_equal "Missing required client capability", response.dig(:error, :message) - assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities)) - end - - test "#handle tools/call with a modern envelope exposes per-request client data without touching the session" do - server = Server.new(name: "modern_test", tools: []) - received = nil - server.define_tool(name: "modern_tool") do |server_context:| - received = { - modern: server_context.modern?, - client_info: server_context.client_info, - client_capabilities: server_context.client_capabilities, - protocol_version: server_context.protocol_version, - } - Tool::Response.new([{ type: "text", text: "ok" }]) - end - session = ServerSession.new(server: server, transport: mock) - - response = server.handle( - modern_request("tools/call", { name: "modern_tool", arguments: {} }, capabilities: { elicitation: {} }), - session: session, - ) - - refute_nil response[:result] - assert received[:modern] - assert_equal({ name: "modern_client", version: "2.0" }, received[:client_info]) - assert_equal({ elicitation: {} }, received[:client_capabilities]) - assert_equal "2026-07-28", received[:protocol_version] - # Per SEP-2575, servers MUST NOT infer client state from prior requests, so nothing is stored. - assert_nil session.client - refute_predicate session, :initialized? - end - - test "#handle tools/call with an unsupported envelope version returns -32022" do - server = Server.new(name: "modern_test", tools: []) - called = false - server.define_tool(name: "modern_tool") do - called = true - Tool::Response.new([{ type: "text", text: "ok" }]) - end - - response = server.handle(modern_request("tools/call", { name: "modern_tool" }, version: "2027-01-01")) - - refute called - assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) - assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) - assert_equal "2027-01-01", response.dig(:error, :data, :requested) - end - - test "#handle rejects a claimed but incomplete envelope with -32602 naming the missing key" do - # A request carrying the `protocolVersion` claim key must be validated, never silently served as legacy: - # the spec maps missing required envelope fields to Invalid params, and the TypeScript and Python classifiers - # answer the same way. - server = Server.new(name: "modern_test", tools: []) - server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } - - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { - name: "modern_tool", - arguments: {}, - _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, - }, - }) - - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) - assert_includes response.dig(:error, :message), "io.modelcontextprotocol/clientCapabilities" - end - - test "#handle serves an envelope without the optional clientInfo" do - # `clientInfo` became optional after the SEP was finalized (spec PR #3002). - server = Server.new(name: "modern_test", tools: []) - received_context = nil - server.define_tool(name: "modern_tool") do |server_context:| - received_context = server_context - Tool::Response.new([{ type: "text", text: "ok" }]) - end - - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { - name: "modern_tool", - arguments: {}, - _meta: { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, - }, - }, - }) - - refute_nil response[:result] - assert_predicate received_context, :modern? - assert_nil received_context.client_info - assert_equal({ elicitation: {} }, received_context.client_capabilities) - end - - test "#handle requires the envelope for requests on a modern-locked session" do - server = Server.new(name: "modern_test", tools: []) - server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } - session = ServerSession.new(server: server, transport: mock, era: :modern) - - response = server.handle( - { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool" } }, - session: session, - ) - - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) - assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion" - end - - test "#handle rejects removed lifecycle methods on a modern-locked session with -32601" do - session = ServerSession.new(server: @server, transport: mock, era: :modern) - - Methods::MODERN_REMOVED_METHODS.each_with_index do |method, index| - response = @server.handle( - { jsonrpc: "2.0", method: method, id: index + 1 }, - session: session, - ) - - assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" - assert_equal index + 1, response[:id], "expected the request id to be preserved for #{method}" - end - end - - test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do - # `StdioTransport` locks the era only after a response succeeds, so the first request of a connection - # arrives with `era == nil`. The envelope on the request is what identifies it as modern there. - Methods::MODERN_REMOVED_METHODS.each do |method| - session = ServerSession.new(server: @server, transport: mock) - - response = @server.handle(modern_request(method, {}), session: session) - - assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" - end - end - - test "#handle does not let an enveloped initialize lock the era before it is settled" do - # Serving it would negotiate the legacy lifecycle for a request that declared the modern one, - # pinning the connection to the wrong era for its whole lifetime. - session = ServerSession.new(server: @server, transport: mock) - - @server.handle(modern_request("initialize", initialize_params), session: session) - - assert_nil session.era - refute_predicate session, :initialized? - end - - test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do - session = ServerSession.new(server: @server, transport: mock) - - response = @server.handle(modern_request("tools/list", {}), session: session) - - refute_nil response[:result] - end - - test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do - # Per the SEP-2575 era model, the handshake never lands on a modern version: the client is - # counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected. - session = ServerSession.new(server: @server, transport: mock) - - response = @server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: initialize_params(protocolVersion: "2026-07-28"), - }, - session: session, - ) - - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response.dig(:result, :protocolVersion) - assert_equal :legacy, session.era - end - - test "#handle server/discover requires the envelope on a modern-locked session" do - # The 2026-07-28 conformance requirements reject an envelope-less `server/discover` - # on the modern era with -32602 (SEP-2575). - session = ServerSession.new(server: @server, transport: mock, era: :modern) - - response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) - - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) - end - - test "#handle server/discover succeeds without an envelope outside the modern era" do - response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) - - refute_nil response[:result] - end - - test "#handle tools/call enforces require_client_capability! from the envelope" do - server = Server.new(name: "modern_test", tools: []) - server.define_tool(name: "guarded_tool") do |server_context:| - server_context.require_client_capability!(:elicitation, :form) - Tool::Response.new([{ type: "text", text: "ok" }]) - end - - declared = server.handle( - modern_request("tools/call", { name: "guarded_tool" }, capabilities: { elicitation: { form: {} } }), - ) - undeclared = server.handle(modern_request("tools/call", { name: "guarded_tool" })) - - refute_nil declared[:result] - assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, undeclared.dig(:error, :code) - assert_equal( - { elicitation: { form: {} } }, - undeclared.dig(:error, :data, :requiredCapabilities), - ) - end - - test "#handle tools/call serializes an input_required result on a modern request" do - server = Server.new(name: "mrtr_test", tools: []) - server.define_tool(name: "mrtr_tool") do |server_context:| - if server_context.input_responses - Tool::Response.new([{ type: "text", text: "done" }]) - else - Server::InputRequiredResult.new( - input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, - request_state: "state-1", - ) - end - end - - response = server.handle( - modern_request("tools/call", { name: "mrtr_tool" }, capabilities: { elicitation: { form: {} } }), - ) - result = response[:result] - - assert_equal "input_required", result[:resultType] - assert_equal "state-1", result[:requestState] - assert_equal( - { method: "elicitation/create", params: { message: "Which region?" } }, - result.dig(:inputRequests, "region"), - ) - end - - test "#handle tools/call retry leg exposes input responses and request state to the handler" do - server = Server.new(name: "mrtr_test", tools: []) - seen = nil - server.define_tool(name: "mrtr_tool") do |server_context:| - seen = { - input_responses: server_context.input_responses, - request_state: server_context.request_state, - region: server_context.input_response(:region), - } - Tool::Response.new([{ type: "text", text: "done" }]) - end - - request = modern_request("tools/call", { - name: "mrtr_tool", - arguments: {}, - inputResponses: { region: { action: "accept", content: { value: "us-east-1" } } }, - requestState: "state-1", - }) - response = server.handle(request) - - refute_nil response[:result] - assert_equal "state-1", seen[:request_state] - assert_equal({ action: "accept", content: { value: "us-east-1" } }, seen[:region]) - assert_equal seen[:region], seen[:input_responses][:region] - end - - test "#handle rejects an input_required result on a legacy request with an internal error" do - # The result type exists only in the 2026-07-28 lifecycle: pre-2026 clients treat - # an unknown resultType as a final result, so it must never reach them. - server = Server.new(name: "mrtr_test", tools: []) - server.define_tool(name: "mrtr_tool") do - Server::InputRequiredResult.new(request_state: "state-1") - end - - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { name: "mrtr_tool" }, - }) - - assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response.dig(:error, :code) - end - - test "#handle rejects an input_required result whose embedded requests exceed declared capabilities" do - server = Server.new(name: "mrtr_test", tools: []) - server.define_tool(name: "mrtr_tool") do - Server::InputRequiredResult.new(input_requests: { - region: { method: "elicitation/create", params: { message: "?" } }, - roots: { method: "roots/list" }, - }) - end - - response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }, capabilities: {})) - - assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) - assert_equal( - { elicitation: { form: {} }, roots: {} }, - response.dig(:error, :data, :requiredCapabilities), - ) - end - - test "#handle tools/call input_required bypasses output schema validation" do - configuration = Configuration.new(validate_tool_call_results: true) - server = Server.new(name: "mrtr_test", tools: [], configuration: configuration) - server.define_tool(name: "mrtr_tool", output_schema: { properties: { value: { type: "string" } }, required: ["value"] }) do - Server::InputRequiredResult.new(request_state: "state-1") - end - - response = server.handle(modern_request("tools/call", { name: "mrtr_tool" })) - - assert_equal "input_required", response.dig(:result, :resultType) - end - - test "#handle seals and unseals requestState transparently when request_state_security is set" do - security = Server::RequestStateSecurity.new(key: "k" * 32) - server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) - seen_state = nil - server.define_tool(name: "mrtr_tool") do |server_context:| - if server_context.request_state - seen_state = server_context.request_state - Tool::Response.new([{ type: "text", text: "done" }]) - else - Server::InputRequiredResult.new(request_state: "plain-state") - end - end - - first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) - sealed = first.dig(:result, :requestState) - - assert sealed.start_with?("v1.") - refute_equal "plain-state", sealed - - retry_response = server.handle(modern_request("tools/call", { - name: "mrtr_tool", - arguments: {}, - requestState: sealed, - })) - - refute_nil retry_response[:result] - # The handler reads the plaintext it originally wrote. - assert_equal "plain-state", seen_state - end - - test "#handle rejects a tampered or cross-call requestState echo with -32602" do - security = Server::RequestStateSecurity.new(key: "k" * 32) - server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) - server.define_tool(name: "mrtr_tool") do |server_context:| - if server_context.request_state - Tool::Response.new([{ type: "text", text: "done" }]) - else - Server::InputRequiredResult.new(request_state: "plain-state") - end - end - - first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) - sealed = first.dig(:result, :requestState) - - tampered = server.handle(modern_request("tools/call", { - name: "mrtr_tool", - arguments: {}, - requestState: sealed.sub("v1.", "v1.x"), - })) - # Different arguments than the sealing call: the digest claim no longer matches. - cross_call = server.handle(modern_request("tools/call", { - name: "mrtr_tool", - arguments: { other: true }, - requestState: sealed, - })) - - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, tampered.dig(:error, :code) - assert_equal "Invalid or expired requestState", tampered.dig(:error, :message) - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, cross_call.dig(:error, :code) - end - - test "#handle passes requestState through untouched without request_state_security" do - server = Server.new(name: "mrtr_test", tools: []) - seen_state = nil - server.define_tool(name: "mrtr_tool") do |server_context:| - seen_state = server_context.request_state - Tool::Response.new([{ type: "text", text: "done" }]) - end - - server.handle(modern_request("tools/call", { name: "mrtr_tool", requestState: "raw-state" })) - - assert_equal "raw-state", seen_state - end - - test "#handle prompts/get serializes an input_required result on a modern request" do - server = Server.new(name: "mrtr_test") - server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:| - server_context.request_state # participates via server_context: - Server::InputRequiredResult.new(request_state: "prompt-state") - end - - response = server.handle(modern_request("prompts/get", { name: "mrtr_prompt", arguments: {} })) - - assert_equal "input_required", response.dig(:result, :resultType) - assert_equal "prompt-state", response.dig(:result, :requestState) - end - - test "#handle resources/read passes an input_required result through unwrapped and without cache hints" do - server = Server.new(name: "mrtr_test", ttl_ms: 5000, cache_scope: "public") - server.resources_read_handler do |_params, server_context:| - server_context.request_state - Server::InputRequiredResult.new(request_state: "resource-state") - end - - response = server.handle(modern_request("resources/read", { uri: "file:///pending.txt" })) - result = response[:result] - - assert_equal "input_required", result[:resultType] - assert_equal "resource-state", result[:requestState] - refute result.key?(:contents) - refute result.key?(:ttlMs) - refute result.key?(:cacheScope) - end - - test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do - session = ServerSession.new(server: @server, transport: mock) - - assert_nil session.era - session.mark_initialized! - assert_equal :legacy, session.era - - session.lock_era!(:legacy) - assert_equal :legacy, session.era - assert_raises(RuntimeError) { session.lock_era!(:modern) } - end - - test "ServerSession accepts a preset era and validates era values" do - session = ServerSession.new(server: @server, transport: mock, era: :modern) - - assert_equal :modern, session.era - assert_raises(ArgumentError) { ServerSession.new(server: @server, transport: mock, era: :future) } - assert_raises(ArgumentError) { session.lock_era!(:future) } - end - test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", @@ -724,7 +187,7 @@ module MCP jsonrpc: "2.0", id: 1, result: { - protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, + protocolVersion: Configuration::LATEST_STABLE_PROTOCOL_VERSION, capabilities: { prompts: { listChanged: true }, resources: { listChanged: true }, @@ -916,7 +379,7 @@ module MCP params: initialize_params(protocolVersion: "1999-01-01"), }) - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "instrumentation data does not include client key when no clientInfo provided" do @@ -1220,7 +683,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] + assert_match(/Internal error calling tool tool_that_raises: /, response[:error][:data]) assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end @@ -1277,7 +740,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] + assert_match(/Internal error calling tool tool_that_raises: /, response[:error][:data]) assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end @@ -1308,7 +771,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_equal "Internal error calling tool tool_with_faulty_schema", response[:error][:data] + assert_match(/Internal error calling tool tool_with_faulty_schema: Unexpected schema error/, response[:error][:data]) end test "#handle tools/call returns JSON-RPC error for unknown tool" do @@ -1395,9 +858,6 @@ module MCP } response = @server.handle(request) - # An unknown prompt is client input, so it maps to Invalid Params (-32602), not the - # default Internal Error (-32603); matches the tools/call and completion/complete siblings. - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal("Prompt not found unknown_prompt", response[:error][:data]) assert_instrumentation_data({ method: "prompts/get", error: :prompt_not_found }) end @@ -1414,9 +874,6 @@ module MCP } response = @server.handle(request) - # A missing required argument is client input, so it maps to Invalid Params (-32602), - # not the default Internal Error (-32603). - assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal "Missing required arguments: test_argument", response[:error][:data] assert_instrumentation_data({ method: "prompts/get", @@ -1437,54 +894,6 @@ module MCP assert_instrumentation_data({ method: "resources/list" }) end - test "#resources_list_handler replaces the served resource collection" do - other = Resource.new(uri: "https://other.invalid", name: "other", mime_type: "text/plain") - @server.resources_list_handler { |_params| [other] } - - response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) - - assert_equal({ resources: [other.to_h] }, response[:result]) - end - - test "#resources_list_handler receives server_context when it opts in" do - real = Resource.new(uri: "https://real.invalid", name: "real", mime_type: "text/plain") - demo = Resource.new(uri: "https://demo.invalid", name: "demo", mime_type: "text/plain") - @server.resources_list_handler do |_params, server_context:| - server_context[:authenticated] ? [real] : [demo] - end - - @server.server_context = { authenticated: false } - anon = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) - @server.server_context = { authenticated: true } - authed = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) - - assert_equal({ resources: [demo.to_h] }, anon[:result]) - assert_equal({ resources: [real.to_h] }, authed[:result]) - end - - test "#resources_list_handler paginates and stamps cache hints on the returned collection" do - first = Resource.new(uri: "https://first.invalid", name: "first", mime_type: "text/plain") - second = Resource.new(uri: "https://second.invalid", name: "second", mime_type: "text/plain") - server = Server.new(name: @server_name, resources: [], page_size: 1, ttl_ms: 60_000) - server.resources_list_handler { |_params| [first, second] } - - response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) - - assert_equal([first.to_h], response[:result][:resources]) - assert_equal("1", response[:result][:nextCursor]) - assert_equal(60_000, response[:result][:ttlMs]) - end - - test "#resources_list_handler serves a server with no constructor-provided resources" do - only = Resource.new(uri: "https://only.invalid", name: "only", mime_type: "text/plain") - server = Server.new(name: @server_name, resources: []) - server.resources_list_handler { |_params| [only] } - - response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) - - assert_equal({ resources: [only.to_h] }, response[:result]) - end - test "#handle resources/read returns an empty array of contents by default" do request = { jsonrpc: "2.0", @@ -1769,70 +1178,6 @@ module MCP refute response.key?(:error) end - test "#configure_logging_level does not warn after a modern initialize request is counter-offered" do - # `initialize` asking for 2026-07-28 lands on 2025-11-25, where logging is not deprecated, - # so no warning fires. On the modern lifecycle itself `logging/setLevel` is a removed method - # and never reaches this handler. - server = Server.new(tools: [TestTool]) - server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2026-07-28", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }, - ) - - response = nil - assert_no_deprecation_warning do - response = server.handle( - { - jsonrpc: "2.0", - id: 2, - method: "logging/setLevel", - params: { - level: "info", - }, - }, - ) - end - - assert_empty response[:result] - end - - test "#configure_logging_level does not warn when negotiated protocol version is older" do - server = Server.new(tools: [TestTool]) - server.handle( - { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0" }, - }, - }, - ) - - assert_no_deprecation_warning do - server.handle( - { - jsonrpc: "2.0", - id: 2, - method: "logging/setLevel", - params: { - level: "info", - }, - }, - ) - end - end - test "#configure_logging_level returns an error object when invalid log level is provided" do server = Server.new( tools: [TestTool], @@ -2091,7 +1436,7 @@ module MCP assert_equal(["tool failure", "around ensure boom"], reported) assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) - assert_nil(response[:error][:data]) + assert_equal("around ensure boom", response[:error][:data]) end test "#handle reports the same exception object reused across requests on every call" do @@ -2143,7 +1488,7 @@ module MCP response = server.handle(request) assert_equal([frozen_error], reported) - assert_equal("Internal error calling tool frozen_tool", response[:error][:data]) + assert_includes(response[:error][:data], "frozen failure") end test "#handle still reports via exception_reporter when around_request swallows the tool failure" do @@ -2235,7 +1580,7 @@ module MCP assert_equal local_exception_reporter, server.configuration.exception_reporter end - test "server uses the latest handshake version when not configured" do + test "server uses default protocol version when not configured" do request = { jsonrpc: "2.0", method: "initialize", @@ -2244,7 +1589,7 @@ module MCP } response = @server.handle(request) - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "server response does not include optional parameters when configured" do @@ -2634,7 +1979,7 @@ module MCP assert_equal "2025-06-18", response[:result][:protocolVersion] end - test "server falls back to the latest handshake version when client requests unsupported version" do + test "server falls back to default version when client requests unsupported version" do server = Server.new(name: "test_server") request = { @@ -2645,24 +1990,7 @@ module MCP } response = server.handle(request) - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] - end - - test "server counter-offers 2025-11-25 when the client requests 2026-07-28 via initialize" do - # Per the SEP-2575 era model, `initialize` negotiates legacy versions only: a modern version carries - # its own version on every request and has no handshake at all. The TypeScript and Python servers - # answer the same way. - server = Server.new(name: "test_server") - - request = { - jsonrpc: "2.0", - method: "initialize", - id: 1, - params: initialize_params(protocolVersion: "2026-07-28"), - } - - response = server.handle(request) - assert_equal "2025-11-25", response[:result][:protocolVersion] + assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "server removes description and icons from server_info when negotiating to 2025-06-18" do @@ -3002,7 +2330,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_equal "Internal error calling tool invalid_structured_content_tool", response[:error][:data] + assert_match(/Internal error calling tool invalid_structured_content_tool: Invalid result:/, response[:error][:data]) end test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do @@ -3031,7 +2359,7 @@ module MCP assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] - assert_equal "Internal error calling tool missing_structured_content_tool", response[:error][:data] + assert_match(/Internal error calling tool missing_structured_content_tool: Invalid result:/, response[:error][:data]) end test "tools/call skips output schema validation for error responses" do @@ -3729,80 +3057,6 @@ module MCP assert_equal "Invalid params", response[:error][:message] end - # Builds an initialized server that advertises the `resources.subscribe` capability. - def subscription_server - server = Server.new(name: "test_server", capabilities: { resources: { subscribe: true } }) - server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) - server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) - server - end - - # Sends `method` (`resources/subscribe` or `resources/unsubscribe`) and returns the JSON-RPC result. - def handle_subscription(server, method) - server.handle({ - jsonrpc: "2.0", - id: 2, - method: method, - params: { uri: "https://example.com/resource" }, - })[:result] - end - - test "#handle resources/subscribe passes a handler-returned _meta through to the result" do - server = subscription_server - server.resources_subscribe_handler { |_params| { _meta: { "acme.example/subscriptionId" => "sub-1" } } } - - result = handle_subscription(server, "resources/subscribe") - - assert_equal({ _meta: { "acme.example/subscriptionId" => "sub-1" } }, result) - end - - test "#handle resources/unsubscribe passes a handler-returned _meta through to the result" do - server = subscription_server - server.resources_unsubscribe_handler { |_params| { _meta: { "acme.example/note" => "gone" } } } - - result = handle_subscription(server, "resources/unsubscribe") - - assert_equal({ _meta: { "acme.example/note" => "gone" } }, result) - end - - test "#handle resources/subscribe drops a handler-returned field that is not _meta" do - # The spec's result defines no member other than `_meta`, so a top-level field the handler adds is not - # a subscription protocol; it stays out of the response. - server = subscription_server - server.resources_subscribe_handler { |_params| { subscriptionId: "sub-1" } } - - result = handle_subscription(server, "resources/subscribe") - - assert_equal({}, result) - end - - test "#handle resources/subscribe accepts a string _meta key from the handler" do - server = subscription_server - server.resources_subscribe_handler { |_params| { "_meta" => { "k" => "v" } } } - - result = handle_subscription(server, "resources/subscribe") - - assert_equal({ _meta: { "k" => "v" } }, result) - end - - test "#handle resources/subscribe ignores a handler-returned _meta that is not a hash" do - server = subscription_server - server.resources_subscribe_handler { |_params| { _meta: "not-a-hash" } } - - result = handle_subscription(server, "resources/subscribe") - - assert_equal({}, result) - end - - test "#handle resources/subscribe keeps an empty result when the handler returns a non-hash" do - server = subscription_server - server.resources_subscribe_handler { |_params| nil } - - result = handle_subscription(server, "resources/subscribe") - - assert_equal({}, result) - end - test "#handle resources/subscribe without uri does not invoke a custom handler" do server = Server.new( name: "test_server", @@ -3902,16 +3156,6 @@ module MCP end end - class JsonObjectValue - def initialize(value) - @value = value - end - - def to_json(*args) - @value.to_json(*args) - end - end - test "server_context_with_meta uses accessor method, not ivar directly" do subclass = Class.new(Server) do def server_context @@ -4003,28 +3247,23 @@ module MCP end end - test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do + test "#handle tools/call mirrors non-object structuredContent into serialized text content" do # Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`, # so the server adds a serialized fallback when the tool provided no content blocks. server = Server.new(name: "structured_test", tools: []) - server.define_tool(name: "nil_content_tool") do + server.define_tool(name: "array_tool") do Tool::Response.new(nil, structured_content: [1, 2]) end - server.define_tool(name: "omitted_content_tool") do - Tool::Response.new(structured_content: [1, 2]) - end - ["nil_content_tool", "omitted_content_tool"].each_with_index do |tool_name, index| - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: index + 1, - params: { name: tool_name, arguments: {} }, - }) + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) - assert_equal [1, 2], response.dig(:result, :structuredContent) - assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) - end + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) end test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do @@ -4060,71 +3299,16 @@ module MCP assert_empty response.dig(:result, :content) end - test "#handle tools/call recognizes object-like structuredContent by its JSON shape" do - structured_content = JsonObjectValue.new(answer: 42) - server = Server.new(name: "structured_test", tools: []) - server.define_tool(name: "object_tool") do - Tool::Response.new(nil, structured_content: structured_content) - end - - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { name: "object_tool", arguments: {} }, - }) - - assert_equal structured_content, response.dig(:result, :structuredContent) - assert_empty response.dig(:result, :content) - end - - test "#handle tools/call preserves explicit empty content for non-object structuredContent" do - server = Server.new(name: "structured_test", tools: []) - server.define_tool(name: "array_tool") do - Tool::Response.new([], structured_content: [1, 2]) - end - - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { name: "array_tool", arguments: {} }, - }) - - assert_equal [1, 2], response.dig(:result, :structuredContent) - assert_empty response.dig(:result, :content) - end - - test "#handle tools/call preserves explicit empty content for object-like structuredContent" do - structured_content = JsonObjectValue.new(answer: 42) - - server = Server.new(name: "structured_test", tools: []) - server.define_tool(name: "object_tool") do - Tool::Response.new([], structured_content: structured_content) - end - - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { name: "object_tool", arguments: {} }, - }) - - assert_equal structured_content, response.dig(:result, :structuredContent) - assert_empty response.dig(:result, :content) - end - test "list results carry ttlMs and cacheScope when ttl_ms is configured" do - # SEP-2549 cache hints. The unset cacheScope fills as "private", the side that cannot - # leak a user-dependent result through a shared cache, matching the TypeScript and - # Python SDK defaults (the spec names no default scope). + # SEP-2549 cache hints. The cacheScope default is "public", matching + # the spec default and the Python SDK. server = Server.new(name: "ttl_test", ttl_ms: 5000) ["tools/list", "prompts/list", "resources/list", "resources/templates/list"].each_with_index do |method, index| result = server.handle({ jsonrpc: "2.0", method: method, id: index + 1 })[:result] assert_equal 5000, result[:ttlMs], "#{method} missing ttlMs" - assert_equal "private", result[:cacheScope], "#{method} missing cacheScope" + assert_equal "public", result[:cacheScope], "#{method} missing cacheScope" end end @@ -4185,7 +3369,7 @@ module MCP })[:result] assert_equal 60_000, result[:ttlMs] - assert_equal "private", result[:cacheScope] + assert_equal "public", result[:cacheScope] assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents] end @@ -4430,137 +3614,5 @@ module MCP refute Apps.client_supports?(server.client_capabilities) end - - # SEP-2322 makes `resultType` REQUIRED on every result of a 2026-07-28 server; - # `"complete"` is the standard shape, and legacy results stay unstamped. - test "modern results carry resultType complete" do - server = Server.new(name: "result_type_test", tools: [result_type_tool]) - - # Every method here has to be one the modern lifecycle still has. `ping` does not qualify: - # SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result. - [ - modern_request(Methods::TOOLS_LIST, {}), - modern_request(Methods::PROMPTS_LIST, {}), - modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }), - ].each do |request| - response = server.handle(request) - assert_equal "complete", response.dig(:result, :resultType), "expected stamp for #{request[:method]}" - end - end - - test "legacy results carry no resultType" do - server = Server.new(name: "result_type_test", tools: [result_type_tool]) - - [ - { jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }, - { jsonrpc: "2.0", method: Methods::PING, id: 2 }, - { jsonrpc: "2.0", method: Methods::TOOLS_CALL, id: 3, params: { name: "result_type_tool", arguments: {} } }, - ].each do |request| - response = server.handle(request) - refute response[:result].key?(:resultType), "expected no stamp for #{request[:method]}" - end - end - - test "results after a counter-offered modern initialize carry no resultType" do - # The scenario from issue #512: a client asking `initialize` for 2026-07-28 lands on - # 2025-11-25, where `resultType` does not exist, so its absence is the correct shape - # (clients MUST read an absent `resultType` as "complete" on that revision). - server = Server.new(name: "result_type_test", tools: [result_type_tool]) - session = ServerSession.new(server: server, transport: mock) - - init_response = server.handle( - { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28") }, - session: session, - ) - response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 2 }, session: session) - - assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, init_response.dig(:result, :protocolVersion) - refute response[:result].key?(:resultType) - end - - test "server/discover carries resultType complete" do - server = Server.new(name: "result_type_test", tools: [result_type_tool]) - - response = server.handle({ jsonrpc: "2.0", method: Methods::SERVER_DISCOVER, id: 1 }) - - assert_equal "complete", response.dig(:result, :resultType) - end - - # SEP-2549 makes the `ttlMs`/`cacheScope` cache hints REQUIRED on cacheable results at 2026-07-28; - # unset hints get the spec defaults, and stable protocol versions keep the opt-in emission. - test "modern cacheable results carry the default cache hints" do - server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) - - response = server.handle(modern_request(Methods::TOOLS_LIST, {})) - - assert_equal 0, response.dig(:result, :ttlMs) - assert_equal "private", response.dig(:result, :cacheScope) - end - - test "modern non-cacheable results carry no cache hints" do - server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) - - # `tools/call` is the non-cacheable example here: the spec types `CallToolResult` as a plain `Result`, - # while the results it types as `CacheableResult` are the discover, list, and read families. - # `ping` cannot stand in for it - SEP-2575 removed that method, so a modern request naming it is refused. - response = server.handle(modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} })) - - refute response[:result].key?(:ttlMs) - refute response[:result].key?(:cacheScope) - end - - test "configured cache hints win over the modern defaults" do - server = Server.new(name: "cache_hints_test", tools: [result_type_tool], ttl_ms: 5000, cache_scope: "private") - - response = server.handle(modern_request(Methods::TOOLS_LIST, {})) - - assert_equal 5000, response.dig(:result, :ttlMs) - assert_equal "private", response.dig(:result, :cacheScope) - end - - test "modern resources/read results carry the default cache hints" do - server = Server.new(name: "cache_hints_test") - server.resources_read_handler do |params| - [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }] - end - - response = server.handle(modern_request(Methods::RESOURCES_READ, { uri: "file:///x" })) - - assert_equal 0, response.dig(:result, :ttlMs) - assert_equal "private", response.dig(:result, :cacheScope) - end - - test "legacy cacheable results keep the opt-in cache hint emission" do - server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) - - response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }) - - refute response[:result].key?(:ttlMs) - refute response[:result].key?(:cacheScope) - end - - private - - # Builds a request carrying the SEP-2575 modern `_meta` envelope. - def modern_request(method, params, version: "2026-07-28", capabilities: {}) - { - jsonrpc: "2.0", - method: method, - id: 1, - params: params.merge( - _meta: { - "io.modelcontextprotocol/protocolVersion": version, - "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, - "io.modelcontextprotocol/clientCapabilities": capabilities, - }, - ), - } - end - - def result_type_tool - @result_type_tool ||= Tool.define(name: "result_type_tool") do |**| - Tool::Response.new([{ type: "text", text: "ok" }]) - end - end end end diff --git a/test/mcp/tool/response_test.rb b/test/mcp/tool/response_test.rb index afa7d3b..122dbb0 100644 --- a/test/mcp/tool/response_test.rb +++ b/test/mcp/tool/response_test.rb @@ -16,12 +16,6 @@ module MCP refute response.error? end - test "#content_provided? distinguishes empty content from omitted or nil content" do - assert Response.new([]).content_provided? - refute Response.new.content_provided? - refute Response.new(nil).content_provided? - end - test "#initialize with content and error set to true" do content = [{ type: "text", diff --git a/test/test_helper.rb b/test/test_helper.rb index 8cd6b9e..4de7ffd 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,21 +23,3 @@ require "sorbet-runtime" if RUBY_VERSION >= "3.0" require_relative "instrumentation_test_helper" require_relative "initialize_params_test_helper" - -module DeprecationWarningTestHelper - def assert_deprecation_warning(message_pattern, &block) - original_verbose = $VERBOSE - $VERBOSE = false - assert_output(nil, message_pattern, &block) - ensure - $VERBOSE = original_verbose - end - - def assert_no_deprecation_warning(&block) - original_verbose = $VERBOSE - $VERBOSE = false - assert_output(nil, "", &block) - ensure - $VERBOSE = original_verbose - end -end