Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538a364c9b |
||
|
|
66b2efa11e |
||
|
|
8173a38280 | ||
|
|
5f429f3fbf |
||
|
|
e1105c7094 |
||
|
|
5081cc5626 | ||
|
|
2c33ad061a |
||
|
|
9455c41b14 |
||
|
|
c9f381a3ee | ||
|
|
f51ccfbe66 | ||
|
|
f22ce86e97 |
||
|
|
29e8d3f2b9 | ||
|
|
47eb74aa7a | ||
|
|
8ad80dd0b4 | ||
|
|
41fbf05d26 |
||
|
|
99ef8732fe | ||
|
|
7d7a5669ef |
||
|
|
8d1d5be4ba | ||
|
|
a18c628787 |
||
|
|
ef3eb7b0b2 | ||
|
|
8e0ae73cc1 |
||
|
|
7e507f2963 | ||
|
|
251a40f780 |
||
|
|
2f65a6e502 | ||
|
|
91ee5a2123 |
||
|
|
4192ea58ea | ||
|
|
c6a5512381 |
||
|
|
3b44ec4f48 | ||
|
|
d0b22ef722 |
||
|
|
d40d9018d5 | ||
|
|
09c89aef14 | ||
|
|
44af58c7e0 |
||
|
|
0da0291887 | ||
|
|
8a24e50d1b |
||
|
|
33525d6494 | ||
|
|
f706cc8a5e |
||
|
|
6d272df0e2 |
||
|
|
eeab784fc8 |
||
|
|
09c310341b | ||
|
|
088f4b99e4 | ||
|
|
6b1704f330 |
||
|
|
6f0b85486b | ||
|
|
09d4bf41aa | ||
|
|
0cd1514bde |
||
|
|
920707cd7e | ||
|
|
11f9aacaa9 |
||
|
|
bfc6e551a3 | ||
|
|
da4ce54075 |
||
|
|
50accf644d | ||
|
|
6b6b641655 |
||
|
|
7909600d16 |
||
|
|
2200cbd2bb | ||
|
|
411f6b9f0f |
||
|
|
636e494068 | ||
|
|
3b79c8e58a |
||
|
|
3da0c10bed | ||
|
|
5ce308b082 | ||
|
|
9cd1041e7c |
||
|
|
8866317e62 | ||
|
|
2a31e0bd80 |
||
|
|
500d247e68 |
||
|
|
c934b04f48 | ||
|
|
c9d36b04bf | ||
|
|
fcb1ac935d |
||
|
|
79d89e0ac6 |
||
|
|
833e21a959 | ||
|
|
88831b46f6 | ||
|
|
5124cb452d |
||
|
|
0ff1406efe |
||
|
|
77f571bc5d | ||
|
|
34473282b7 |
||
|
|
4eb66155e2 |
||
|
|
87ad91d170 |
||
|
|
a53e8d9ed4 | ||
|
|
02db089038 | ||
|
|
ad73154697 |
||
|
|
8a886639fd | ||
|
|
8c2e89afd5 | ||
|
|
27044b0047 |
99 changed files with 9602 additions and 3799 deletions
2
.github/workflows/conformance.yml
vendored
2
.github/workflows/conformance.yml
vendored
|
|
@ -26,4 +26,4 @@ jobs:
|
|||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24' # Specify the latest Node.js version.
|
||||
- run: bundle exec rake conformance
|
||||
- run: bundle exec rake conformance:test
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,3 +9,7 @@
|
|||
/spec/reports/
|
||||
/tmp/
|
||||
Gemfile.lock
|
||||
docs/_site/
|
||||
docs/.jekyll-cache/
|
||||
docs/.jekyll-metadata
|
||||
docs/_data/versions.yml
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -15,7 +15,9 @@ 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 tests and linting (default task)
|
||||
- `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)
|
||||
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
|
||||
- `gem build mcp.gemspec` - Build the gem
|
||||
|
||||
|
|
@ -34,6 +36,14 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
|||
- Keep dependencies minimal
|
||||
- Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only
|
||||
|
||||
## Documentation
|
||||
|
||||
- User-facing documentation lives in `docs/`, one page per topic, published at https://ruby.sdk.modelcontextprotocol.io (deploys only when a release is published)
|
||||
- Pages live in the `docs/_server/`, `docs/_client/`, and `docs/_extensions/` collections
|
||||
- Keep README.md slim: quick start and pointers only; document features on the relevant docs page
|
||||
- Internal links are absolute and extensionless (e.g. `/server/tools/`); front matter is followed by a blank line before the h1
|
||||
- Callout tiers: `.note` (supplementary), `.important` (spec constraints), `.warning` (deprecated features)
|
||||
|
||||
## Commit message conventions
|
||||
|
||||
- Use conventional commit format when possible
|
||||
|
|
|
|||
65
CHANGELOG.md
65
CHANGELOG.md
|
|
@ -7,6 +7,71 @@ 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
|
||||
|
|
|
|||
32
ROADMAP.md
32
ROADMAP.md
|
|
@ -5,21 +5,29 @@ It is a living document and will evolve as the SDK matures.
|
|||
|
||||
## Current Status
|
||||
|
||||
The SDK implements the 2025-06-18 and 2025-11-25 spec revisions, and passes all server and client conformance scenarios.
|
||||
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 will mark the public API as stable: breaking changes then ship only in major releases,
|
||||
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 fully supported throughout 1.x, and deprecation warnings will be added in a future minor release.
|
||||
Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet:
|
||||
whether 2.0.0 removes them depends on how future MCP spec revisions treat these features
|
||||
and on adoption of their replacements.
|
||||
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
|
||||
|
||||
|
|
@ -28,12 +36,16 @@ Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provide
|
|||
|
||||
## Documentation
|
||||
|
||||
Reference documentation covers all core features.
|
||||
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.
|
||||
Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests,
|
||||
and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision,
|
||||
such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0.
|
||||
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,41 +14,85 @@ require "rubocop/rake_task"
|
|||
|
||||
RuboCop::RakeTask.new
|
||||
|
||||
task default: [:rubocop, :test, :conformance]
|
||||
task default: [:rubocop, :test, "conformance:test"]
|
||||
|
||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||
task :conformance do |t|
|
||||
next unless npx_available?(t.name)
|
||||
namespace :conformance do
|
||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||
task :test 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
|
||||
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
|
||||
end
|
||||
|
||||
desc "List available conformance scenarios"
|
||||
task :conformance_list do |t|
|
||||
next unless npx_available?(t.name)
|
||||
# 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"
|
||||
|
||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
||||
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
|
||||
end
|
||||
|
||||
desc "Start the conformance server (PORT)"
|
||||
task :conformance_server do
|
||||
require_relative "conformance/server"
|
||||
# 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?
|
||||
|
||||
options = {}
|
||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||
|
||||
Conformance::Server.new(**options).start
|
||||
mkdir_p(File.join(docs_dir, "_data"))
|
||||
File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join)
|
||||
end
|
||||
|
||||
def npx_available?(task_name)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
|
|||
### Run all scenarios
|
||||
|
||||
```bash
|
||||
bundle exec rake conformance
|
||||
bundle exec rake conformance:test
|
||||
```
|
||||
|
||||
Runs both server and client conformance tests in sequence. Server conformance starts the
|
||||
|
|
@ -31,20 +31,20 @@ are allowed to fail without affecting the exit code.
|
|||
|
||||
```bash
|
||||
# Run a single server scenario
|
||||
bundle exec rake conformance SCENARIO=ping
|
||||
bundle exec rake conformance:test SCENARIO=ping
|
||||
|
||||
# Use a different port with verbose output
|
||||
bundle exec rake conformance PORT=3000 VERBOSE=1
|
||||
bundle exec rake conformance:test 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
|
||||
|
|
@ -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`.
|
||||
These are shown in the output of `bundle exec rake conformance:test`.
|
||||
|
|
|
|||
15
docs/Gemfile
Normal file
15
docs/Gemfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 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"
|
||||
405
docs/_client/authorization.md
Normal file
405
docs/_client/authorization.md
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
---
|
||||
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.
|
||||
71
docs/_client/cancellation.md
Normal file
71
docs/_client/cancellation.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
layout: default
|
||||
title: Cancellation
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Cancellation
|
||||
|
||||
`MCP::Client` lets the caller cancel a request it has already issued,
|
||||
per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation).
|
||||
The recommended pattern is to pass
|
||||
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
|
||||
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
|
||||
the server, and the calling thread is woken up with `MCP::CancelledError`:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
cancellation = MCP::Cancellation.new
|
||||
|
||||
Thread.new do
|
||||
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
||||
rescue MCP::CancelledError
|
||||
# cleanup
|
||||
end
|
||||
|
||||
# Later, from another thread:
|
||||
cancellation.cancel(reason: "user pressed cancel")
|
||||
```
|
||||
|
||||
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
||||
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword.
|
||||
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
||||
|
||||
{: .note }
|
||||
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
||||
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
||||
> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
||||
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
||||
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
||||
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
||||
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
||||
|
||||
{: .note }
|
||||
> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight
|
||||
> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests
|
||||
> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still
|
||||
> raises `MCP::CancelledError` - but the server runs the request to completion.
|
||||
|
||||
## Wire-order guarantees
|
||||
|
||||
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
||||
so the server is guaranteed to read the request line before the cancel line.
|
||||
|
||||
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
||||
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
||||
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
||||
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)),
|
||||
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
||||
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
||||
|
||||
## Custom transports
|
||||
|
||||
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
||||
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
||||
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
||||
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
||||
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
||||
|
||||
## Server Side
|
||||
|
||||
How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page.
|
||||
47
docs/_client/index.md
Normal file
47
docs/_client/index.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
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.
|
||||
78
docs/_client/lifecycle.md
Normal file
78
docs/_client/lifecycle.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
layout: default
|
||||
title: Lifecycle
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Lifecycle
|
||||
|
||||
Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake
|
||||
on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28.
|
||||
This page covers `MCP::Client#connect` and how it negotiates between the two.
|
||||
|
||||
## Handshake
|
||||
|
||||
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
||||
|
||||
```ruby
|
||||
client.connect
|
||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
|
||||
client.connected? # => true
|
||||
client.server_info # => cached InitializeResult
|
||||
```
|
||||
|
||||
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
||||
|
||||
This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page.
|
||||
|
||||
By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake
|
||||
only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it.
|
||||
|
||||
## Lifecycle Negotiation
|
||||
|
||||
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
|
||||
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/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"]
|
||||
```
|
||||
62
docs/_client/mrtr.md
Normal file
62
docs/_client/mrtr.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
layout: default
|
||||
title: Multi Round-Trip Requests
|
||||
nav_order: 4
|
||||
redirect_from:
|
||||
- /client/multi-round-trip-results/
|
||||
---
|
||||
|
||||
# Multi Round-Trip Requests
|
||||
|
||||
MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`,
|
||||
or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
|
||||
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
|
||||
|
||||
## Automatic Driving
|
||||
|
||||
The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`,
|
||||
and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds`
|
||||
(10 by default, matching the TypeScript and Python SDKs).
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
client.connect(capabilities: { elicitation: { form: {} } })
|
||||
|
||||
client.on_elicitation do |params|
|
||||
{ action: "accept", content: { name: "Alice" } }
|
||||
end
|
||||
|
||||
# The input_required round trips are driven automatically; this returns the final result.
|
||||
response = client.call_tool(name: "collect_name", arguments: {})
|
||||
```
|
||||
|
||||
Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds
|
||||
the client declared.
|
||||
|
||||
## Manual Driving
|
||||
|
||||
Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final;
|
||||
the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords:
|
||||
|
||||
```ruby
|
||||
begin
|
||||
client.call_tool(name: "collect_name", arguments: {})
|
||||
rescue MCP::Client::InputRequiredError => error
|
||||
answers = error.input_requests.transform_values { |request| answer_for(request) }
|
||||
|
||||
client.call_tool(
|
||||
name: "collect_name",
|
||||
arguments: {},
|
||||
input_responses: answers,
|
||||
request_state: error.request_state,
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility.
|
||||
Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged.
|
||||
|
||||
## Server Side
|
||||
|
||||
Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment
|
||||
shim that serves pre-2026 clients are documented on the server [Multi Round-Trip Requests](/server/mrtr/) page.
|
||||
80
docs/_client/pagination.md
Normal file
80
docs/_client/pagination.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
layout: default
|
||||
title: Pagination
|
||||
nav_order: 7
|
||||
---
|
||||
|
||||
# Pagination
|
||||
|
||||
Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses
|
||||
per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination).
|
||||
Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it.
|
||||
|
||||
## Iterating Pages
|
||||
|
||||
`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
||||
**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection.
|
||||
The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor
|
||||
as method accessors; a `meta` accessor also mirrors the response's `_meta` field:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
|
||||
cursor = nil
|
||||
loop do
|
||||
page = client.list_tools(cursor: cursor)
|
||||
page.tools.each { |tool| process(tool) }
|
||||
cursor = page.next_cursor
|
||||
break unless cursor
|
||||
end
|
||||
```
|
||||
|
||||
The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and
|
||||
`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page.
|
||||
|
||||
Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration:
|
||||
|
||||
| Server `page_size` | `client.list_tools(cursor: nil)` |
|
||||
|--------------------|---------------------------------------------------------------------|
|
||||
| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. |
|
||||
| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. |
|
||||
|
||||
If your application needs the complete collection regardless of how the server is configured, either loop on
|
||||
`next_cursor` as shown above, or use the whole-collection methods described below.
|
||||
|
||||
## Fetching the Complete Collection
|
||||
|
||||
`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate
|
||||
through all pages and return a plain array of items, guaranteeing the full collection regardless
|
||||
of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round
|
||||
trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor`
|
||||
it has already sent, and it stops after `max_pages` pages.
|
||||
|
||||
```ruby
|
||||
tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
|
||||
```
|
||||
|
||||
`MCP::Client.new` accepts an optional `max_pages:` keyword that caps how many pages these methods
|
||||
will walk. It defaults to `1_000`; a server that keeps offering a fresh `nextCursor` past that
|
||||
point raises `MCP::Client::PaginationLimitError` rather than being followed indefinitely. Raise it
|
||||
if you legitimately expect more pages than that.
|
||||
|
||||
Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need
|
||||
fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
|
||||
|
||||
## Cache Hints
|
||||
|
||||
Per SEP-2549, list and read results can carry cache hints telling clients how long a result stays fresh (`ttlMs`)
|
||||
and whether shared intermediaries may cache it (`cacheScope`); see
|
||||
[List Result Caching](/server/pagination/#list-result-caching) on the server page for how they are emitted.
|
||||
On the client, the values are surfaced on the paginated result structs as `ttl_ms` and `cache_scope`:
|
||||
|
||||
```ruby
|
||||
page = client.list_tools
|
||||
page.ttl_ms # => 60000 (nil when the server sent no hint)
|
||||
page.cache_scope # => "private"
|
||||
```
|
||||
|
||||
## Server Side
|
||||
|
||||
Enabling pagination with `page_size:` is documented on the server [Pagination](/server/pagination/) page.
|
||||
49
docs/_client/ping.md
Normal file
49
docs/_client/ping.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
layout: default
|
||||
title: Ping
|
||||
nav_order: 6
|
||||
---
|
||||
|
||||
# Ping
|
||||
|
||||
The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping)
|
||||
allows either side of the connection to verify that the peer is still responsive.
|
||||
|
||||
{: .note }
|
||||
> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of
|
||||
> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself
|
||||
> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle,
|
||||
> so on a modern connection a conforming server (the Ruby SDK server included) rejects it
|
||||
> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility.
|
||||
|
||||
`MCP::Client` exposes `ping` to send a ping to the server:
|
||||
|
||||
```ruby
|
||||
client = MCP::Client.new(transport: transport)
|
||||
client.ping # => {} on success
|
||||
```
|
||||
|
||||
`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error.
|
||||
It raises `MCP::Client::ValidationError` when the response `result` is missing or
|
||||
is not a Hash (matching the spec requirement that `result` be an object).
|
||||
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
|
||||
propagate as exceptions raised by the transport layer.
|
||||
|
||||
## Answering Server Pings
|
||||
|
||||
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with
|
||||
the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open
|
||||
(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings.
|
||||
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when
|
||||
the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is
|
||||
dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:`
|
||||
on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits.
|
||||
|
||||
To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces
|
||||
the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`,
|
||||
as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page.
|
||||
|
||||
## Server Side
|
||||
|
||||
How servers answer `ping` requests and ping the client themselves is documented on
|
||||
the server [Ping](/server/ping/) page.
|
||||
285
docs/_client/transports.md
Normal file
285
docs/_client/transports.md
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
---
|
||||
layout: default
|
||||
title: Transports
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Transports
|
||||
|
||||
`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with.
|
||||
This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports,
|
||||
their sessions and server-to-client request handling, and the interface a custom transport must implement.
|
||||
|
||||
## Stdio Transport Layer
|
||||
|
||||
Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output.
|
||||
|
||||
`MCP::Client::Stdio.new` accepts the following keyword arguments:
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|---|---|---|
|
||||
| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). |
|
||||
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
||||
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
|
||||
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
|
||||
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
|
||||
|
||||
Example usage:
|
||||
|
||||
```ruby
|
||||
stdio_transport = MCP::Client::Stdio.new(
|
||||
command: "bundle",
|
||||
args: ["exec", "ruby", "path/to/server.rb"],
|
||||
env: { "API_KEY" => "my_secret_key" },
|
||||
read_timeout: 30
|
||||
)
|
||||
client = MCP::Client.new(transport: stdio_transport)
|
||||
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools.
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
puts "Tool: #{tool.name} - #{tool.description}"
|
||||
end
|
||||
|
||||
# Call a specific tool.
|
||||
response = client.call_tool(
|
||||
tool: tools.first,
|
||||
arguments: { message: "Hello, world!" }
|
||||
)
|
||||
|
||||
# Close the transport when done.
|
||||
stdio_transport.close
|
||||
```
|
||||
|
||||
The stdio transport automatically handles:
|
||||
|
||||
- Spawning the server process with `Open3.popen3`
|
||||
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
|
||||
- JSON-RPC 2.0 message framing over newline-delimited JSON
|
||||
- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings)
|
||||
|
||||
{: .note }
|
||||
> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create`
|
||||
> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer
|
||||
> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route,
|
||||
> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation`
|
||||
> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/).
|
||||
|
||||
## HTTP Transport Layer
|
||||
|
||||
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
|
||||
|
||||
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser`
|
||||
to read SSE (`text/event-stream`) responses.
|
||||
Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both,
|
||||
so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always
|
||||
does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well.
|
||||
`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to
|
||||
answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode)
|
||||
serving handshake-lifecycle clients, and only while no listening stream is opened:
|
||||
|
||||
```ruby
|
||||
gem "mcp"
|
||||
gem "faraday", ">= 2.0"
|
||||
gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses
|
||||
```
|
||||
|
||||
Example usage:
|
||||
|
||||
```ruby
|
||||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
|
||||
client = MCP::Client.new(transport: http_transport)
|
||||
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
puts <<~TOOL_INFORMATION
|
||||
Tool: #{tool.name}
|
||||
Description: #{tool.description}
|
||||
Input Schema: #{tool.input_schema}
|
||||
TOOL_INFORMATION
|
||||
end
|
||||
|
||||
# Call a specific tool
|
||||
response = client.call_tool(
|
||||
tool: tools.first,
|
||||
arguments: { message: "Hello, world!" }
|
||||
)
|
||||
|
||||
# Call a tool with progress tracking.
|
||||
response = client.call_tool(
|
||||
tool: tools.first,
|
||||
arguments: { count: 10 },
|
||||
progress_token: "my-progress-token"
|
||||
)
|
||||
```
|
||||
|
||||
The server sends `notifications/progress` during execution; the bundled transports do not currently
|
||||
expose these notifications to application code, so the token's effect is visible on the server side.
|
||||
See the [Progress](/server/progress/) page.
|
||||
|
||||
`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
|
||||
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
|
||||
a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
|
||||
|
||||
`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts,
|
||||
and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread,
|
||||
so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes).
|
||||
The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead,
|
||||
the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin
|
||||
the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at.
|
||||
|
||||
### Sessions
|
||||
|
||||
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
|
||||
|
||||
```ruby
|
||||
http_transport.session_id # => "abc123..."
|
||||
http_transport.protocol_version # => "2025-11-25"
|
||||
```
|
||||
|
||||
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
|
||||
|
||||
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
|
||||
|
||||
```ruby
|
||||
http_transport.close
|
||||
```
|
||||
|
||||
These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is
|
||||
no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead.
|
||||
|
||||
### Server-to-Client Requests (Elicitation)
|
||||
|
||||
Servers can send requests back to the client while one of the client's own requests is in flight - for example,
|
||||
[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call.
|
||||
Register a handler and advertise the capability on `connect` to respond to them:
|
||||
|
||||
```ruby
|
||||
client.connect(capabilities: { elicitation: {} })
|
||||
|
||||
client.on_elicitation do |params|
|
||||
{
|
||||
action: "accept",
|
||||
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
|
||||
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
Registering a handler opens a standalone HTTP GET SSE stream on a background thread
|
||||
([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)),
|
||||
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
|
||||
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
|
||||
`http_transport.on_server_request("method/name") { |params| ... }`.
|
||||
|
||||
On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same
|
||||
registered handlers instead drive the requests embedded in `input_required` results,
|
||||
as documented on [Multi Round-Trip Requests](/client/mrtr/).
|
||||
|
||||
### Server-to-Client Requests (Sampling)
|
||||
|
||||
Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
|
||||
letting a server leverage the client's model access without its own API keys.
|
||||
|
||||
{: .warning }
|
||||
> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
|
||||
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
|
||||
> new servers should call LLM provider APIs directly.
|
||||
|
||||
Register a handler and advertise the capability on `connect`:
|
||||
|
||||
```ruby
|
||||
client.connect(capabilities: { sampling: {} })
|
||||
|
||||
client.on_sampling do |params|
|
||||
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
|
||||
{
|
||||
role: "assistant",
|
||||
content: { type: "text", text: completion.text },
|
||||
model: completion.model,
|
||||
stopReason: "endTurn",
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
|
||||
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
|
||||
|
||||
```ruby
|
||||
client.on_sampling do |params|
|
||||
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
|
||||
|
||||
generate_completion(params)
|
||||
end
|
||||
```
|
||||
|
||||
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
|
||||
|
||||
### Custom Headers from Tool Parameters
|
||||
|
||||
On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries
|
||||
an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers per SEP-2243, so intermediaries can route
|
||||
on the values without parsing bodies. The declarations are learned from `tools/list` responses:
|
||||
list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values
|
||||
(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`,
|
||||
and a `null` or absent argument omits its header.
|
||||
|
||||
Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names,
|
||||
duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from
|
||||
`tools/list` results on modern connections, with a warning naming the tool.
|
||||
Legacy connections are unaffected: nothing is learned, mirrored, or excluded.
|
||||
|
||||
### Customizing the Faraday Connection
|
||||
|
||||
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.
|
||||
The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter:
|
||||
|
||||
```ruby
|
||||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
|
||||
faraday.use MyApp::Middleware::HttpRecorder
|
||||
faraday.adapter :typhoeus
|
||||
end
|
||||
```
|
||||
|
||||
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
|
||||
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
|
||||
|
||||
{: .note }
|
||||
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
|
||||
> the SSE streaming callback of another response, re-entering the connection on the same thread.
|
||||
> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter
|
||||
> with persistent connections must tolerate that re-entry.
|
||||
|
||||
## Custom Transports
|
||||
|
||||
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:
|
||||
|
||||
```ruby
|
||||
class CustomTransport
|
||||
# Sends a JSON-RPC request to the server and returns the raw response.
|
||||
#
|
||||
# @param request [Hash] A complete JSON-RPC request object.
|
||||
# https://www.jsonrpc.org/specification#request_object
|
||||
# @return [Hash] A hash modeling a JSON-RPC response object.
|
||||
# https://www.jsonrpc.org/specification#response_object
|
||||
def send_request(request:)
|
||||
# Your transport-specific logic here
|
||||
# - HTTP: POST to endpoint with JSON body
|
||||
# - WebSocket: Send message over WebSocket
|
||||
# - stdio: Write to stdout, read from stdin
|
||||
# - etc.
|
||||
end
|
||||
end
|
||||
|
||||
client = MCP::Client.new(transport: CustomTransport.new)
|
||||
```
|
||||
|
||||
Custom transports that need to support client-side cancellation have additional requirements;
|
||||
see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page.
|
||||
|
|
@ -2,18 +2,44 @@
|
|||
title: "MCP Ruby SDK"
|
||||
description: "The official Ruby SDK for Model Context Protocol servers and clients."
|
||||
remote_theme: just-the-docs/just-the-docs
|
||||
color_scheme: ruby-light
|
||||
|
||||
# Include generated files and directories which may start with underscores
|
||||
include:
|
||||
- "_*"
|
||||
plugins:
|
||||
- jekyll-redirect-from
|
||||
|
||||
# Sidebar sections: each collection renders as a plain category heading
|
||||
# with its pages listed beneath it, instead of a clickable parent page.
|
||||
collections:
|
||||
server:
|
||||
permalink: "/server/:name/"
|
||||
output: true
|
||||
client:
|
||||
permalink: "/client/:name/"
|
||||
output: true
|
||||
extensions:
|
||||
permalink: "/extensions/:name/"
|
||||
output: true
|
||||
|
||||
just_the_docs:
|
||||
collections:
|
||||
server:
|
||||
name: Building Servers
|
||||
client:
|
||||
name: Building Clients
|
||||
extensions:
|
||||
name: Extensions
|
||||
|
||||
# Search
|
||||
search_enabled: true
|
||||
|
||||
# Footer
|
||||
gh_edit_link: true
|
||||
gh_edit_link_text: "Edit this page on GitHub."
|
||||
gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk"
|
||||
gh_edit_branch: "main"
|
||||
gh_edit_source: "docs"
|
||||
gh_edit_view_mode: "edit"
|
||||
# Callouts
|
||||
callouts:
|
||||
note:
|
||||
title: Note
|
||||
color: blue
|
||||
important:
|
||||
title: Important
|
||||
color: yellow
|
||||
warning:
|
||||
title: Warning
|
||||
color: red
|
||||
|
|
|
|||
35
docs/_extensions/capability-extensions.md
Normal file
35
docs/_extensions/capability-extensions.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
layout: default
|
||||
title: Capability Extensions
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
# Capability Extensions
|
||||
|
||||
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
|
||||
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
||||
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
|
||||
|
||||
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
|
||||
|
||||
```ruby
|
||||
capabilities = MCP::Server::Capabilities.new
|
||||
capabilities.support_tools
|
||||
capabilities.support_extensions("com.example/feature" => { enabled: true })
|
||||
|
||||
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
|
||||
```
|
||||
|
||||
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
|
||||
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
|
||||
|
||||
On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result,
|
||||
and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake.
|
||||
Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations
|
||||
with envelope-first resolution, on either lifecycle.
|
||||
|
||||
On the client, pass extensions through `connect`:
|
||||
|
||||
```ruby
|
||||
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
|
||||
```
|
||||
17
docs/_extensions/index.md
Normal file
17
docs/_extensions/index.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
layout: default
|
||||
title: Overview
|
||||
nav_order: 1
|
||||
permalink: /extensions/
|
||||
---
|
||||
|
||||
# Extensions
|
||||
|
||||
Extensions add optional functionality on top of the core MCP protocol.
|
||||
Support for an extension is negotiated per connection:
|
||||
clients and servers declare the extensions they speak under the `extensions` member of their capabilities,
|
||||
as described on [Capability Extensions](/extensions/capability-extensions/).
|
||||
|
||||
The extensions this SDK ships support for:
|
||||
|
||||
- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue