Compare commits

..
205 changed files with 4794 additions and 41273 deletions

View file

@ -9,7 +9,7 @@ jobs:
strategy:
matrix:
entry:
- { ruby: '2.7.0', allowed-failure: false } # Minimum supported Ruby version.
- { ruby: '2.7', allowed-failure: false }
- { ruby: '3.0', allowed-failure: false }
- { ruby: '3.1', allowed-failure: false }
- { ruby: '3.2', allowed-failure: false }
@ -19,7 +19,7 @@ jobs:
- { ruby: 'head', allowed-failure: true }
name: Test Ruby ${{ matrix.entry.ruby }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.entry.ruby }}
@ -33,7 +33,7 @@ jobs:
contents: read
name: RuboCop
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0 # Specify the latest supported Ruby version.
@ -46,7 +46,7 @@ jobs:
contents: read
name: YARD Documentation
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0 # Specify the latest supported Ruby version.

View file

@ -7,7 +7,7 @@ on:
workflow_dispatch:
concurrency:
group: conformance-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || '' }}
group: conformance-${{ github.ref }}
cancel-in-progress: true
permissions:
@ -18,12 +18,12 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0' # Specify the latest supported Ruby version.
bundler-cache: true
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '24' # Specify the latest Node.js version.
- run: bundle exec rake conformance:test
- run: bundle exec rake conformance

View file

@ -16,7 +16,7 @@ jobs:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
@ -34,7 +34,7 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history for all branches and tags

4
.gitignore vendored
View file

@ -9,7 +9,3 @@
/spec/reports/
/tmp/
Gemfile.lock
docs/_site/
docs/.jekyll-cache/
docs/.jekyll-metadata
docs/_data/versions.yml

View file

@ -7,7 +7,6 @@ plugins:
AllCops:
TargetRubyVersion: 2.7
SuggestExtensions: false
Gemspec/DevelopmentDependencies:
Enabled: true
@ -17,7 +16,3 @@ Lint/IncompatibleIoSelectWithFiberScheduler:
Minitest/LiteralAsActualArgument:
Enabled: true
Naming/MethodName:
AllowedPatterns:
- '\Atest_'

View file

@ -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
@ -34,15 +32,6 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
- Use frozen string literals
- Follow Ruby community conventions
- 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

View file

@ -7,316 +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,
apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md).
### Added
- Add `ROADMAP.md` outlining the path toward SEP-1730 (#465)
- Add `VERSIONING.md` documenting the versioning and breaking-change policy (#466)
## [0.25.0] - 2026-07-18
### Added
- Support SEP-990 Cross-App Access via ID-JAG and the `jwt-bearer` grant (#454)
- Support client-side sampling via `MCP::Client#on_sampling` (#458)
### Fixed
- Reject notifications carrying an unknown or expired session (#455)
- Handle an `initialize` request sent without an ID (#456)
- Bound client-side message buffering in the HTTP transport (#459)
- Lowercase response header names in `StreamableHTTPTransport` (#460)
## [0.24.0] - 2026-07-12
### Added
- Support SSE reconnection per SEP-1699 in the HTTP client transport (#426)
- Validate the RFC 9207 `iss` authorization response parameter per SEP-2468 (#431)
- Support `private_key_jwt` client authentication for the `client_credentials` grant (#432)
- Recognize multi round-trip `input_required` results per SEP-2322 (#433)
- Add opt-in `ttlMs` / `cacheScope` cache hints to List and Read results per SEP-2549 (#436)
- Add `server/discover` and stateless lifecycle error codes per SEP-2575 (#438)
- Associate server-to-client requests with the originating client request per SEP-2260 (#440)
- Add server-side MCP Apps helpers per SEP-1865 (#441)
- Support client-side elicitation with SEP-1034 schema defaults (#443)
- Support class-based Resource and ResourceTemplate definitions (#447)
### Changed
- Bind stored client credentials to the authorization server issuer per SEP-2352 (#439)
### Fixed
- Handle non-object JSON-RPC messages without raising (#448)
- Perform SSE stream writes outside the session mutex (#449)
- Validate required params for `initialize` and `resources/subscribe` (#451)
## [0.23.0] - 2026-07-07
### Added
- Add a session-ownership hook to `StreamableHTTPTransport`
### Changed
- Require calling `MCP::Client#connect` before sending requests on stdio transport (#427)
### Deprecated
- Annotate Roots, Sampling, and Logging APIs as deprecated per SEP-2577 (#429)
### Fixed
- Fix an incorrect `result: null` response to an id-bearing notification message (#435)
- Validate `Host` and `Origin` headers to prevent DNS rebinding per MCP 2025-11-25
- Bound stateful session retention to prevent an initialize-flood DoS
- Bound stdio frame reads with `max_line_bytes`
- Bound request body and frame reads to prevent memory-exhaustion DoS
## [0.22.0] - 2026-06-27
### Added
- Add `audience` role validation for `MCP::Annotations` per MCP specification (#422)
- Send SEP-2243 `Mcp-Method` and `Mcp-Name` headers per MCP specification (#423)
- Support client-side `notifications/cancelled` per MCP specification (#425)
### Changed
- Conform Tool Schemas to JSON Schema 2020-12 per SEP-2106 (#417)
### Fixed
- Fix a `SyntaxError` on Ruby 2.7.0 caused by arguments forwarding syntax (#419)
## [0.21.0] - 2026-06-20
### Added
- Support capability extensions per SEP-2133 (#405)
- Add range validation for `MCP::Annotations#priority` per MCP specification (#410)
- Isolate stateless requests in ephemeral sessions per SEP-2567 (#415)
### Changed
- Set OIDC `application_type` on Dynamic Client Registration per SEP-837 (#408)
- Fall back to legacy 2025-03-26 OAuth discovery for servers without PRM (#414)
## [0.20.0] - 2026-06-14
### Added
- Support W3C Trace Context Propagation via `_meta` per SEP-414 (#397)
- Support OAuth `client_credentials` grant in OAuth client (#399)
- Add `annotations` field to `MCP::Resource` and `MCP::ResourceTemplate` per MCP specification (#403)
### Changed
- Re-run OAuth flow on 403 `insufficient_scope` (step-up) (#368)
- Speed up `Tool::Schema` validation by 5x to 100x (#369)
- Use JSON-RPC error envelope for `StreamableHTTPTransport` errors (#371)
- Pin RFC 8414 default well-known suffix per SEP-2351 (#395)
- Default missing `MCP-Protocol-Version` to `2025-03-26` in `StreamableHTTPTransport` (#392)
### Fixed
- Preserve the request ID in invalid request error responses (#400)
- Standardize Resource Not Found errors on -32602 with URI data per SEP-2164 (#402)
## [0.19.0] - 2026-06-13
### Added
- Support Client ID Metadata Documents in OAuth client (#361)
- Request `offline_access` scope when supported (#365)
- Add `size` field to `MCP::Resource` per MCP specification (#393)
## [0.18.0] - 2026-05-30
### Added
- Support server-to-client `ping` per MCP specification (#358)
### Changed
- Warn on implicit stdio initialization (#338)
- Cache `Tool::Schema` validation to avoid re-validating identical schemas (#363)
### Fixed
- Fix case-sensitive `Accept` header comparison (#359)
## [0.17.0] - 2026-05-19
### Added
- Add OAuth 2.1 client support for MCP authorization flow (#353)
### Fixed
- Validate `MCP-Protocol-Version` header in `StreamableHTTPTransport` (#347)
- Reject duplicate `initialize` requests (#350)
- Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport` (#354)
## [0.16.0] - 2026-05-14
### Added
- Add opt-in tool output validation (#344)
### Changed
- Advertise JSON Schema 2020-12 dialect on emitted tool schemas (#342)
### Fixed
- Preserve client tool output schemas (#343)
- Fix missing `output_schema` argument in `define_tool` API (#301)
## [0.15.0] - 2026-05-05
### Added
- Support `notifications/cancelled` per MCP specification (#332)
- Add client-level `connect` for initialize handshake (#327)
- Add client-level `connect` handshake to stdio transport (#336)
### Changed
- Return tool argument validation failures as tool execution errors (#333)
### Removed
- Remove obsolete `MCP::Transports` module (#331)
## [0.14.0] - 2026-04-24
### Added

View file

@ -6,7 +6,7 @@ source "https://rubygems.org"
gemspec
# Specify development dependencies below
gem "rubocop-minitest", ">= 0.39.0", require: false
gem "rubocop-minitest", require: false
gem "rubocop-rake", require: false
gem "rubocop-shopify", ">= 2.18", require: false if RUBY_VERSION >= "3.1"
@ -17,8 +17,6 @@ gem "rackup", ">= 2.1.0"
gem "activesupport"
# Fix io-console install error when Ruby 3.0.
gem "debug" if RUBY_VERSION >= "3.1"
# Avoid i18n 1.15.0, which breaks on Ruby 3.1 (ruby-i18n/i18n#735).
gem "i18n", "!= 1.15.0"
gem "rake", "~> 13.0"
gem "sorbet-static-and-runtime" if RUBY_VERSION >= "3.0"
gem "yard", "~> 0.9"

1698
README.md

File diff suppressed because it is too large Load diff

View file

@ -1,51 +0,0 @@
# Roadmap
This roadmap outlines the MCP Ruby SDK's path toward SEP-1730 Tier 1.
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.
## API Stability
The 1.0.0 release marked the public API as stable: breaking changes 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.
## Conformance
The SDK maintains a 100% conformance pass rate as new scenarios are added to the conformance suite.
Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provides modern Streamable HTTP only.
## Documentation
Reference documentation covers all non-experimental features with examples.
## 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)).

View file

@ -14,85 +14,38 @@ 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/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::Runner.new(**options).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")
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)

View file

@ -1,56 +0,0 @@
# Versioning Policy
The MCP Ruby SDK follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
This document describes what each version component means for users of the `mcp` gem,
what counts as a breaking change, and how breaking changes are communicated.
## Version Scheme
Given a version number `MAJOR.MINOR.PATCH`:
- **MAJOR** is incremented for incompatible changes to the public API.
- **MINOR** is incremented for backwards-compatible new functionality.
- **PATCH** is incremented for backwards-compatible bug fixes.
### Current 0.x Phase
While the SDK is at version 0.x, the public API is not yet considered stable.
Following SemVer conventions for initial development, minor releases (0.x -> 0.y) may contain breaking changes.
All breaking changes are recorded in [CHANGELOG.md](CHANGELOG.md).
### After 1.0.0
Once 1.0.0 is released, breaking changes will only ship in major releases, with the exceptions described below.
## What Counts as a Breaking Change
A breaking change is any change that requires users to modify their code when upgrading, including:
- Removing or renaming a public class, module, method, or constant
- Changing a public method's signature in a way that rejects previously valid arguments
- Changing documented behavior that users can reasonably depend on
New functionality that is purely additive (new classes, new methods, new optional keyword arguments)
is not a breaking change.
## Exceptions: Spec Compliance, Security, and Clear Defects
Minor releases avoid incompatible changes as much as possible. However, the SDK's primary contract is
conformance to the [MCP specification](https://modelcontextprotocol.io/specification/).
Behavior that deviates from the specification is treated as a bug, even when users may have come to rely on it.
Therefore, incompatible changes may be shipped in a minor release when they are required to:
- Fix incorrect conformance to the MCP specification
- Address a security vulnerability
- Fix a clear defect, such as behavior that contradicts the documentation, a crash,
or data corruption, where compatibility would mean preserving the defect
When such a change ships, the release notes and CHANGELOG entry explicitly call out the incompatibility
and describe how to migrate.
## How Breaking Changes Are Communicated
- [CHANGELOG.md](CHANGELOG.md) records every release in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format;
breaking changes appear under the "Changed" or "Removed" headings
- [GitHub Releases](https://github.com/modelcontextprotocol/ruby-sdk/releases) mirror the changelog for each release
- Where practical, deprecation warnings are added at least one minor release before removal

View file

@ -12,45 +12,45 @@ 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
conformance server and tests it. Client conformance spawns test servers for each scenario
and invokes `conformance/client.rb` against them. Scenarios listed in `expected_failures.yml`
are allowed to fail without affecting the exit code.
Starts the conformance server, runs all active scenarios against it, prints a pass/fail
summary for each scenario, and exits with a non-zero status code if any unexpected failures
are detected. Scenarios listed in `expected_failures.yml` are allowed to fail without
affecting the exit code.
### Environment variables
| Variable | Description | Default |
|----------------|---------------------------------------|---------|
| `PORT` | Server port (server conformance only) | `9292` |
| `SCENARIO` | Run a single scenario by name | (all) |
| `SPEC_VERSION` | Filter scenarios by spec version | (all) |
| `VERBOSE` | Show raw JSON output when set | (off) |
| Variable | Description | Default |
|----------------|--------------------------------------|---------|
| `PORT` | Server port | `9292` |
| `SCENARIO` | Run a single scenario by name | (all) |
| `SPEC_VERSION` | Filter scenarios by spec version | (all) |
| `VERBOSE` | Show raw JSON output when set | (off) |
```bash
# Run a single server scenario
bundle exec rake conformance:test SCENARIO=ping
# Run a single scenario
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
@ -91,10 +91,8 @@ submissions.
```
conformance/
server.rb # Conformance server (Rack + Puma, default port 9292)
server_runner.rb # Starts the server, runs npx conformance server, exits with result code
client.rb # Conformance client (invoked by npx conformance client)
client_runner.rb # Runs npx conformance client, exits with result code
expected_failures.yml # Baseline of known-failing scenarios (server and client)
runner.rb # Starts the server, runs npx conformance, exits with result code
expected_failures.yml # Baseline of known-failing scenarios
README.md # This file
```
@ -102,4 +100,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`.

View file

@ -1,306 +0,0 @@
# frozen_string_literal: true
# Conformance test client for the MCP Ruby SDK.
# Invoked by the conformance runner:
# MCP_CONFORMANCE_SCENARIO=<scenario> bundle exec ruby conformance/client.rb <server-url>
#
# The server URL is passed as the last positional argument.
# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
# which is set automatically by the conformance test runner.
require "faraday"
require "json"
require_relative "../lib/mcp"
scenario = ENV["MCP_CONFORMANCE_SCENARIO"]
server_url = ARGV.last
unless scenario && server_url
abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>")
end
# URL the conformance harness expects to see as the OAuth `client_id` for the `auth/basic-cimd` scenario
# when the AS advertises `client_id_metadata_document_supported`. The harness does not fetch the document,
# only matches the value, so the URL does not need to resolve.
CONFORMANCE_CIMD_URL = "https://conformance-test.local/client-metadata.json"
# The conformance harness optionally injects scenario-specific data via
# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is
# defined by the harness, not the MCP spec, and has varied between versions:
#
# - Newer (`@modelcontextprotocol/conformance` >= 0.x): scenario fields are
# spread at the top level alongside `name`, e.g.
# `{"name":"auth/pre-registration","client_id":"...","client_secret":"..."}`.
# - Older: a nested `context` object: `{"name":"...","context":{...}}`.
#
# Both shapes are accepted so the client conforms to whichever harness version
# the developer has on hand.
def conformance_context
raw = ENV["MCP_CONFORMANCE_CONTEXT"]
return {} if raw.nil? || raw.empty?
parsed = JSON.parse(raw)
return {} unless parsed.is_a?(Hash)
if parsed["context"].is_a?(Hash)
parsed["context"]
else
parsed.reject { |key, _| key == "name" }
end
rescue JSON::ParserError
{}
end
# Saves the pre-registered `client_id` / `client_secret` the harness injects
# via context (used by pre-registration and client_credentials scenarios).
def storage_for(context)
storage = MCP::Client::OAuth::InMemoryStorage.new
if context["client_id"]
storage.save_client_information(
"client_id" => context["client_id"],
"client_secret" => context["client_secret"],
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
)
end
storage
end
# Builds a `client_credentials`-only provider (machine-to-machine, no redirect).
# The pre-registered credentials are injected by the harness via context:
# a shared secret for the basic scenario, or a PEM private key plus signing algorithm
# (default ES256, matching the TypeScript and Python conformance clients)
# for the `private_key_jwt` scenario.
def build_client_credentials_provider(context)
if context["private_key_pem"]
MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: context["client_id"],
token_endpoint_auth_method: "private_key_jwt",
private_key: context["private_key_pem"],
signing_algorithm: context["signing_algorithm"] || "ES256",
)
else
MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: context["client_id"],
client_secret: context["client_secret"],
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
)
end
end
# Builds a SEP-990 Cross-App Access provider: the harness injects an IdP ID token plus the IdP token endpoint
# via context; the assertion provider exchanges them for an ID-JAG (RFC 8693), which the flow then presents to
# the MCP authorization server with the jwt-bearer grant.
def build_cross_app_access_provider(context)
assertion_provider = ->(audience:, resource:) do
MCP::Client::OAuth::IDJAGTokenExchange.request(
token_endpoint: context["idp_token_endpoint"],
id_token: context["idp_id_token"],
client_id: context["idp_client_id"],
audience: audience,
resource: resource,
)
end
MCP::Client::OAuth::CrossAppAccessProvider.new(
client_id: context["client_id"],
client_secret: context["client_secret"],
assertion_provider: assertion_provider,
)
end
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
# non-interactively against the conformance test's auth server. The conformance
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
# `code=test-auth-code`, so we follow it manually instead of opening a browser.
def build_oauth_provider(context, scenario:)
callback_holder = {}
redirect_uri = "http://localhost:0/callback"
redirect_handler = ->(authorization_url) do
response = Faraday.new.get(authorization_url) do |req|
req.options.params_encoder = nil
end
location = response.headers["location"] || response.headers["Location"]
abort("Authorization request did not redirect: #{response.status}.") unless location
callback_holder[:url] = URI.parse(location)
end
callback_handler = -> do
query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h
# The 3-element form opts into SEP-2468 / RFC 9207 `iss` validation;
# `query["iss"]` is nil when the authorization response carried no `iss`.
[query["code"], query["state"], query["iss"]]
end
MCP::Client::OAuth::Provider.new(
client_metadata: {
client_name: "ruby-sdk-conformance-client",
redirect_uris: [redirect_uri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: redirect_uri,
redirect_handler: redirect_handler,
callback_handler: callback_handler,
storage: storage_for(context),
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
)
end
def build_provider_for(scenario, context)
if context["idp_id_token"]
build_cross_app_access_provider(context)
elsif scenario.start_with?("auth/client-credentials")
build_client_credentials_provider(context)
else
build_oauth_provider(context, scenario: scenario)
end
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
# (e.g. `auth/resource-mismatch`, `auth/scope-retry-limit`) are expected to drive the client into
# a terminal failure: the SDK correctly refuses a mismatched resource or stops after the scope step-up
# retry limit. Report such an expected failure as a one-line reason and exit cleanly so the run stays
# 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
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" }
abort("Tool add_numbers not found") unless add_numbers
client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 })
when "sse-retry"
# SEP-1699: the server closes the tools/call SSE stream right after a priming event.
# The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`,
# and receives the tool result on the resumed stream; the harness verifies the reconnect,
# its timing, and the header.
tools = client.tools
test_reconnection = tools.find { |t| t.name == "test_reconnection" }
abort("Tool test_reconnection not found") unless test_reconnection
client.call_tool(tool: test_reconnection, arguments: {})
when "elicitation-sep1034-client-defaults"
# SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring
# a `default` and none required. The handler accepts and fills the omitted fields from those defaults.
client.on_elicitation do |params|
{ action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) }
end
tools = client.tools
defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" }
abort("Tool test_client_elicitation_defaults not found") unless defaults_tool
client.call_tool(tool: defaults_tool, arguments: {})
when %r|\Aauth/|
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
# are observed by the conformance server during `connect` and the subsequent request below.
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
tools = client.tools
# `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`,
# so the client must actually invoke a tool to drive the second authorization request
# the scenario asserts on.
if scenario == "auth/scope-step-up"
tool = tools.find { |t| t.name == "test-tool" } || tools.first
abort("No tool exposed by conformance server for #{scenario}") unless tool
client.call_tool(tool: tool, arguments: {})
end
else
abort("Unknown or unsupported scenario: #{scenario}")
end
rescue MCP::Client::OAuth::Flow::AuthorizationError, MCP::Client::RequestHandlerError, Faraday::Error => e
warn("#{scenario}: client ended in the expected terminal failure (#{e.class}: #{e.message})")
exit
end

View file

@ -1,48 +0,0 @@
# frozen_string_literal: true
# Runs `npx @modelcontextprotocol/conformance@alpha client` against the conformance client script.
require "English"
module Conformance
class ClientRunner
def initialize(scenario: nil, spec_version: nil, verbose: false)
@scenario = scenario
@spec_version = spec_version
@verbose = verbose
end
def run
command = build_command
puts "Command: #{command.join(" ")}\n\n"
system(*command)
conformance_exit_code = $CHILD_STATUS.exitstatus
exit(conformance_exit_code || 1) unless conformance_exit_code == 0
end
private
def build_command
expected_failures_yml = File.expand_path("expected_failures.yml", __dir__)
client_script = File.expand_path("client.rb", __dir__)
npx_command = [
"npx",
"--yes",
"@modelcontextprotocol/conformance@alpha",
"client",
"--command",
"bundle exec ruby #{client_script}",
]
npx_command += if @scenario
["--scenario", @scenario]
else
["--suite", "all"]
end
npx_command += ["--spec-version", @spec_version] if @spec_version
npx_command += ["--verbose"] if @verbose
npx_command += ["--expected-failures", expected_failures_yml]
npx_command
end
end
end

View file

@ -1,6 +1 @@
server: []
client:
# Unimplemented authorization extensions.
- auth/dpop
- auth/dpop-nonce
- auth/wif-jwt-bearer

101
conformance/runner.rb Normal file
View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
# Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it.
require "English"
require "net/http"
require_relative "server"
module Conformance
class Runner
# Timeout for waiting for the Puma server to start.
SERVER_START_TIMEOUT = 20
SERVER_POLL_INTERVAL = 0.5
SERVER_HEALTH_CHECK_RETRIES = (SERVER_START_TIMEOUT / SERVER_POLL_INTERVAL).to_i
def initialize(port: Server::DEFAULT_PORT, scenario: nil, spec_version: nil, verbose: false)
@port = port
@scenario = scenario
@spec_version = spec_version
@verbose = verbose
end
def run
command = build_command
server_pid = start_server
run_conformance(command, server_pid: server_pid)
end
private
def build_command
expected_failures_yml = File.expand_path("expected_failures.yml", __dir__)
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
npx_command += ["--expected-failures", expected_failures_yml]
npx_command
end
def start_server
puts "Starting conformance server on port #{@port}..."
server_pid = fork do
Conformance::Server.new(port: @port).start
end
health_url = URI("http://localhost:#{@port}/health")
ready = false
SERVER_HEALTH_CHECK_RETRIES.times do
begin
response = Net::HTTP.get_response(health_url)
if response.code == "200"
ready = true
break
end
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::ReadTimeout
# not ready yet
end
sleep(SERVER_POLL_INTERVAL)
end
unless ready
warn("ERROR: Conformance server did not start within #{SERVER_START_TIMEOUT} seconds")
terminate_server(server_pid)
exit(1)
end
puts "Server ready. Running conformance tests..."
server_pid
end
def run_conformance(command, server_pid:)
puts "Command: #{command.join(" ")}\n\n"
conformance_exit_code = nil
begin
system(*command)
conformance_exit_code = $CHILD_STATUS.exitstatus
ensure
terminate_server(server_pid)
end
exit(conformance_exit_code || 1)
end
def terminate_server(pid)
Process.kill("TERM", pid)
rescue Errno::ESRCH
# process already exited
ensure
begin
Process.wait(pid)
rescue Errno::ECHILD
# already reaped
end
end
end
end

View file

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

View file

@ -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"

View file

@ -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 <access_token>` 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 `<origin>/.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.

Some files were not shown because too many files have changed in this diff Show more