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
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '24' # Specify the latest Node.js version.
|
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/
|
/spec/reports/
|
||||||
/tmp/
|
/tmp/
|
||||||
Gemfile.lock
|
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
|
- `bundle install` - Install dependencies
|
||||||
- `rake test` - Run all tests
|
- `rake test` - Run all tests
|
||||||
- `rake rubocop` - Run linter
|
- `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
|
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
|
||||||
- `gem build mcp.gemspec` - Build the gem
|
- `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
|
- 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
|
- 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
|
## Commit message conventions
|
||||||
|
|
||||||
- Use conventional commit format when possible
|
- 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]
|
## [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
|
## [1.2.0] - 2026-08-15
|
||||||
|
|
||||||
This release completes the SEP-2575 stateless lifecycle of the 2026-07-28 specification, together with
|
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
|
## 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
|
## 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),
|
apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md),
|
||||||
which also defines the Semantic Versioning scheme and breaking-change policy.
|
which also defines the Semantic Versioning scheme and breaking-change policy.
|
||||||
|
|
||||||
## Deprecated Features
|
## Deprecated Features
|
||||||
|
|
||||||
The 2026-07-28 spec revision deprecates Roots, Sampling, and Logging (SEP-2577).
|
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.
|
These features remain supported throughout 1.x: on the legacy lifecycle they work unchanged
|
||||||
Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet:
|
(the deprecations do not apply there), and the 2026-07-28 lifecycle carries them through its own mechanisms:
|
||||||
whether 2.0.0 removes them depends on how future MCP spec revisions treat these features
|
roots-shaped and sampling-shaped input requests embedded in `InputRequiredResult` responses
|
||||||
and on adoption of their replacements.
|
(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
|
## Conformance
|
||||||
|
|
||||||
|
|
@ -28,12 +36,16 @@ Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provide
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Reference documentation covers all core features.
|
Reference documentation covers all non-experimental features with examples.
|
||||||
|
|
||||||
## Tracking New Spec Revisions
|
## Tracking New Spec Revisions
|
||||||
|
|
||||||
The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per
|
The SDK aims to support each new MCP specification revision, with the implementation timeline agreed per
|
||||||
release based on feature complexity.
|
release based on feature complexity.
|
||||||
Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests,
|
The 2026-07-28 revision shipped during 1.x: the server serves both lifecycle eras side by side,
|
||||||
and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision,
|
so the SEP-2575 stateless lifecycle (including `server/discover` and `subscriptions/listen`),
|
||||||
such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0.
|
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)).
|
||||||
|
|
|
||||||
62
Rakefile
62
Rakefile
|
|
@ -14,10 +14,11 @@ require "rubocop/rake_task"
|
||||||
|
|
||||||
RuboCop::RakeTask.new
|
RuboCop::RakeTask.new
|
||||||
|
|
||||||
task default: [:rubocop, :test, :conformance]
|
task default: [:rubocop, :test, "conformance:test"]
|
||||||
|
|
||||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
namespace :conformance do
|
||||||
task :conformance do |t|
|
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||||
|
task :test do |t|
|
||||||
next unless npx_available?(t.name)
|
next unless npx_available?(t.name)
|
||||||
|
|
||||||
require_relative "conformance/server_runner"
|
require_relative "conformance/server_runner"
|
||||||
|
|
@ -31,24 +32,67 @@ task :conformance do |t|
|
||||||
|
|
||||||
Conformance::ServerRunner.new(**options).run
|
Conformance::ServerRunner.new(**options).run
|
||||||
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
|
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "List available conformance scenarios"
|
desc "List available conformance scenarios"
|
||||||
task :conformance_list do |t|
|
task :list do |t|
|
||||||
next unless npx_available?(t.name)
|
next unless npx_available?(t.name)
|
||||||
|
|
||||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Start the conformance server (PORT)"
|
desc "Start the conformance server (PORT)"
|
||||||
task :conformance_server do
|
task :server do
|
||||||
require_relative "conformance/server"
|
require_relative "conformance/server"
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||||
|
|
||||||
Conformance::Server.new(**options).start
|
Conformance::Server.new(**options).start
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance`
|
||||||
|
# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without
|
||||||
|
# running anything. This keeps the name pointing at the suite instead of at that silent no-op.
|
||||||
|
desc "Alias for conformance:test"
|
||||||
|
task conformance: "conformance:test"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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?
|
||||||
|
|
||||||
|
mkdir_p(File.join(docs_dir, "_data"))
|
||||||
|
File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join)
|
||||||
end
|
end
|
||||||
|
|
||||||
def npx_available?(task_name)
|
def npx_available?(task_name)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
|
||||||
### Run all scenarios
|
### Run all scenarios
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle exec rake conformance
|
bundle exec rake conformance:test
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs both server and client conformance tests in sequence. Server conformance starts the
|
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
|
```bash
|
||||||
# Run a single server scenario
|
# 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
|
# 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
|
# 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
|
### Start the server and test separately
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: start the server
|
# Terminal 1: start the server
|
||||||
bundle exec rake conformance_server
|
bundle exec rake conformance:server
|
||||||
|
|
||||||
# Terminal 2: run all scenarios
|
# Terminal 2: run all scenarios
|
||||||
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
|
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
|
### List available scenarios
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle exec rake conformance_list
|
bundle exec rake conformance:list
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints all scenario names that can be passed to `SCENARIO`.
|
Prints all scenario names that can be passed to `SCENARIO`.
|
||||||
|
|
@ -76,7 +76,7 @@ repository with the conformance server running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 (this repository): start the conformance server
|
# 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
|
# 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
|
/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
|
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.
|
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"
|
title: "MCP Ruby SDK"
|
||||||
description: "The official Ruby SDK for Model Context Protocol servers and clients."
|
description: "The official Ruby SDK for Model Context Protocol servers and clients."
|
||||||
remote_theme: just-the-docs/just-the-docs
|
remote_theme: just-the-docs/just-the-docs
|
||||||
|
color_scheme: ruby-light
|
||||||
|
|
||||||
# Include generated files and directories which may start with underscores
|
plugins:
|
||||||
include:
|
- 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
|
||||||
search_enabled: true
|
search_enabled: true
|
||||||
|
|
||||||
# Footer
|
# Callouts
|
||||||
gh_edit_link: true
|
callouts:
|
||||||
gh_edit_link_text: "Edit this page on GitHub."
|
note:
|
||||||
gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk"
|
title: Note
|
||||||
gh_edit_branch: "main"
|
color: blue
|
||||||
gh_edit_source: "docs"
|
important:
|
||||||
gh_edit_view_mode: "edit"
|
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