Compare commits
99 changed files with 3801 additions and 9604 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:test
|
- run: bundle exec rake conformance
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,7 +9,3 @@
|
||||||
/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,9 +15,7 @@ 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 linting, tests, and the conformance suite (default task)
|
- `rake` - Run tests and linting (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
|
||||||
|
|
||||||
|
|
@ -36,14 +34,6 @@ 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,71 +7,6 @@ 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,29 +5,21 @@ It is a living document and will evolve as the SDK matures.
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
The SDK implements the 2025-06-18, 2025-11-25, and 2026-07-28 spec revisions, and passes all scored server
|
The SDK implements the 2025-06-18 and 2025-11-25 spec revisions, and passes all server and client conformance scenarios.
|
||||||
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 marked the public API as stable: breaking changes ship only in major releases,
|
The 1.0.0 release will mark the public API as stable: breaking changes then ship only in major releases,
|
||||||
apart from the narrow exceptions documented in [VERSIONING.md](VERSIONING.md),
|
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 supported throughout 1.x: on the legacy lifecycle they work unchanged
|
These features remain fully supported throughout 1.x, and deprecation warnings will be added in a future minor release.
|
||||||
(the deprecations do not apply there), and the 2026-07-28 lifecycle carries them through its own mechanisms:
|
Under Semantic Versioning their removal requires a major release, but no removal is scheduled yet:
|
||||||
roots-shaped and sampling-shaped input requests embedded in `InputRequiredResult` responses
|
whether 2.0.0 removes them depends on how future MCP spec revisions treat these features
|
||||||
(the SEP-2322 multi round-trip requests pattern) and per-request log delivery through the envelope's `logLevel`.
|
and on adoption of their replacements.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -36,16 +28,12 @@ Legacy SSE transport (2024-11-05) is intentionally out of scope; the SDK provide
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Reference documentation covers all non-experimental features with examples.
|
Reference documentation covers all core features.
|
||||||
|
|
||||||
## 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.
|
||||||
The 2026-07-28 revision shipped during 1.x: the server serves both lifecycle eras side by side,
|
Additive features from the 2026-07-28 revision (such as `server/discover`, multi round-trip requests,
|
||||||
so the SEP-2575 stateless lifecycle (including `server/discover` and `subscriptions/listen`),
|
and the tasks extension) ship as opt-in functionality during 1.x. Breaking parts of that revision,
|
||||||
SEP-2322 multi round-trip requests, SEP-2549 cache hints, and SEP-2243 custom headers are available alongside
|
such as the SEP-2575 stateless lifecycle rewrite, are reserved for 2.0.
|
||||||
the legacy handshake. The few incompatible adjustments the revision required shipped in
|
|
||||||
a minor release under the spec-conformance and security exceptions described in [VERSIONING.md](VERSIONING.md).
|
|
||||||
Removing legacy-era support is reserved for a future major release.
|
|
||||||
The tasks extension (SEP-2663) remains planned ([#391](https://github.com/modelcontextprotocol/ruby-sdk/issues/391)).
|
|
||||||
|
|
|
||||||
94
Rakefile
94
Rakefile
|
|
@ -14,85 +14,41 @@ require "rubocop/rake_task"
|
||||||
|
|
||||||
RuboCop::RakeTask.new
|
RuboCop::RakeTask.new
|
||||||
|
|
||||||
task default: [:rubocop, :test, "conformance:test"]
|
task default: [:rubocop, :test, :conformance]
|
||||||
|
|
||||||
namespace :conformance do
|
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
||||||
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
|
task :conformance do |t|
|
||||||
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"
|
||||||
require_relative "conformance/client_runner"
|
require_relative "conformance/client_runner"
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||||
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
|
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
|
||||||
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
|
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
|
||||||
options[:verbose] = true if ENV["VERBOSE"]
|
options[:verbose] = true if ENV["VERBOSE"]
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
desc "List available conformance scenarios"
|
|
||||||
task :list do |t|
|
|
||||||
next unless npx_available?(t.name)
|
|
||||||
|
|
||||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
|
||||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "Start the conformance server (PORT)"
|
|
||||||
task :server do
|
|
||||||
require_relative "conformance/server"
|
|
||||||
|
|
||||||
options = {}
|
|
||||||
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
|
||||||
|
|
||||||
Conformance::Server.new(**options).start
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# The other two former names now fail with a "Did you mean?" suggestion, but a bare `conformance`
|
desc "List available conformance scenarios"
|
||||||
# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without
|
task :conformance_list do |t|
|
||||||
# running anything. This keeps the name pointing at the suite instead of at that silent no-op.
|
next unless npx_available?(t.name)
|
||||||
desc "Alias for conformance:test"
|
|
||||||
task conformance: "conformance:test"
|
|
||||||
|
|
||||||
namespace :docs do
|
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||||
desc "Serve the documentation site locally at http://localhost:4000 (PORT)"
|
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
|
||||||
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
|
end
|
||||||
|
|
||||||
# Mirrors bin/generate-gh-pages.sh: the released site receives `_data/versions.yml` from
|
desc "Start the conformance server (PORT)"
|
||||||
# the version tags at deploy time, and the preview generates the same data so the nav footer
|
task :conformance_server do
|
||||||
# shows the released-gem version line.
|
require_relative "conformance/server"
|
||||||
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"))
|
options = {}
|
||||||
File.write(File.join(docs_dir, "_data", "versions.yml"), versions.map { |version| "- #{version}\n" }.join)
|
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
|
||||||
|
|
||||||
|
Conformance::Server.new(**options).start
|
||||||
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:test
|
bundle exec rake conformance
|
||||||
```
|
```
|
||||||
|
|
||||||
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:test SCENARIO=ping
|
bundle exec rake conformance SCENARIO=ping
|
||||||
|
|
||||||
# Use a different port with verbose output
|
# Use a different port with verbose output
|
||||||
bundle exec rake conformance:test PORT=3000 VERBOSE=1
|
bundle exec rake conformance PORT=3000 VERBOSE=1
|
||||||
|
|
||||||
# Start the server on a specific port
|
# 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:test`.
|
These are shown in the output of `bundle exec rake conformance`.
|
||||||
|
|
|
||||||
15
docs/Gemfile
15
docs/Gemfile
|
|
@ -1,15 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Dependencies for the local docs preview (`rake docs:preview`), kept out of the gem's own Gemfile:
|
|
||||||
# github-pages mirrors the GitHub Pages runtime that builds the released site
|
|
||||||
# (jekyll-remote-theme, jekyll-redirect-from, and the Jekyll version Pages actually runs).
|
|
||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
gem "github-pages", group: :jekyll_plugins
|
|
||||||
gem "webrick"
|
|
||||||
|
|
||||||
# Former default gems that the Jekyll version pinned by github-pages still requires on Ruby 4.0.
|
|
||||||
gem "base64"
|
|
||||||
gem "bigdecimal"
|
|
||||||
gem "csv"
|
|
||||||
gem "logger"
|
|
||||||
|
|
@ -1,405 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Authorization
|
|
||||||
nav_order: 8
|
|
||||||
---
|
|
||||||
|
|
||||||
# Authorization
|
|
||||||
|
|
||||||
Authorization is handled by the transport layer. This page covers authenticating the HTTP transport,
|
|
||||||
from custom headers such as bearer tokens to the OAuth 2.1 flows the SDK implements
|
|
||||||
(PKCE with dynamic client registration, the client credentials grant, and cross-app access).
|
|
||||||
|
|
||||||
## HTTP Authorization
|
|
||||||
|
|
||||||
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
http_transport = MCP::Client::HTTP.new(
|
|
||||||
url: "https://api.example.com/mcp",
|
|
||||||
headers: {
|
|
||||||
"Authorization" => "Bearer my_token"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
client = MCP::Client.new(transport: http_transport)
|
|
||||||
client.tools # will make the call using Bearer auth
|
|
||||||
```
|
|
||||||
|
|
||||||
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
|
|
||||||
|
|
||||||
## OAuth 2.1 Authorization
|
|
||||||
|
|
||||||
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization),
|
|
||||||
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
|
|
||||||
|
|
||||||
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
|
|
||||||
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
|
||||||
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
|
|
||||||
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
|
|
||||||
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
|
|
||||||
and when even that is absent the spec's default endpoints `/authorize`, `/token`, and `/register` at the origin are used with PKCE S256 assumed.
|
|
||||||
When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`,
|
|
||||||
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
|
|
||||||
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
|
||||||
`ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead;
|
|
||||||
their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again.
|
|
||||||
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
|
|
||||||
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
|
|
||||||
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
|
|
||||||
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
|
|
||||||
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
|
|
||||||
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
require "mcp"
|
|
||||||
|
|
||||||
provider = MCP::Client::OAuth::Provider.new(
|
|
||||||
client_metadata: {
|
|
||||||
client_name: "My MCP App",
|
|
||||||
redirect_uris: ["http://localhost:3030/callback"],
|
|
||||||
grant_types: ["authorization_code", "refresh_token"],
|
|
||||||
response_types: ["code"],
|
|
||||||
token_endpoint_auth_method: "none",
|
|
||||||
},
|
|
||||||
redirect_uri: "http://localhost:3030/callback",
|
|
||||||
redirect_handler: ->(authorization_url) {
|
|
||||||
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
|
|
||||||
# or a manual `puts authorization_url` in CLI tools.
|
|
||||||
},
|
|
||||||
callback_handler: -> {
|
|
||||||
# Capture the redirect (for example, by running a small HTTP listener on
|
|
||||||
# `redirect_uri`) and return [code, state] from the query string.
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
transport = MCP::Client::HTTP.new(
|
|
||||||
url: "https://api.example.com/mcp",
|
|
||||||
oauth: provider,
|
|
||||||
)
|
|
||||||
client = MCP::Client.new(transport: transport)
|
|
||||||
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
|
|
||||||
client.tools
|
|
||||||
```
|
|
||||||
|
|
||||||
Required keyword arguments to `Provider.new`:
|
|
||||||
|
|
||||||
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
|
|
||||||
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
|
|
||||||
When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
|
|
||||||
an explicit value always wins.
|
|
||||||
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
|
|
||||||
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
|
|
||||||
- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form
|
|
||||||
(with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match
|
|
||||||
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
|
|
||||||
|
|
||||||
Optional keyword arguments:
|
|
||||||
|
|
||||||
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
|
||||||
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
|
|
||||||
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
|
|
||||||
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.
|
|
||||||
See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client).
|
|
||||||
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
|
|
||||||
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
|
|
||||||
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
|
|
||||||
(portable CIMD `client_id`s are kept). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what
|
|
||||||
lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members
|
|
||||||
instead drops these bindings with no error.
|
|
||||||
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
|
|
||||||
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
|
|
||||||
When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
||||||
the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
|
|
||||||
Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
|
|
||||||
userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
|
|
||||||
them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
|
|
||||||
Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
|
|
||||||
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
|
|
||||||
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
|
|
||||||
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
||||||
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
|
|
||||||
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
|
|
||||||
Defaults to `nil`, which adds nothing. The authorization request is not affected.
|
|
||||||
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
|
|
||||||
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
|
|
||||||
when the provider is built, and the accepted Hash is copied and frozen.
|
|
||||||
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
|
|
||||||
the flow checks the returned value by the same rules and raises the same error before the token request is sent.
|
|
||||||
|
|
||||||
{: .warning }
|
|
||||||
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
|
|
||||||
> while remaining available for authorization servers that do not support them. Publish a CIMD document and set `client_id_metadata_document_url`; the SDK then prefers it
|
|
||||||
> automatically wherever the authorization server advertises support.
|
|
||||||
|
|
||||||
To persist credentials across restarts, supply your own storage:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
class FileTokenStorage
|
|
||||||
def initialize(path)
|
|
||||||
@path = path
|
|
||||||
end
|
|
||||||
|
|
||||||
def tokens
|
|
||||||
read["tokens"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_tokens(value)
|
|
||||||
write("tokens" => value)
|
|
||||||
end
|
|
||||||
|
|
||||||
def client_information
|
|
||||||
read["client"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_client_information(value)
|
|
||||||
write("client" => value)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def read
|
|
||||||
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def write(updates)
|
|
||||||
File.write(@path, JSON.dump(read.merge(updates)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
provider = MCP::Client::OAuth::Provider.new(
|
|
||||||
# ... required keywords ...
|
|
||||||
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token Endpoint Errors
|
|
||||||
|
|
||||||
When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and
|
|
||||||
the authorization server's `error` and `error_description` from [RFC 6749 Section 5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2).
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods
|
|
||||||
```
|
|
||||||
|
|
||||||
The exception exposes `http_status`, `error`, and `error_description` readers for structured diagnostics. Missing or non-string
|
|
||||||
diagnostic fields are `nil`; non-JSON responses retain the status-only message. Other authorization failures have `nil` readers.
|
|
||||||
An `invalid_grant` response still raises `Flow::InvalidGrantError`, a subclass of `Flow::AuthorizationError`, so refresh-token
|
|
||||||
recovery behavior is unchanged.
|
|
||||||
|
|
||||||
Diagnostic fields are limited to 128 characters for `error` and 512 for `error_description`, including a trailing `...` when
|
|
||||||
truncated. Characters outside the RFC's printable ASCII set are replaced with spaces, and surrounding whitespace is removed.
|
|
||||||
The SDK excludes all other response fields, including `error_uri`, and does not include the raw response body in these errors.
|
|
||||||
Descriptions are provider-controlled text, not guaranteed to be free of sensitive information; apply your application's logging
|
|
||||||
and redaction policy before persisting them or displaying them to users.
|
|
||||||
|
|
||||||
### Client Credentials Grant
|
|
||||||
|
|
||||||
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
|
|
||||||
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
|
|
||||||
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant is not expected to issue a refresh token (RFC 6749 Section 4.4.3);
|
|
||||||
a refresh token the authorization server issues anyway is used on the next `401`.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
|
||||||
client_id: "my-service",
|
|
||||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
|
||||||
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
|
|
||||||
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
|
|
||||||
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
|
|
||||||
)
|
|
||||||
|
|
||||||
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
|
||||||
```
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
|
|
||||||
- `client_id`: Required. `client_secret`: Required with the secret-based methods; the grant is
|
|
||||||
for confidential clients, so a credential is mandatory.
|
|
||||||
- `token_endpoint_auth_method`: `"client_secret_basic"` (default), `"client_secret_post"`,
|
|
||||||
or `"private_key_jwt"` (RFC 7523 JWT client assertion per SEP-1046). `"none"` is rejected
|
|
||||||
with `ClientCredentialsProvider::InvalidCredentialsError`.
|
|
||||||
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
|
|
||||||
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
|
|
||||||
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
|
|
||||||
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
|
|
||||||
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
|
|
||||||
|
|
||||||
### Cross-App Access (JWT Bearer) Grant
|
|
||||||
|
|
||||||
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
|
|
||||||
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
|
|
||||||
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
|
|
||||||
A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again.
|
|
||||||
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
|
|
||||||
|
|
||||||
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
|
|
||||||
an enterprise secret store or a test double without changing the transport wiring.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
|
|
||||||
client_id: "my-mcp-client",
|
|
||||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
|
||||||
assertion_provider: ->(audience:, resource:) {
|
|
||||||
MCP::Client::OAuth::IDJAGTokenExchange.request(
|
|
||||||
token_endpoint: "https://idp.example.com/token",
|
|
||||||
id_token: ENV.fetch("IDP_ID_TOKEN"),
|
|
||||||
client_id: "my-idp-client",
|
|
||||||
audience: audience,
|
|
||||||
resource: resource,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
# scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one)
|
|
||||||
)
|
|
||||||
|
|
||||||
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
|
||||||
```
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
|
|
||||||
- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server.
|
|
||||||
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
|
||||||
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
|
|
||||||
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
|
||||||
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
|
|
||||||
|
|
||||||
### Customizing the OAuth HTTP Client
|
|
||||||
|
|
||||||
The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery,
|
|
||||||
dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own,
|
|
||||||
not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server.
|
|
||||||
To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
|
||||||
client_id: "my-service",
|
|
||||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
|
||||||
http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder },
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL,
|
|
||||||
and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection.
|
|
||||||
It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently;
|
|
||||||
today it runs once per authorization attempt, but that is not a promise.
|
|
||||||
A few constraints follow from the checks described below:
|
|
||||||
|
|
||||||
- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send
|
|
||||||
to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError`
|
|
||||||
before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)`
|
|
||||||
that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off.
|
|
||||||
- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off.
|
|
||||||
- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request
|
|
||||||
and to turn a token endpoint error into `Flow::InvalidGrantError`.
|
|
||||||
- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives.
|
|
||||||
- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens,
|
|
||||||
and the access tokens in token responses; redact them before they reach a log.
|
|
||||||
|
|
||||||
### Communication Security
|
|
||||||
|
|
||||||
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
|
||||||
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
|
|
||||||
|
|
||||||
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
|
|
||||||
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
|
|
||||||
whether the swap was triggered by
|
|
||||||
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
|
|
||||||
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
|
|
||||||
|
|
||||||
### Discovery URL Destinations
|
|
||||||
|
|
||||||
The scheme rules above say how a URL is contacted, not where it points. Discovery URLs arrive from the network, so the SDK also constrains their destinations.
|
|
||||||
Both checks run before the request is sent, and neither is configurable.
|
|
||||||
|
|
||||||
- The `resource_metadata` URL in a `WWW-Authenticate` challenge must be on the MCP server's own origin. Protected Resource Metadata describes that server,
|
|
||||||
so a real deployment publishes it there; requiring it means a `401` cannot aim the first request of the flow at an unrelated host. This is stricter than RFC 9728,
|
|
||||||
which does not require it.
|
|
||||||
- The PRM `authorization_servers` entry and the `authorization_endpoint`, `token_endpoint`, and `registration_endpoint` from Authorization Server metadata must not be
|
|
||||||
IP literals in a private, loopback, link-local, or unique-local range, per the SSRF precaution in [RFC 9728 Section 7.7](https://www.rfc-editor.org/rfc/rfc9728#section-7.7).
|
|
||||||
The blocked ranges are `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `::/96`, `fc00::/7`,
|
|
||||||
and `fe80::/10`, along with the IPv4-mapped IPv6 spellings of each and the `localhost` name.
|
|
||||||
- That range check is skipped when the MCP server URL you configured is itself on such an address. Pointing the client at a private network is a deliberate act,
|
|
||||||
and the authorization server for it usually lives on the same network, so `http://localhost` development and deployments that never leave a corporate network keep working.
|
|
||||||
|
|
||||||
The range check compares IP literals and does not resolve hostnames, so it cannot recognize an internal service that is named rather than addressed,
|
|
||||||
such as `https://vault.corp.internal/`. Resolving names here would not close that gap either, because the address the SDK looked up need not be the one
|
|
||||||
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
|
|
||||||
|
|
||||||
On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request
|
|
||||||
goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)`
|
|
||||||
replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against
|
|
||||||
the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused.
|
|
||||||
A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself.
|
|
||||||
|
|
||||||
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
|
|
||||||
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.
|
|
||||||
Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through
|
|
||||||
`http_client_factory:` is bounded as well, so there is no way to opt out of it.
|
|
||||||
|
|
||||||
### Reviewing the authorization request
|
|
||||||
|
|
||||||
The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want
|
|
||||||
your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes
|
|
||||||
it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects,
|
|
||||||
so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on
|
|
||||||
MCP servers rather than on clients.
|
|
||||||
|
|
||||||
`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"]
|
|
||||||
|
|
||||||
provider = MCP::Client::OAuth::Provider.new(
|
|
||||||
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
|
|
||||||
authorization_request_validator: ->(request) {
|
|
||||||
ALLOWED_ISSUERS.include?(request.authorization_server)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither
|
|
||||||
the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification
|
|
||||||
can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide
|
|
||||||
(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes.
|
|
||||||
|
|
||||||
`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized
|
|
||||||
(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one,
|
|
||||||
the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce
|
|
||||||
a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured.
|
|
||||||
|
|
||||||
Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path,
|
|
||||||
and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising.
|
|
||||||
|
|
||||||
The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has
|
|
||||||
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] }
|
|
||||||
|
|
||||||
provider = MCP::Client::OAuth::Provider.new(
|
|
||||||
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
|
|
||||||
authorization_request_validator: ->(request) {
|
|
||||||
ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them.
|
|
||||||
|
|
||||||
It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential,
|
|
||||||
or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened,
|
|
||||||
and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server
|
|
||||||
the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected.
|
|
||||||
Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it,
|
|
||||||
while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure.
|
|
||||||
|
|
||||||
The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification
|
|
||||||
requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for,
|
|
||||||
which is what every MCP SDK does today.
|
|
||||||
|
|
||||||
It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for.
|
|
||||||
An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to
|
|
||||||
present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is.
|
|
||||||
The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse.
|
|
||||||
Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization.
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Cancellation
|
|
||||||
nav_order: 5
|
|
||||||
---
|
|
||||||
|
|
||||||
# Cancellation
|
|
||||||
|
|
||||||
`MCP::Client` lets the caller cancel a request it has already issued,
|
|
||||||
per the [MCP `notifications/cancelled` utility](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation).
|
|
||||||
The recommended pattern is to pass
|
|
||||||
an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
|
|
||||||
`cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
|
|
||||||
the server, and the calling thread is woken up with `MCP::CancelledError`:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client = MCP::Client.new(transport: transport)
|
|
||||||
cancellation = MCP::Cancellation.new
|
|
||||||
|
|
||||||
Thread.new do
|
|
||||||
client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
|
|
||||||
rescue MCP::CancelledError
|
|
||||||
# cleanup
|
|
||||||
end
|
|
||||||
|
|
||||||
# Later, from another thread:
|
|
||||||
cancellation.cancel(reason: "user pressed cancel")
|
|
||||||
```
|
|
||||||
|
|
||||||
All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
|
|
||||||
`prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `discover`, `ping`) accept the `cancellation:` keyword.
|
|
||||||
Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
|
|
||||||
|
|
||||||
{: .note }
|
|
||||||
> When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
|
|
||||||
> it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
|
|
||||||
> `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
|
|
||||||
> the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
|
|
||||||
> to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
|
|
||||||
> (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
|
|
||||||
> least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
|
|
||||||
|
|
||||||
{: .note }
|
|
||||||
> On a [modern](/client/lifecycle/) connection the cancel notification cannot reach the in-flight
|
|
||||||
> request: correlating the two is a session mechanic of the handshake lifecycle, and modern requests
|
|
||||||
> are sessionless single POST exchanges. The local effect is unchanged - the calling thread still
|
|
||||||
> raises `MCP::CancelledError` - but the server runs the request to completion.
|
|
||||||
|
|
||||||
## Wire-order guarantees
|
|
||||||
|
|
||||||
`Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
|
|
||||||
so the server is guaranteed to read the request line before the cancel line.
|
|
||||||
|
|
||||||
`Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
|
|
||||||
so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
|
|
||||||
on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
|
|
||||||
still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/latest/basic/patterns/cancellation)),
|
|
||||||
and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
|
|
||||||
happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
|
|
||||||
|
|
||||||
## Custom transports
|
|
||||||
|
|
||||||
Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
|
|
||||||
They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
|
|
||||||
(under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
|
|
||||||
The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
|
|
||||||
the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
|
|
||||||
|
|
||||||
## Server Side
|
|
||||||
|
|
||||||
How servers observe cancellation in their handlers is documented on the server [Cancellation](/server/cancellation/) page.
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Overview
|
|
||||||
nav_order: 1
|
|
||||||
permalink: /client/
|
|
||||||
redirect_from:
|
|
||||||
- /building-clients.html
|
|
||||||
- /building-clients/
|
|
||||||
---
|
|
||||||
|
|
||||||
# Building an MCP Client
|
|
||||||
|
|
||||||
The `MCP::Client` class provides an interface for interacting with MCP servers.
|
|
||||||
|
|
||||||
This class supports:
|
|
||||||
|
|
||||||
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
|
|
||||||
when the server serves it; see [Lifecycle](/client/lifecycle/)
|
|
||||||
- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery)
|
|
||||||
- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings;
|
|
||||||
see [Answering Server Pings](/client/ping/#answering-server-pings)
|
|
||||||
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
|
|
||||||
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
|
|
||||||
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
|
||||||
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
|
|
||||||
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
|
|
||||||
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
|
|
||||||
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
|
|
||||||
- Completion requests via the `completion/complete` method (`MCP::Client#complete`); see [Completion](/server/completion/)
|
|
||||||
- Automatic driving of multi round-trip `input_required` results once `on_elicitation`, `on_sampling`,
|
|
||||||
or `on_roots` handlers are registered; see [Multi Round-Trip Requests](/client/mrtr/)
|
|
||||||
- Cancellation of in-flight requests via the `cancellation:` keyword; see [Cancellation](/client/cancellation/)
|
|
||||||
- Cursor-based page iteration on the `list_*` methods and whole-collection fetching with
|
|
||||||
the `max_pages` guard; see [Pagination](/client/pagination/)
|
|
||||||
- Automatic JSON-RPC 2.0 message formatting
|
|
||||||
- UUID request ID generation
|
|
||||||
|
|
||||||
Clients are initialized with a [transport layer](/client/transports/) instance that handles the low-level communication mechanics.
|
|
||||||
Authorization is handled by the transport layer; see [Authorization](/client/authorization/).
|
|
||||||
|
|
||||||
## Tool Objects
|
|
||||||
|
|
||||||
The client provides a wrapper class for tools returned by the server:
|
|
||||||
|
|
||||||
- `MCP::Client::Tool` - Represents a single tool with its metadata
|
|
||||||
|
|
||||||
This class provides easy access to tool properties like name, description, input schema, and output schema.
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Lifecycle
|
|
||||||
nav_order: 3
|
|
||||||
---
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
|
|
||||||
Before sending requests, a client establishes its lifecycle with the server: the classic `initialize` handshake
|
|
||||||
on legacy protocol versions, or the sessionless modern lifecycle of MCP 2026-07-28.
|
|
||||||
This page covers `MCP::Client#connect` and how it negotiates between the two.
|
|
||||||
|
|
||||||
## Handshake
|
|
||||||
|
|
||||||
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.connect
|
|
||||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
|
||||||
|
|
||||||
client.connected? # => true
|
|
||||||
client.server_info # => cached InitializeResult
|
|
||||||
```
|
|
||||||
|
|
||||||
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
|
||||||
|
|
||||||
This applies to both the Stdio and HTTP transports described on the [Transports](/client/transports/) page.
|
|
||||||
|
|
||||||
By default `connect` [negotiates the lifecycle](#lifecycle-negotiation) first and performs this handshake
|
|
||||||
only when the server does not serve the modern lifecycle, or when `mode: :legacy` or a legacy `protocol_version:` forces it.
|
|
||||||
|
|
||||||
## Lifecycle Negotiation
|
|
||||||
|
|
||||||
`MCP::Client#connect` selects the protocol lifecycle automatically by default: on the bundled
|
|
||||||
`MCP::Client::HTTP` and `MCP::Client::Stdio` transports it probes [`server/discover`](/server/discover/) first and adopts
|
|
||||||
the stateless modern lifecycle (MCP 2026-07-28, SEP-2575) when the server serves it, falling back to
|
|
||||||
the classic `initialize` handshake otherwise. Custom transports whose `connect` does not declare
|
|
||||||
a `mode:` keyword always receive the classic call shape, unchanged.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.connect # negotiate automatically (default)
|
|
||||||
client.connect(mode: :modern) # require the modern lifecycle; fails on legacy-only servers
|
|
||||||
client.connect(mode: :legacy) # force the classic initialize handshake
|
|
||||||
client.connect(protocol_version: "2025-11-25") # an explicit legacy version pins the handshake, no probe
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer `mode: :legacy` for spawn-per-invocation CLI tools (the probe adds a round trip per process)
|
|
||||||
and when using server-initiated requests (`on_elicitation` / `on_sampling`), which exist only on
|
|
||||||
the legacy lifecycle.
|
|
||||||
|
|
||||||
Because the raw `connect` return value and `MCP::Client#server_info` mirror the wire result,
|
|
||||||
their shape depends on the negotiated lifecycle: `InitializeResult` (`protocolVersion`,
|
|
||||||
top-level `serverInfo`) on legacy, `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`)
|
|
||||||
on modern. Code that should work against both lifecycles can use the era-independent readers instead:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.protocol_version # negotiated or adopted version, either lifecycle
|
|
||||||
client.server_capabilities # capabilities Hash, either lifecycle
|
|
||||||
client.instructions # instructions text, either lifecycle
|
|
||||||
client.server_implementation # server name/version; nil when a modern server does not identify itself
|
|
||||||
```
|
|
||||||
|
|
||||||
Troubleshooting: if `server_info["protocolVersion"]` starts returning `nil` after a server you connect to was upgraded,
|
|
||||||
the server now serves the modern lifecycle and the automatic negotiation adopted it.
|
|
||||||
Pass `mode: :legacy` for an immediate return to the previous behavior, or switch to the readers above for a permanent fix.
|
|
||||||
|
|
||||||
## Explicit Discovery
|
|
||||||
|
|
||||||
`MCP::Client#discover` sends `server/discover` directly: sessionless capability discovery
|
|
||||||
that works before (or instead of) `connect`. It returns an `MCP::Client::DiscoverResult` struct
|
|
||||||
exposing `supported_versions`, `capabilities`, `server_info`, `instructions`, and
|
|
||||||
the `ttl_ms` / `cache_scope` cache hints; see the server [Discovery](/server/discover/) page
|
|
||||||
for the wire shapes.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
result = client.discover
|
|
||||||
result.supported_versions # => ["2026-07-28"]
|
|
||||||
```
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Multi Round-Trip Requests
|
|
||||||
nav_order: 4
|
|
||||||
redirect_from:
|
|
||||||
- /client/multi-round-trip-results/
|
|
||||||
---
|
|
||||||
|
|
||||||
# Multi Round-Trip Requests
|
|
||||||
|
|
||||||
MCP 2026-07-28 replaces in-flight server-to-client requests with Multi Round-Trip Requests (SEP-2322): instead of issuing `sampling/createMessage`, `roots/list`,
|
|
||||||
or `elicitation/create` while a request is being processed, a server may answer with a result whose `resultType` is `"input_required"`, carrying an `inputRequests` map
|
|
||||||
and an opaque `requestState`; the client fulfills the requests and re-issues the original request with `inputResponses` and the echoed `requestState`.
|
|
||||||
|
|
||||||
## Automatic Driving
|
|
||||||
|
|
||||||
The Ruby client drives such results automatically: once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, the `call_tool`, `get_prompt`,
|
|
||||||
and `read_resource` methods fulfill the embedded requests and re-issue the original request with `inputResponses` plus the echoed `requestState`, capped at `input_required_max_rounds`
|
|
||||||
(10 by default, matching the TypeScript and Python SDKs).
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client = MCP::Client.new(transport: transport)
|
|
||||||
client.connect(capabilities: { elicitation: { form: {} } })
|
|
||||||
|
|
||||||
client.on_elicitation do |params|
|
|
||||||
{ action: "accept", content: { name: "Alice" } }
|
|
||||||
end
|
|
||||||
|
|
||||||
# The input_required round trips are driven automatically; this returns the final result.
|
|
||||||
response = client.call_tool(name: "collect_name", arguments: {})
|
|
||||||
```
|
|
||||||
|
|
||||||
Declare the capabilities matching the registered handlers on `connect`: a server embeds only the request kinds
|
|
||||||
the client declared.
|
|
||||||
|
|
||||||
## Manual Driving
|
|
||||||
|
|
||||||
Without a matching handler, `MCP::Client::InputRequiredError` is raised instead of returning the result as if it were final;
|
|
||||||
the error exposes `input_requests`, `request_state`, and the raw `result` for manual driving via the `input_responses:` and `request_state:` keywords:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
begin
|
|
||||||
client.call_tool(name: "collect_name", arguments: {})
|
|
||||||
rescue MCP::Client::InputRequiredError => error
|
|
||||||
answers = error.input_requests.transform_values { |request| answer_for(request) }
|
|
||||||
|
|
||||||
client.call_tool(
|
|
||||||
name: "collect_name",
|
|
||||||
arguments: {},
|
|
||||||
input_responses: answers,
|
|
||||||
request_state: error.request_state,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
`MCP::ResultType::COMPLETE` and `MCP::ResultType::INPUT_REQUIRED` are provided for forward compatibility.
|
|
||||||
Servers on legacy protocol versions never send `resultType`, so existing behavior is unchanged.
|
|
||||||
|
|
||||||
## Server Side
|
|
||||||
|
|
||||||
Authoring `input_required` results with `InputRequiredResult`, securing `requestState`, `resultType` stamping, and the legacy fulfillment
|
|
||||||
shim that serves pre-2026 clients are documented on the server [Multi Round-Trip Requests](/server/mrtr/) page.
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Pagination
|
|
||||||
nav_order: 7
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pagination
|
|
||||||
|
|
||||||
Servers may paginate `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` responses
|
|
||||||
per the [MCP pagination utility](https://modelcontextprotocol.io/specification/latest/server/utilities/pagination).
|
|
||||||
Cursor tokens are opaque to clients: the server decides page size, and the client follows `nextCursor` until the server omits it.
|
|
||||||
|
|
||||||
## Iterating Pages
|
|
||||||
|
|
||||||
`MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
|
||||||
**Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** - not the full collection.
|
|
||||||
The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor
|
|
||||||
as method accessors; a `meta` accessor also mirrors the response's `_meta` field:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client = MCP::Client.new(transport: transport)
|
|
||||||
|
|
||||||
cursor = nil
|
|
||||||
loop do
|
|
||||||
page = client.list_tools(cursor: cursor)
|
|
||||||
page.tools.each { |tool| process(tool) }
|
|
||||||
cursor = page.next_cursor
|
|
||||||
break unless cursor
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and
|
|
||||||
`list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page.
|
|
||||||
|
|
||||||
Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration:
|
|
||||||
|
|
||||||
| Server `page_size` | `client.list_tools(cursor: nil)` |
|
|
||||||
|--------------------|---------------------------------------------------------------------|
|
|
||||||
| Not set (default) | Returns every item in one response. `next_cursor` is `nil`. |
|
|
||||||
| Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. |
|
|
||||||
|
|
||||||
If your application needs the complete collection regardless of how the server is configured, either loop on
|
|
||||||
`next_cursor` as shown above, or use the whole-collection methods described below.
|
|
||||||
|
|
||||||
## Fetching the Complete Collection
|
|
||||||
|
|
||||||
`client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate
|
|
||||||
through all pages and return a plain array of items, guaranteeing the full collection regardless
|
|
||||||
of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round
|
|
||||||
trips per call. Two guards keep that loop finite: it stops when the server returns a `nextCursor`
|
|
||||||
it has already sent, and it stops after `max_pages` pages.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
tools = client.tools # => Array<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.
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Ping
|
|
||||||
nav_order: 6
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ping
|
|
||||||
|
|
||||||
The [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping)
|
|
||||||
allows either side of the connection to verify that the peer is still responsive.
|
|
||||||
|
|
||||||
{: .note }
|
|
||||||
> MCP 2026-07-28 removes `ping` from the protocol (SEP-2575): requests of
|
|
||||||
> the [modern lifecycle](/client/lifecycle/) are single POST exchanges whose connection itself
|
|
||||||
> signals liveness. `#ping` sends the request regardless of the negotiated lifecycle,
|
|
||||||
> so on a modern connection a conforming server (the Ruby SDK server included) rejects it
|
|
||||||
> with `-32601` Method not found and `ServerError` is raised; ping is a handshake-lifecycle utility.
|
|
||||||
|
|
||||||
`MCP::Client` exposes `ping` to send a ping to the server:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client = MCP::Client.new(transport: transport)
|
|
||||||
client.ping # => {} on success
|
|
||||||
```
|
|
||||||
|
|
||||||
`#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error.
|
|
||||||
It raises `MCP::Client::ValidationError` when the response `result` is missing or
|
|
||||||
is not a Hash (matching the spec requirement that `result` be an object).
|
|
||||||
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
|
|
||||||
propagate as exceptions raised by the transport layer.
|
|
||||||
|
|
||||||
## Answering Server Pings
|
|
||||||
|
|
||||||
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with
|
|
||||||
the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open
|
|
||||||
(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings.
|
|
||||||
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when
|
|
||||||
the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is
|
|
||||||
dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:`
|
|
||||||
on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits.
|
|
||||||
|
|
||||||
To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces
|
|
||||||
the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`,
|
|
||||||
as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page.
|
|
||||||
|
|
||||||
## Server Side
|
|
||||||
|
|
||||||
How servers answer `ping` requests and ping the client themselves is documented on
|
|
||||||
the server [Ping](/server/ping/) page.
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Transports
|
|
||||||
nav_order: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
# Transports
|
|
||||||
|
|
||||||
`MCP::Client` is transport-agnostic: it sends every request through the transport instance it is built with.
|
|
||||||
This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports,
|
|
||||||
their sessions and server-to-client request handling, and the interface a custom transport must implement.
|
|
||||||
|
|
||||||
## Stdio Transport Layer
|
|
||||||
|
|
||||||
Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output.
|
|
||||||
|
|
||||||
`MCP::Client::Stdio.new` accepts the following keyword arguments:
|
|
||||||
|
|
||||||
| Parameter | Required | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `command:` | Yes | The command to spawn the server process (e.g., `"ruby"`, `"bundle"`, `"npx"`). |
|
|
||||||
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
|
||||||
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
|
|
||||||
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
|
|
||||||
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
stdio_transport = MCP::Client::Stdio.new(
|
|
||||||
command: "bundle",
|
|
||||||
args: ["exec", "ruby", "path/to/server.rb"],
|
|
||||||
env: { "API_KEY" => "my_secret_key" },
|
|
||||||
read_timeout: 30
|
|
||||||
)
|
|
||||||
client = MCP::Client.new(transport: stdio_transport)
|
|
||||||
|
|
||||||
# Perform the MCP initialization handshake before sending any requests.
|
|
||||||
client.connect
|
|
||||||
|
|
||||||
# List available tools.
|
|
||||||
tools = client.tools
|
|
||||||
tools.each do |tool|
|
|
||||||
puts "Tool: #{tool.name} - #{tool.description}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Call a specific tool.
|
|
||||||
response = client.call_tool(
|
|
||||||
tool: tools.first,
|
|
||||||
arguments: { message: "Hello, world!" }
|
|
||||||
)
|
|
||||||
|
|
||||||
# Close the transport when done.
|
|
||||||
stdio_transport.close
|
|
||||||
```
|
|
||||||
|
|
||||||
The stdio transport automatically handles:
|
|
||||||
|
|
||||||
- Spawning the server process with `Open3.popen3`
|
|
||||||
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
|
|
||||||
- JSON-RPC 2.0 message framing over newline-delimited JSON
|
|
||||||
- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings)
|
|
||||||
|
|
||||||
{: .note }
|
|
||||||
> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create`
|
|
||||||
> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer
|
|
||||||
> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route,
|
|
||||||
> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation`
|
|
||||||
> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/).
|
|
||||||
|
|
||||||
## HTTP Transport Layer
|
|
||||||
|
|
||||||
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
|
|
||||||
|
|
||||||
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser`
|
|
||||||
to read SSE (`text/event-stream`) responses.
|
|
||||||
Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both,
|
|
||||||
so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always
|
|
||||||
does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well.
|
|
||||||
`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to
|
|
||||||
answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode)
|
|
||||||
serving handshake-lifecycle clients, and only while no listening stream is opened:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
gem "mcp"
|
|
||||||
gem "faraday", ">= 2.0"
|
|
||||||
gem "event_stream_parser", ">= 1.0" # optional, required only for SSE responses
|
|
||||||
```
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
|
|
||||||
client = MCP::Client.new(transport: http_transport)
|
|
||||||
|
|
||||||
# Perform the MCP initialization handshake before sending any requests.
|
|
||||||
client.connect
|
|
||||||
|
|
||||||
# List available tools
|
|
||||||
tools = client.tools
|
|
||||||
tools.each do |tool|
|
|
||||||
puts <<~TOOL_INFORMATION
|
|
||||||
Tool: #{tool.name}
|
|
||||||
Description: #{tool.description}
|
|
||||||
Input Schema: #{tool.input_schema}
|
|
||||||
TOOL_INFORMATION
|
|
||||||
end
|
|
||||||
|
|
||||||
# Call a specific tool
|
|
||||||
response = client.call_tool(
|
|
||||||
tool: tools.first,
|
|
||||||
arguments: { message: "Hello, world!" }
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call a tool with progress tracking.
|
|
||||||
response = client.call_tool(
|
|
||||||
tool: tools.first,
|
|
||||||
arguments: { count: 10 },
|
|
||||||
progress_token: "my-progress-token"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The server sends `notifications/progress` during execution; the bundled transports do not currently
|
|
||||||
expose these notifications to application code, so the token's effect is visible on the server side.
|
|
||||||
See the [Progress](/server/progress/) page.
|
|
||||||
|
|
||||||
`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
|
|
||||||
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
|
|
||||||
a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
|
|
||||||
|
|
||||||
`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts,
|
|
||||||
and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread,
|
|
||||||
so without a budget a server answering with a large `retry:` parks a thread of your application for as long as it likes. It defaults to `300` (5 minutes).
|
|
||||||
The server's `retry:` is never shortened: when honoring it would run past the budget, the client stops trying to resume and raises instead,
|
|
||||||
the same thing it already does once the reconnection attempts are used up. A floor of 100ms applies to each wait, so a `retry: 0` cannot spin
|
|
||||||
the listening stream's reconnect loop; waiting longer than the server asked for is explicitly allowed by the SSE reconnection algorithm the spec points at.
|
|
||||||
|
|
||||||
### Sessions
|
|
||||||
|
|
||||||
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
http_transport.session_id # => "abc123..."
|
|
||||||
http_transport.protocol_version # => "2025-11-25"
|
|
||||||
```
|
|
||||||
|
|
||||||
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by calling `connect` again.
|
|
||||||
|
|
||||||
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors - 5xx, authentication failures, connection errors - propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
http_transport.close
|
|
||||||
```
|
|
||||||
|
|
||||||
These are handshake-lifecycle mechanics: on a [modern](/client/lifecycle/) connection there is
|
|
||||||
no session to capture - `session_id` stays `nil` and requests carry the `_meta` envelope instead.
|
|
||||||
|
|
||||||
### Server-to-Client Requests (Elicitation)
|
|
||||||
|
|
||||||
Servers can send requests back to the client while one of the client's own requests is in flight - for example,
|
|
||||||
[`elicitation/create`](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) to ask the user for additional input during a tool call.
|
|
||||||
Register a handler and advertise the capability on `connect` to respond to them:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.connect(capabilities: { elicitation: {} })
|
|
||||||
|
|
||||||
client.on_elicitation do |params|
|
|
||||||
{
|
|
||||||
action: "accept",
|
|
||||||
# Fill fields omitted by the user with the schema's `default` values (SEP-1034)
|
|
||||||
content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
|
|
||||||
}
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Registering a handler opens a standalone HTTP GET SSE stream on a background thread
|
|
||||||
([listening for messages from the server](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server)),
|
|
||||||
since servers deliver requests that are not tied to a client request on that stream. Server requests with no registered handler are answered with
|
|
||||||
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
|
|
||||||
`http_transport.on_server_request("method/name") { |params| ... }`.
|
|
||||||
|
|
||||||
On a [modern](/client/lifecycle/) connection servers cannot send requests at all; the same
|
|
||||||
registered handlers instead drive the requests embedded in `input_required` results,
|
|
||||||
as documented on [Multi Round-Trip Requests](/client/mrtr/).
|
|
||||||
|
|
||||||
### Server-to-Client Requests (Sampling)
|
|
||||||
|
|
||||||
Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
|
|
||||||
letting a server leverage the client's model access without its own API keys.
|
|
||||||
|
|
||||||
{: .warning }
|
|
||||||
> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
|
|
||||||
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
|
|
||||||
> new servers should call LLM provider APIs directly.
|
|
||||||
|
|
||||||
Register a handler and advertise the capability on `connect`:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.connect(capabilities: { sampling: {} })
|
|
||||||
|
|
||||||
client.on_sampling do |params|
|
|
||||||
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: { type: "text", text: completion.text },
|
|
||||||
model: completion.model,
|
|
||||||
stopReason: "endTurn",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
|
|
||||||
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.on_sampling do |params|
|
|
||||||
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
|
|
||||||
|
|
||||||
generate_completion(params)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
|
|
||||||
|
|
||||||
### Custom Headers from Tool Parameters
|
|
||||||
|
|
||||||
On a modern `MCP::Client::HTTP` connection, `tools/call` mirrors arguments whose `inputSchema` property carries
|
|
||||||
an `x-mcp-header` annotation into `Mcp-Param-{Name}` request headers per SEP-2243, so intermediaries can route
|
|
||||||
on the values without parsing bodies. The declarations are learned from `tools/list` responses:
|
|
||||||
list the tools before calling one to enable the mirroring. Values that cannot ride as plain ASCII header values
|
|
||||||
(non-ASCII, control characters, edge whitespace, empty strings) are wrapped as `=?base64?...?=`,
|
|
||||||
and a `null` or absent argument omits its header.
|
|
||||||
|
|
||||||
Per the specification, a tool definition whose `x-mcp-header` annotations are invalid (empty or non-token names,
|
|
||||||
duplicate names, non-primitive properties, annotations outside a chain of `properties` keys) is excluded from
|
|
||||||
`tools/list` results on modern connections, with a warning naming the tool.
|
|
||||||
Legacy connections are unaffected: nothing is learned, mirrored, or excluded.
|
|
||||||
|
|
||||||
### Customizing the Faraday Connection
|
|
||||||
|
|
||||||
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.
|
|
||||||
The block is called after the default middleware is configured, so you can add middleware or swap the HTTP adapter:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |faraday|
|
|
||||||
faraday.use MyApp::Middleware::HttpRecorder
|
|
||||||
faraday.adapter :typhoeus
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
|
|
||||||
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
|
|
||||||
|
|
||||||
{: .note }
|
|
||||||
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
|
|
||||||
> the SSE streaming callback of another response, re-entering the connection on the same thread.
|
|
||||||
> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter
|
|
||||||
> with persistent connections must tolerate that re-entry.
|
|
||||||
|
|
||||||
## Custom Transports
|
|
||||||
|
|
||||||
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
class CustomTransport
|
|
||||||
# Sends a JSON-RPC request to the server and returns the raw response.
|
|
||||||
#
|
|
||||||
# @param request [Hash] A complete JSON-RPC request object.
|
|
||||||
# https://www.jsonrpc.org/specification#request_object
|
|
||||||
# @return [Hash] A hash modeling a JSON-RPC response object.
|
|
||||||
# https://www.jsonrpc.org/specification#response_object
|
|
||||||
def send_request(request:)
|
|
||||||
# Your transport-specific logic here
|
|
||||||
# - HTTP: POST to endpoint with JSON body
|
|
||||||
# - WebSocket: Send message over WebSocket
|
|
||||||
# - stdio: Write to stdout, read from stdin
|
|
||||||
# - etc.
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
client = MCP::Client.new(transport: CustomTransport.new)
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom transports that need to support client-side cancellation have additional requirements;
|
|
||||||
see [Custom transports](/client/cancellation/#custom-transports) on the Cancellation page.
|
|
||||||
|
|
@ -2,44 +2,18 @@
|
||||||
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
|
|
||||||
|
|
||||||
plugins:
|
# Include generated files and directories which may start with underscores
|
||||||
- jekyll-redirect-from
|
include:
|
||||||
|
- "_*"
|
||||||
# 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
|
||||||
|
|
||||||
# Callouts
|
# Footer
|
||||||
callouts:
|
gh_edit_link: true
|
||||||
note:
|
gh_edit_link_text: "Edit this page on GitHub."
|
||||||
title: Note
|
gh_edit_repository: "https://github.com/modelcontextprotocol/ruby-sdk"
|
||||||
color: blue
|
gh_edit_branch: "main"
|
||||||
important:
|
gh_edit_source: "docs"
|
||||||
title: Important
|
gh_edit_view_mode: "edit"
|
||||||
color: yellow
|
|
||||||
warning:
|
|
||||||
title: Warning
|
|
||||||
color: red
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Capability Extensions
|
|
||||||
nav_order: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
# Capability Extensions
|
|
||||||
|
|
||||||
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
|
|
||||||
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
|
||||||
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
|
|
||||||
|
|
||||||
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
capabilities = MCP::Server::Capabilities.new
|
|
||||||
capabilities.support_tools
|
|
||||||
capabilities.support_extensions("com.example/feature" => { enabled: true })
|
|
||||||
|
|
||||||
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
|
|
||||||
```
|
|
||||||
|
|
||||||
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
|
|
||||||
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
|
|
||||||
|
|
||||||
On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), the same declarations appear in the `server/discover` result,
|
|
||||||
and the client's extensions ride each request's `_meta` envelope instead of an `initialize` handshake.
|
|
||||||
Inside a handler, `server_context.client_capabilities[:extensions]` reads the current request's declarations
|
|
||||||
with envelope-first resolution, on either lifecycle.
|
|
||||||
|
|
||||||
On the client, pass extensions through `connect`:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
|
|
||||||
```
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Overview
|
|
||||||
nav_order: 1
|
|
||||||
permalink: /extensions/
|
|
||||||
---
|
|
||||||
|
|
||||||
# Extensions
|
|
||||||
|
|
||||||
Extensions add optional functionality on top of the core MCP protocol.
|
|
||||||
Support for an extension is negotiated per connection:
|
|
||||||
clients and servers declare the extensions they speak under the `extensions` member of their capabilities,
|
|
||||||
as described on [Capability Extensions](/extensions/capability-extensions/).
|
|
||||||
|
|
||||||
The extensions this SDK ships support for:
|
|
||||||
|
|
||||||
- [MCP Apps](/extensions/mcp-apps/) (SEP-1865) - interactive HTML user interfaces rendered by the host for tool results
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue