Compare commits
153 changed files with 3993 additions and 20818 deletions
6
.github/workflows/conformance.yml
vendored
6
.github/workflows/conformance.yml
vendored
|
|
@ -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:
|
||||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
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
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,7 +9,3 @@
|
|||
/spec/reports/
|
||||
/tmp/
|
||||
Gemfile.lock
|
||||
docs/_site/
|
||||
docs/.jekyll-cache/
|
||||
docs/.jekyll-metadata
|
||||
docs/_data/versions.yml
|
||||
|
|
|
|||
|
|
@ -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_'
|
||||
|
|
|
|||
13
AGENTS.md
13
AGENTS.md
|
|
@ -15,9 +15,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
|||
- `bundle install` - Install dependencies
|
||||
- `rake test` - Run all tests
|
||||
- `rake rubocop` - Run linter
|
||||
- `rake` - Run linting, tests, and the conformance suite (default task)
|
||||
- `bundle exec rake conformance:test` - Run the MCP conformance suite (see conformance/README.md)
|
||||
- `bundle exec rake docs:preview` - Serve the documentation site locally at http://localhost:4000 (PORT to override)
|
||||
- `rake` - Run tests and linting (default task)
|
||||
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
|
||||
- `gem build mcp.gemspec` - Build the gem
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
147
CHANGELOG.md
147
CHANGELOG.md
|
|
@ -7,153 +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
|
||||
|
|
|
|||
51
ROADMAP.md
51
ROADMAP.md
|
|
@ -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)).
|
||||
94
Rakefile
94
Rakefile
|
|
@ -14,85 +14,41 @@ require "rubocop/rake_task"
|
|||
|
||||
RuboCop::RakeTask.new
|
||||
|
||||
task default: [:rubocop, :test, "conformance:test"]
|
||||
task default: [:rubocop, :test, :conformance]
|
||||
|
||||
namespace :conformance do
|
||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||
task :test do |t|
|
||||
next unless npx_available?(t.name)
|
||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||
task :conformance do |t|
|
||||
next unless npx_available?(t.name)
|
||||
|
||||
require_relative "conformance/server_runner"
|
||||
require_relative "conformance/client_runner"
|
||||
require_relative "conformance/server_runner"
|
||||
require_relative "conformance/client_runner"
|
||||
|
||||
options = {}
|
||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
|
||||
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
|
||||
options[:verbose] = true if ENV["VERBOSE"]
|
||||
options = {}
|
||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
|
||||
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
|
||||
options[:verbose] = true if ENV["VERBOSE"]
|
||||
|
||||
Conformance::ServerRunner.new(**options).run
|
||||
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
|
||||
end
|
||||
|
||||
desc "List available conformance scenarios"
|
||||
task :list do |t|
|
||||
next unless npx_available?(t.name)
|
||||
|
||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
||||
end
|
||||
|
||||
desc "Start the conformance server (PORT)"
|
||||
task :server do
|
||||
require_relative "conformance/server"
|
||||
|
||||
options = {}
|
||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||
|
||||
Conformance::Server.new(**options).start
|
||||
end
|
||||
Conformance::ServerRunner.new(**options).run
|
||||
Conformance::ClientRunner.new(**options.except(:port)).run
|
||||
end
|
||||
|
||||
# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance`
|
||||
# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without
|
||||
# running anything. This keeps the name pointing at the suite instead of at that silent no-op.
|
||||
desc "Alias for conformance:test"
|
||||
task conformance: "conformance:test"
|
||||
desc "List available conformance scenarios"
|
||||
task :conformance_list do |t|
|
||||
next unless npx_available?(t.name)
|
||||
|
||||
namespace :docs do
|
||||
desc "Serve the documentation site locally at http://localhost:4000 (PORT)"
|
||||
task :preview do
|
||||
docs_dir = File.expand_path("docs", __dir__)
|
||||
generate_docs_versions_data(docs_dir)
|
||||
|
||||
env = {
|
||||
"BUNDLE_GEMFILE" => File.join(docs_dir, "Gemfile"),
|
||||
"RUBYOPT" => "-r#{File.join(docs_dir, "_preview", "taint_shim.rb")}",
|
||||
}
|
||||
port = ENV.fetch("PORT", "4000")
|
||||
|
||||
Bundler.with_unbundled_env do
|
||||
system(env, "bundle", "install", "--quiet", chdir: docs_dir, exception: true)
|
||||
system(env, "bundle", "exec", "jekyll", "serve", "--port", port, chdir: docs_dir, exception: true)
|
||||
rescue Interrupt
|
||||
# Ctrl-C is the way to stop the preview, not an error.
|
||||
end
|
||||
end
|
||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
||||
end
|
||||
|
||||
# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from
|
||||
# the version tags at deploy time, and the preview generates the same data so the nav footer
|
||||
# shows the released-gem version line.
|
||||
def generate_docs_versions_data(docs_dir)
|
||||
versions = %x(git tag --list).split("\n").filter_map { |tag|
|
||||
tag[/\A[^0-9]*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\z/, 1]
|
||||
}.sort_by { |version|
|
||||
Gem::Version.new(version)
|
||||
}.reverse
|
||||
return if versions.empty?
|
||||
desc "Start the conformance server (PORT)"
|
||||
task :conformance_server do
|
||||
require_relative "conformance/server"
|
||||
|
||||
mkdir_p(File.join(docs_dir, "_data"))
|
||||
File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join)
|
||||
options = {}
|
||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||
|
||||
Conformance::Server.new(**options).start
|
||||
end
|
||||
|
||||
def npx_available?(task_name)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
|
|||
### Run all scenarios
|
||||
|
||||
```bash
|
||||
bundle exec rake conformance:test
|
||||
bundle exec rake conformance
|
||||
```
|
||||
|
||||
Runs both server and client conformance tests in sequence. Server conformance starts the
|
||||
|
|
@ -31,26 +31,26 @@ are allowed to fail without affecting the exit code.
|
|||
|
||||
```bash
|
||||
# Run a single server scenario
|
||||
bundle exec rake conformance:test SCENARIO=ping
|
||||
bundle exec rake conformance SCENARIO=ping
|
||||
|
||||
# Use a different port with verbose output
|
||||
bundle exec rake conformance:test PORT=3000 VERBOSE=1
|
||||
bundle exec rake conformance PORT=3000 VERBOSE=1
|
||||
|
||||
# Start the server on a specific port
|
||||
bundle exec rake conformance:server PORT=3000
|
||||
bundle exec rake conformance_server PORT=3000
|
||||
```
|
||||
|
||||
### Start the server and test separately
|
||||
|
||||
```bash
|
||||
# Terminal 1: start the server
|
||||
bundle exec rake conformance:server
|
||||
bundle exec rake conformance_server
|
||||
|
||||
# Terminal 2: run all scenarios
|
||||
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
|
||||
npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp
|
||||
|
||||
# Terminal 2: run a single scenario
|
||||
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp --scenario ping
|
||||
npx @modelcontextprotocol/conformance server --url http://localhost:9292/mcp --scenario ping
|
||||
```
|
||||
|
||||
Keeps the server alive between test runs, which avoids the startup overhead when iterating
|
||||
|
|
@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done.
|
|||
### List available scenarios
|
||||
|
||||
```bash
|
||||
bundle exec rake conformance:list
|
||||
bundle exec rake conformance_list
|
||||
```
|
||||
|
||||
Prints all scenario names that can be passed to `SCENARIO`.
|
||||
|
|
@ -76,7 +76,7 @@ repository with the conformance server running:
|
|||
|
||||
```bash
|
||||
# Terminal 1 (this repository): start the conformance server
|
||||
bundle exec rake conformance:server
|
||||
bundle exec rake conformance_server
|
||||
|
||||
# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
|
||||
/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
|
||||
|
|
@ -102,4 +102,4 @@ conformance/
|
|||
|
||||
Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
|
||||
fail without affecting the exit code and are tracked to catch regressions.
|
||||
These are shown in the output of `bundle exec rake conformance:test`.
|
||||
These are shown in the output of `bundle exec rake conformance`.
|
||||
|
|
|
|||
|
|
@ -87,27 +87,6 @@ def build_client_credentials_provider(context)
|
|||
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
|
||||
|
|
@ -150,9 +129,7 @@ def build_oauth_provider(context, scenario:)
|
|||
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")
|
||||
if scenario.start_with?("auth/client-credentials")
|
||||
build_client_credentials_provider(context)
|
||||
else
|
||||
build_oauth_provider(context, scenario: scenario)
|
||||
|
|
@ -162,145 +139,56 @@ 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,
|
||||
)
|
||||
client.connect(
|
||||
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
||||
capabilities: capabilities,
|
||||
)
|
||||
|
||||
case scenario
|
||||
when "initialize"
|
||||
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
|
||||
|
||||
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
|
||||
tools = client.tools
|
||||
defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" }
|
||||
|
||||
connect.call
|
||||
end
|
||||
abort("Tool test_client_elicitation_defaults not found") unless defaults_tool
|
||||
|
||||
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
|
||||
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: {})
|
||||
|
||||
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
|
||||
else
|
||||
abort("Unknown or unsupported scenario: #{scenario}")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Runs `npx @modelcontextprotocol/conformance@alpha client` against the conformance client script.
|
||||
# Runs `npx @modelcontextprotocol/conformance client` against the conformance client script.
|
||||
require "English"
|
||||
|
||||
module Conformance
|
||||
|
|
@ -29,7 +29,7 @@ module Conformance
|
|||
npx_command = [
|
||||
"npx",
|
||||
"--yes",
|
||||
"@modelcontextprotocol/conformance@alpha",
|
||||
"@modelcontextprotocol/conformance",
|
||||
"client",
|
||||
"--command",
|
||||
"bundle exec ruby #{client_script}",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
server: []
|
||||
client:
|
||||
# Unimplemented authorization extensions.
|
||||
- auth/dpop
|
||||
- auth/dpop-nonce
|
||||
- auth/wif-jwt-bearer
|
||||
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
|
||||
- auth/cross-app-access-complete-flow
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Starts the conformance server and runs `npx @modelcontextprotocol/conformance@alpha` against it.
|
||||
# Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it.
|
||||
require "English"
|
||||
require "net/http"
|
||||
require_relative "server"
|
||||
|
|
@ -31,7 +31,7 @@ module Conformance
|
|||
def build_command
|
||||
expected_failures_yml = File.expand_path("expected_failures.yml", __dir__)
|
||||
|
||||
npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance@alpha", "server", "--url", "http://localhost:#{@port}/mcp"]
|
||||
npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance", "server", "--url", "http://localhost:#{@port}/mcp"]
|
||||
npx_command += ["--scenario", @scenario] if @scenario
|
||||
npx_command += ["--spec-version", @spec_version] if @spec_version
|
||||
npx_command += ["--verbose"] if @verbose
|
||||
|
|
|
|||
15
docs/Gemfile
15
docs/Gemfile
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
---
|
||||
layout: default
|
||||
title: Cancellation
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Cancellation
|
||||
|
||||
`MCP::Client` lets the caller cancel a request it has already issued,
|
||||
per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation).
|
||||
The recommended pattern is to pass
|
||||
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
|
||||
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
|
||||
the server, and the calling thread is woken up with `MCP::CancelledError`:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
Thread.new do
|
||||
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
||||
rescue MCP::CancelledError
|
||||
# cleanup
|
||||
end
|
||||
|
||||
# Later, from another thread:
|
||||
cancellation.cancel(reason: "user pressed cancel")
|
||||
```
|
||||
|
||||
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
||||
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword.
|
||||
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
||||
|
||||
{: .note }
|
||||
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
||||
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
||||
> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
||||
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
||||
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
||||
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
||||
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
||||
|
||||
{: .note }
|
||||
> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight
|
||||
> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests
|
||||
> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still
|
||||
> raises `MCP::CancelledError` - but the server runs the request to completion.
|
||||
|
||||
## Wire-order guarantees
|
||||
|
||||
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
||||
so the server is guaranteed to read the request line before the cancel line.
|
||||
|
||||
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
||||
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
||||
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
||||
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)),
|
||||
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
||||
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
||||
|
||||
## Custom transports
|
||||
|
||||
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
||||
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
||||
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
||||
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
||||
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
||||
|
||||
## Server Side
|
||||
|
||||
How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page.
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
layout: default
|
||||
title: Overview
|
||||
nav_order: 1
|
||||
permalink: /client/
|
||||
redirect_from:
|
||||
- /building-clients.html
|
||||
- /building-clients/
|
||||
---
|
||||
|
||||
# Building an MCP Client
|
||||
|
||||
The `MCP::Client` class provides an interface for interacting with MCP servers.
|
||||
|
||||
This class supports:
|
||||
|
||||
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
|
||||
when the server serves it; see [Lifecycle](/client/lifecycle/)
|
||||
- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery)
|
||||
- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings;
|
||||
see [Answering Server Pings](/client/ping/#answering-server-pings)
|
||||
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
|
||||
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
|
||||
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
||||
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
|
||||
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
|
||||
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
|
||||
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
|
||||
- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completion](/server/completion/)
|
||||
- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`,
|
||||
or `on_roots` handlers are registered; see [Multi Round-Trip Requests](/client/mrtr/)
|
||||
- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/)
|
||||
- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with
|
||||
the `max_pages` guard; see [Pagination](/client/pagination/)
|
||||
- Automatic JSON-RPC 2.0 message formatting
|
||||
- UUID request ID generation
|
||||
|
||||
Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics.
|
||||
Authorization is handled by the transport layer; see [Authorization](/client/authorization/).
|
||||
|
||||
## Tool Objects
|
||||
|
||||
The client provides a wrapper class for tools returned by the server:
|
||||
|
||||
- `MCP::Client::Tool` - Represents a single tool with its metadata
|
||||
|
||||
This class provides easy access to tool properties like name, description, input schema, and output schema.
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
---
|
||||
layout: default
|
||||
title: Lifecycle
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Lifecycle
|
||||
|
||||
Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake
|
||||
on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28.
|
||||
This page covers `MCP::Client#connect` and how it negotiates between the two.
|
||||
|
||||
## Handshake
|
||||
|
||||
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
||||
|
||||
```ruby
|
||||
client.connect
|
||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
|
||||
client.connected? # => true
|
||||
client.server_info # => cached InitializeResult
|
||||
```
|
||||
|
||||
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
||||
|
||||
This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page.
|
||||
|
||||
By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake
|
||||
only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it.
|
||||
|
||||
## Lifecycle Negotiation
|
||||
|
||||
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
|
||||
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discover/) first and adopts
|
||||
the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to
|
||||
the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare
|
||||
a `mode:` keyword always receive the classic call shape, unchanged.
|
||||
|
||||
```ruby
|
||||
client.connect # negotiate automatically (default)
|
||||
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
|
||||
client.connect(mode: :legacy) # force the classic initialize handshake
|
||||
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe
|
||||
```
|
||||
|
||||
Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process)
|
||||
and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on
|
||||
the legacy lifecycle.
|
||||
|
||||
Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result,
|
||||
their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`,
|
||||
top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`)
|
||||
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
|
||||
|
||||
```ruby
|
||||
client.protocol_version # negotiated or adopted version, either lifecycle
|
||||
client.server_capabilities # capabilities Hash, either lifecycle
|
||||
client.instructions # instructions text, either lifecycle
|
||||
client.server_implementation # server name/version; nil when a modern server does not identify itself
|
||||
```
|
||||
|
||||
Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded,
|
||||
the server now serves the modern lifecycle and the automatic negotiation adopted it.
|
||||
Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
|
||||
|
||||
## Explicit Discovery
|
||||
|
||||
`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery
|
||||
that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct
|
||||
exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and
|
||||
the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discover/) page
|
||||
for the wire shapes.
|
||||
|
||||
```ruby
|
||||
result = client.discover
|
||||
result.supported_versions # => ["2026-07-28"]
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue