Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538a364c9b |
||
|
|
66b2efa11e |
||
|
|
8173a38280 | ||
|
|
5f429f3fbf |
||
|
|
e1105c7094 |
||
|
|
5081cc5626 | ||
|
|
2c33ad061a |
||
|
|
9455c41b14 |
||
|
|
c9f381a3ee | ||
|
|
f51ccfbe66 | ||
|
|
f22ce86e97 |
||
|
|
29e8d3f2b9 | ||
|
|
47eb74aa7a | ||
|
|
8ad80dd0b4 | ||
|
|
41fbf05d26 |
||
|
|
99ef8732fe | ||
|
|
7d7a5669ef |
||
|
|
8d1d5be4ba | ||
|
|
a18c628787 |
||
|
|
ef3eb7b0b2 | ||
|
|
8e0ae73cc1 |
||
|
|
7e507f2963 | ||
|
|
251a40f780 |
||
|
|
2f65a6e502 | ||
|
|
91ee5a2123 |
||
|
|
4192ea58ea | ||
|
|
c6a5512381 |
||
|
|
3b44ec4f48 | ||
|
|
d0b22ef722 |
||
|
|
d40d9018d5 | ||
|
|
09c89aef14 | ||
|
|
44af58c7e0 |
||
|
|
0da0291887 | ||
|
|
8a24e50d1b |
||
|
|
33525d6494 | ||
|
|
f706cc8a5e |
||
|
|
6d272df0e2 |
||
|
|
eeab784fc8 |
||
|
|
09c310341b | ||
|
|
088f4b99e4 | ||
|
|
6b1704f330 |
||
|
|
6f0b85486b | ||
|
|
09d4bf41aa | ||
|
|
0cd1514bde |
||
|
|
920707cd7e | ||
|
|
11f9aacaa9 |
||
|
|
bfc6e551a3 |
46 changed files with 4221 additions and 295 deletions
2
.github/workflows/conformance.yml
vendored
2
.github/workflows/conformance.yml
vendored
|
|
@ -26,4 +26,4 @@ jobs:
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '24' # Specify the latest Node.js version.
|
node-version: '24' # Specify the latest Node.js version.
|
||||||
- run: bundle exec rake conformance
|
- run: bundle exec rake conformance:test
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
|
||||||
- `bundle install` - Install dependencies
|
- `bundle install` - Install dependencies
|
||||||
- `rake test` - Run all tests
|
- `rake test` - Run all tests
|
||||||
- `rake rubocop` - Run linter
|
- `rake rubocop` - Run linter
|
||||||
- `rake` - Run tests and linting (default task)
|
- `rake` - Run linting, tests, and the conformance suite (default task)
|
||||||
- `bundle exec rake conformance` - Run the MCP conformance suite (see conformance/README.md)
|
- `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)
|
- `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
|
||||||
|
|
|
||||||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -7,6 +7,37 @@ 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
|
## [1.4.0] - 2026-08-28
|
||||||
|
|
||||||
This release repairs the `subscriptions/listen` stream. A host that buffers its responses,
|
This release repairs the `subscriptions/listen` stream. A host that buffers its responses,
|
||||||
|
|
|
||||||
16
Rakefile
16
Rakefile
|
|
@ -14,10 +14,11 @@ require "rubocop/rake_task"
|
||||||
|
|
||||||
RuboCop::RakeTask.new
|
RuboCop::RakeTask.new
|
||||||
|
|
||||||
task default: [:rubocop, :test, :conformance]
|
task default: [:rubocop, :test, "conformance:test"]
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
@ -34,7 +35,7 @@ task :conformance do |t|
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "List available conformance scenarios"
|
desc "List available conformance scenarios"
|
||||||
task :conformance_list do |t|
|
task :list do |t|
|
||||||
next unless npx_available?(t.name)
|
next unless npx_available?(t.name)
|
||||||
|
|
||||||
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
|
||||||
|
|
@ -42,7 +43,7 @@ task :conformance_list do |t|
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Start the conformance server (PORT)"
|
desc "Start the conformance server (PORT)"
|
||||||
task :conformance_server do
|
task :server do
|
||||||
require_relative "conformance/server"
|
require_relative "conformance/server"
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
|
|
@ -50,6 +51,13 @@ task :conformance_server do
|
||||||
|
|
||||||
Conformance::Server.new(**options).start
|
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`
|
||||||
|
# would match the `conformance/` directory: Rake synthesizes a file task for it and exits 0 without
|
||||||
|
# running anything. This keeps the name pointing at the suite instead of at that silent no-op.
|
||||||
|
desc "Alias for conformance:test"
|
||||||
|
task conformance: "conformance:test"
|
||||||
|
|
||||||
namespace :docs do
|
namespace :docs do
|
||||||
desc "Serve the documentation site locally at http://localhost:4000 (PORT)"
|
desc "Serve the documentation site locally at http://localhost:4000 (PORT)"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
|
||||||
### Run all scenarios
|
### Run all scenarios
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle exec rake conformance
|
bundle exec rake conformance:test
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs both server and client conformance tests in sequence. Server conformance starts the
|
Runs both server and client conformance tests in sequence. Server conformance starts the
|
||||||
|
|
@ -31,20 +31,20 @@ are allowed to fail without affecting the exit code.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run a single server scenario
|
# Run a single server scenario
|
||||||
bundle exec rake conformance SCENARIO=ping
|
bundle exec rake conformance:test SCENARIO=ping
|
||||||
|
|
||||||
# Use a different port with verbose output
|
# Use a different port with verbose output
|
||||||
bundle exec rake conformance PORT=3000 VERBOSE=1
|
bundle exec rake conformance:test PORT=3000 VERBOSE=1
|
||||||
|
|
||||||
# Start the server on a specific port
|
# Start the server on a specific port
|
||||||
bundle exec rake conformance_server PORT=3000
|
bundle exec rake conformance:server PORT=3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Start the server and test separately
|
### Start the server and test separately
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: start the server
|
# Terminal 1: start the server
|
||||||
bundle exec rake conformance_server
|
bundle exec rake conformance:server
|
||||||
|
|
||||||
# Terminal 2: run all scenarios
|
# Terminal 2: run all scenarios
|
||||||
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
|
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
|
||||||
|
|
@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done.
|
||||||
### List available scenarios
|
### List available scenarios
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bundle exec rake conformance_list
|
bundle exec rake conformance:list
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints all scenario names that can be passed to `SCENARIO`.
|
Prints all scenario names that can be passed to `SCENARIO`.
|
||||||
|
|
@ -76,7 +76,7 @@ repository with the conformance server running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 (this repository): start the conformance server
|
# Terminal 1 (this repository): start the conformance server
|
||||||
bundle exec rake conformance_server
|
bundle exec rake conformance:server
|
||||||
|
|
||||||
# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
|
# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
|
||||||
/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
|
/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
|
||||||
|
|
@ -102,4 +102,4 @@ conformance/
|
||||||
|
|
||||||
Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
|
Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
|
||||||
fail without affecting the exit code and are tracked to catch regressions.
|
fail without affecting the exit code and are tracked to catch regressions.
|
||||||
These are shown in the output of `bundle exec rake conformance`.
|
These are shown in the output of `bundle exec rake conformance:test`.
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,13 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
|
||||||
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
- 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.
|
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
|
- 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` without the RFC 8414 issuer byte-match (which the legacy spec predates),
|
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.
|
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).
|
- 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),
|
- 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.
|
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.
|
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.
|
||||||
|
|
@ -93,10 +97,16 @@ Required keyword arguments to `Provider.new`:
|
||||||
Optional keyword arguments:
|
Optional keyword arguments:
|
||||||
|
|
||||||
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
- `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`,
|
- `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
|
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
|
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). Treat the hash as opaque and persist it as-is.
|
(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
|
- `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).
|
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
|
||||||
When the authorization server advertises `client_id_metadata_document_supported: true`,
|
When the authorization server advertises `client_id_metadata_document_supported: true`,
|
||||||
|
|
@ -108,6 +118,14 @@ Optional keyword arguments:
|
||||||
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
|
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
|
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`.
|
`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 }
|
{: .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,
|
> 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,
|
||||||
|
|
@ -155,11 +173,33 @@ provider = MCP::Client::OAuth::Provider.new(
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### Client Credentials Grant
|
||||||
|
|
||||||
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
|
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 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 does not issue a refresh token.
|
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
|
```ruby
|
||||||
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||||
|
|
@ -167,6 +207,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
||||||
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
|
# 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)
|
# 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)
|
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
||||||
|
|
@ -182,13 +223,15 @@ Keyword arguments:
|
||||||
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
|
- `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 `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.
|
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
|
||||||
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
- `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
|
### 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`.
|
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
|
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`.
|
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.
|
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
|
`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
|
||||||
|
|
@ -219,7 +262,39 @@ Keyword arguments:
|
||||||
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
- `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).
|
`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.
|
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
||||||
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
- `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
|
### Communication Security
|
||||||
|
|
||||||
|
|
@ -251,10 +326,80 @@ The range check compares IP literals and does not resolve hostnames, so it canno
|
||||||
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
|
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.
|
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.
|
||||||
|
|
||||||
If you replace the OAuth HTTP client through `MCP::Client::OAuth::Flow.new(http_client_factory:)`, do not add redirect-following middleware. Every check above runs against
|
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
|
||||||
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
|
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,
|
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.
|
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
|
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.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ This class supports:
|
||||||
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
|
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
|
||||||
when the server serves it; see [Lifecycle](/client/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)
|
- 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`)
|
- 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 listing via the `tools/list` method (`MCP::Client#tools`)
|
||||||
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
|
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
|
||||||
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,20 @@ 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)
|
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
|
||||||
propagate as exceptions raised by the transport layer.
|
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
|
## Server Side
|
||||||
|
|
||||||
How servers answer `ping` requests and ping the client themselves is documented on
|
How servers answer `ping` requests and ping the client themselves is documented on
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,27 @@ The stdio transport automatically handles:
|
||||||
- Spawning the server process with `Open3.popen3`
|
- Spawning the server process with `Open3.popen3`
|
||||||
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
|
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
|
||||||
- JSON-RPC 2.0 message framing over newline-delimited JSON
|
- 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
|
## HTTP Transport Layer
|
||||||
|
|
||||||
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
|
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. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses:
|
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
|
```ruby
|
||||||
gem "mcp"
|
gem "mcp"
|
||||||
|
|
@ -233,6 +248,15 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
|
||||||
end
|
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
|
## 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:
|
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:
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ It implements the Model Context Protocol specification, handling model context r
|
||||||
- Supports resource registration and retrieval
|
- Supports resource registration and retrieval
|
||||||
- Supports stdio and Streamable HTTP (including SSE) transports
|
- Supports stdio and Streamable HTTP (including SSE) transports
|
||||||
- Supports notifications for list changes (tools, prompts, resources)
|
- Supports notifications for list changes (tools, prompts, resources)
|
||||||
- Supports roots (server-to-client filesystem boundary queries)
|
- Supports roots (server-to-client filesystem boundary queries; deprecated as of 2026-07-28)
|
||||||
- Supports sampling (server-to-client LLM completion requests)
|
- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28)
|
||||||
- Supports cursor-based pagination for list operations
|
- Supports cursor-based pagination for list operations
|
||||||
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
|
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ It implements the Model Context Protocol specification, handling model context r
|
||||||
- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask
|
- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): handlers return `MCP::Server::InputRequiredResult` to ask
|
||||||
the client for additional input instead of performing a server-initiated request; see [Multi Round-Trip Requests](/server/mrtr/)
|
the client for additional input instead of performing a server-initiated request; see [Multi Round-Trip Requests](/server/mrtr/)
|
||||||
- `ping` - Simple health check
|
- `ping` - Simple health check
|
||||||
- `logging/setLevel` - Configures the minimum log level for the server
|
- `logging/setLevel` - Configures the minimum log level for the server (deprecated as of 2026-07-28)
|
||||||
- `tools/list` - Lists all registered tools and their schemas
|
- `tools/list` - Lists all registered tools and their schemas
|
||||||
- `tools/call` - Invokes a specific tool with provided arguments
|
- `tools/call` - Invokes a specific tool with provided arguments
|
||||||
- `prompts/list` - Lists all registered prompts and their schemas
|
- `prompts/list` - Lists all registered prompts and their schemas
|
||||||
|
|
@ -48,6 +48,6 @@ It implements the Model Context Protocol specification, handling model context r
|
||||||
- `resources/subscribe` - Subscribes to updates for a specific resource
|
- `resources/subscribe` - Subscribes to updates for a specific resource
|
||||||
- `resources/unsubscribe` - Unsubscribes from updates for a specific resource
|
- `resources/unsubscribe` - Unsubscribes from updates for a specific resource
|
||||||
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
|
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
|
||||||
- `roots/list` - Requests filesystem roots from the client (server-to-client)
|
- `roots/list` - Requests filesystem roots from the client (server-to-client; deprecated as of 2026-07-28)
|
||||||
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
|
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client; deprecated as of 2026-07-28)
|
||||||
- `elicitation/create` - Requests user input from the client (server-to-client)
|
- `elicitation/create` - Requests user input from the client (server-to-client)
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,5 @@ Server-to-client requests are bounded by a timeout on the Streamable HTTP transp
|
||||||
## Client Side
|
## Client Side
|
||||||
|
|
||||||
Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page.
|
Pinging the server with `MCP::Client#ping` is documented on the client [Ping](/client/ping/) page.
|
||||||
|
The Ruby client answers the pings sent with `ServerSession#ping` automatically;
|
||||||
|
see [Answering Server Pings](/client/ping/#answering-server-pings).
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ The first SSE event on the stream is the acknowledgement:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Delivery Semantics
|
||||||
|
|
||||||
|
A stream delivers only the changes that happen after its acknowledgement; it is not a replay log.
|
||||||
|
Nothing is queued while no stream is connected, and a dropped stream is gone together with anything it missed.
|
||||||
|
Clients fetch the current state after subscribing and treat each notification as a cue to refetch,
|
||||||
|
rather than as a payload.
|
||||||
|
|
||||||
## Transport Support
|
## Transport Support
|
||||||
|
|
||||||
The stream is served on the Streamable HTTP modern path; stdio answers `-32601`.
|
The stream is served on the Streamable HTTP modern path; stdio answers `-32601`.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@ $ gem install mcp
|
||||||
You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem:
|
You may need to add additional dependencies depending on which features you wish to access. For example, the HTTP client transport requires the `faraday` gem:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
gem "mcp"
|
|
||||||
gem "faraday", ">= 2.0"
|
gem "faraday", ">= 2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Reading SSE (`text/event-stream`) responses needs `event_stream_parser` as well.
|
||||||
|
Whether a response is JSON or SSE is the server's choice, made for each response, and this SDK's own server picks SSE by default,
|
||||||
|
so a client written for arbitrary servers needs both gems:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
gem "faraday", ">= 2.0"
|
||||||
|
gem "event_stream_parser", ">= 1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
The [Transports](/client/transports/#http-transport-layer) page describes the one setup where `faraday` alone is enough.
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S
|
||||||
|
|
||||||
**Available Tools:**
|
**Available Tools:**
|
||||||
|
|
||||||
- `notification_tool` - Send custom SSE notifications with optional delays
|
- `notification_tool` - Sends progress notifications over SSE, with optional delays
|
||||||
- `echo` - Simple echo tool for basic testing
|
- `echo` - Simple echo tool for basic testing
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
@ -129,6 +129,7 @@ An interactive client that connects to the SSE stream and provides a menu-driven
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run the SSE test client in another terminal:
|
2. Run the SSE test client in another terminal:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ ruby examples/streamable_http_client.rb
|
$ ruby examples/streamable_http_client.rb
|
||||||
```
|
```
|
||||||
|
|
@ -155,6 +156,34 @@ $ bundle exec puma --port 9292
|
||||||
|
|
||||||
The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough.
|
The MCP endpoint is available at `http://localhost:9292/mcp`. See [`rails/README.md`](rails/README.md) for a full curl-based walkthrough.
|
||||||
|
|
||||||
|
### 8. Modern Lifecycle HTTP Server / Client (`modern_http_server.rb`, `modern_http_client.rb`)
|
||||||
|
|
||||||
|
A server and client pair demonstrating the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake and per-session state with sessionless, self-contained requests.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- `server/discover` capability discovery before (or instead of) a handshake
|
||||||
|
- The per-request `_meta` envelope and `Mcp-Method` / `Mcp-Name` headers, stamped by the SDK automatically
|
||||||
|
- `resultType` stamping and SEP-2549 cache hints (`ttlMs`, `cacheScope`) on results
|
||||||
|
- A multi round-trip `deploy` tool (SEP-2322), resumed automatically by the client's elicitation handler
|
||||||
|
- The removal of legacy-only methods such as `ping`
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
1. Start the server in one terminal:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ ruby examples/modern_http_server.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the client in another terminal:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ ruby examples/modern_http_client.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
The same server still accepts the legacy `initialize` flow: the transport routes each request to the legacy or modern lifecycle by its `MCP-Protocol-Version` header.
|
||||||
|
|
||||||
### Testing with MCP Inspector
|
### Testing with MCP Inspector
|
||||||
|
|
||||||
[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers.
|
[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is a browser-based tool for testing and debugging MCP servers.
|
||||||
|
|
@ -188,24 +217,78 @@ You can also test SSE functionality manually using cURL:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
SESSION_ID=$(curl -D - -s -o /dev/null http://localhost:9393 \
|
SESSION_ID=$(curl -D - -s -o /dev/null http://localhost:9393 \
|
||||||
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r')
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' | grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r')
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Connect to SSE stream (in one terminal):
|
2. Optionally connect the standalone SSE stream, which carries server-initiated messages that are not tied to a request (in another terminal):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -i -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393
|
curl -i -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Trigger notifications (in another terminal):
|
3. Call the notification tool. The `notifications/progress` events (requested via the `progressToken` in `_meta`) and the final response arrive as SSE events on the POST response itself:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
# Send immediate notification
|
|
||||||
curl -i http://localhost:9393 \
|
curl -i http://localhost:9393 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||||
--json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}'
|
--json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!","delay":0.5},"_meta":{"progressToken":"curl-progress"}}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Testing the modern lifecycle with cURL
|
||||||
|
|
||||||
|
The modern lifecycle (2026-07-28, SEP-2575) is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`. Start `examples/modern_http_server.rb` and walk it manually:
|
||||||
|
|
||||||
|
1. Probe capabilities with `server/discover`:
|
||||||
|
|
||||||
|
```console
|
||||||
|
curl -s http://localhost:9494 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
--json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. List tools (the `MCP-Protocol-Version` header selects the era, `Mcp-Method` mirrors the method, and `params._meta` carries the SEP-2575 envelope):
|
||||||
|
|
||||||
|
```console
|
||||||
|
curl -s http://localhost:9494 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-H "MCP-Protocol-Version: 2026-07-28" \
|
||||||
|
-H "Mcp-Method: tools/list" \
|
||||||
|
--json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Call a tool (name-bearing methods additionally mirror the name in the `Mcp-Name` header):
|
||||||
|
|
||||||
|
```console
|
||||||
|
curl -s http://localhost:9494 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-H "MCP-Protocol-Version: 2026-07-28" \
|
||||||
|
-H "Mcp-Method: tools/call" \
|
||||||
|
-H "Mcp-Name: greet" \
|
||||||
|
--json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Observe a multi round-trip result (SEP-2322), then resume it by echoing the `requestState` back together with the answer. Declaring the `elicitation` capability in the envelope is required before the server may embed elicitation requests:
|
||||||
|
|
||||||
|
```console
|
||||||
|
curl -s http://localhost:9494 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-H "MCP-Protocol-Version: 2026-07-28" \
|
||||||
|
-H "Mcp-Method: tools/call" \
|
||||||
|
-H "Mcp-Name: deploy" \
|
||||||
|
--json '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}'
|
||||||
|
|
||||||
|
curl -s http://localhost:9494 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-H "MCP-Protocol-Version: 2026-07-28" \
|
||||||
|
-H "Mcp-Method: tools/call" \
|
||||||
|
-H "Mcp-Name: deploy" \
|
||||||
|
--json '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deploy","arguments":{"app":"storefront"},"inputResponses":{"environment":{"action":"accept","content":{"environment":"staging"}}},"requestState":"{\"app\":\"storefront\"}","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The same server still accepts the legacy flow from the previous sections, routed by the `MCP-Protocol-Version` header.
|
||||||
|
|
||||||
## Streamable HTTP Transport Details
|
## Streamable HTTP Transport Details
|
||||||
|
|
||||||
### Protocol Flow
|
### Protocol Flow
|
||||||
|
|
@ -236,13 +319,15 @@ Initialize a session:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -i http://localhost:9292 \
|
curl -i http://localhost:9292 \
|
||||||
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
List tools (using the session ID from initialization):
|
List tools (using the session ID from initialization):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -i http://localhost:9292 \
|
curl -i http://localhost:9292 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||||
--json '{"jsonrpc":"2.0","method":"tools/list","id":2}'
|
--json '{"jsonrpc":"2.0","method":"tools/list","id":2}'
|
||||||
```
|
```
|
||||||
|
|
@ -251,6 +336,7 @@ Call a tool:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -i http://localhost:9292 \
|
curl -i http://localhost:9292 \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||||
--json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}'
|
--json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"example_tool","arguments":{"a":5,"b":3}}}'
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ require "net/http"
|
||||||
require "json"
|
require "json"
|
||||||
require "uri"
|
require "uri"
|
||||||
|
|
||||||
# Simple HTTP client example for interacting with the MCP HTTP server
|
# Simple HTTP client example for interacting with the MCP HTTP server.
|
||||||
class MCPHTTPClient
|
class MCPHTTPClient
|
||||||
def initialize(base_url = "http://localhost:9292")
|
def initialize(base_url = "http://localhost:9292")
|
||||||
@base_url = base_url
|
@base_url = base_url
|
||||||
|
|
@ -30,19 +30,51 @@ class MCPHTTPClient
|
||||||
|
|
||||||
response = http.request(request)
|
response = http.request(request)
|
||||||
|
|
||||||
# Store session ID if provided
|
# Store session ID if provided.
|
||||||
if response["Mcp-Session-Id"]
|
if response["Mcp-Session-Id"]
|
||||||
@session_id = response["Mcp-Session-Id"]
|
@session_id = response["Mcp-Session-Id"]
|
||||||
puts "Session ID: #{@session_id}"
|
puts "Session ID: #{@session_id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
JSON.parse(response.body)
|
parse_response_body(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
# In the transport's default SSE mode, POST responses on an established session arrive as
|
||||||
|
# a Server-Sent Events stream whose `data:` line carries the JSON-RPC response; unwrap it
|
||||||
|
# before parsing.
|
||||||
|
def parse_response_body(response)
|
||||||
|
body = response.body
|
||||||
|
|
||||||
|
if response["Content-Type"]&.start_with?("text/event-stream")
|
||||||
|
data_lines = body.lines.select { |line| line.start_with?("data:") }
|
||||||
|
body = data_lines.map { |line| line.sub(/\Adata:\s*/, "") }.join
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_notification(method, params = nil)
|
||||||
|
uri = URI(@base_url)
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
|
||||||
|
request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
|
||||||
|
request["Content-Type"] = "application/json"
|
||||||
|
request["Mcp-Session-Id"] = @session_id if @session_id
|
||||||
|
|
||||||
|
# Notifications carry no `id` and receive no JSON-RPC response body.
|
||||||
|
request.body = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: method,
|
||||||
|
params: params,
|
||||||
|
}.compact.to_json
|
||||||
|
|
||||||
|
http.request(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize_session
|
def initialize_session
|
||||||
puts "=== Initializing session ==="
|
puts "=== Initializing session ==="
|
||||||
result = send_request("initialize", {
|
result = send_request("initialize", {
|
||||||
protocolVersion: "2024-11-05",
|
protocolVersion: "2025-11-25",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: "example_client",
|
name: "example_client",
|
||||||
|
|
@ -54,6 +86,14 @@ class MCPHTTPClient
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def notify_initialized
|
||||||
|
puts "=== Sending notifications/initialized ==="
|
||||||
|
response = send_notification("notifications/initialized")
|
||||||
|
puts "Response status: #{response.code} #{response.message}"
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
def ping
|
def ping
|
||||||
puts "=== Sending ping ==="
|
puts "=== Sending ping ==="
|
||||||
result = send_request("ping")
|
result = send_request("ping")
|
||||||
|
|
@ -147,37 +187,40 @@ def main
|
||||||
client = MCPHTTPClient.new
|
client = MCPHTTPClient.new
|
||||||
|
|
||||||
begin
|
begin
|
||||||
# Initialize session
|
# Initialize session.
|
||||||
client.initialize_session
|
client.initialize_session
|
||||||
|
|
||||||
# Test ping
|
# Complete the handshake.
|
||||||
|
client.notify_initialized
|
||||||
|
|
||||||
|
# Test ping.
|
||||||
client.ping
|
client.ping
|
||||||
|
|
||||||
# List available tools
|
# List available tools.
|
||||||
client.list_tools
|
client.list_tools
|
||||||
|
|
||||||
# Call the example_tool (note: snake_case name)
|
# Call the example_tool (note: snake_case name).
|
||||||
client.call_tool("example_tool", { a: 5, b: 3 })
|
client.call_tool("example_tool", { a: 5, b: 3 })
|
||||||
|
|
||||||
# Call the echo tool
|
# Call the echo tool.
|
||||||
client.call_tool("echo", { message: "Hello from client!" })
|
client.call_tool("echo", { message: "Hello from client!" })
|
||||||
|
|
||||||
# List prompts
|
# List prompts.
|
||||||
client.list_prompts
|
client.list_prompts
|
||||||
|
|
||||||
# Get a prompt (note: snake_case name)
|
# Get a prompt (note: snake_case name).
|
||||||
client.get_prompt("example_prompt", { message: "This is a test message" })
|
client.get_prompt("example_prompt", { message: "This is a test message" })
|
||||||
|
|
||||||
# List resources
|
# List resources.
|
||||||
client.list_resources
|
client.list_resources
|
||||||
|
|
||||||
# Read a resource
|
# Read a resource (the URI registered by http_server.rb).
|
||||||
client.read_resource("test_resource")
|
client.read_resource("https://test_resource.invalid")
|
||||||
rescue => e
|
rescue => e
|
||||||
puts "Error: #{e.message}"
|
puts "Error: #{e.message}"
|
||||||
puts e.backtrace
|
puts e.backtrace
|
||||||
ensure
|
ensure
|
||||||
# Clean up session
|
# Clean up session.
|
||||||
client.close_session
|
client.close_session
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ end
|
||||||
# Set up the server
|
# Set up the server
|
||||||
server = MCP::Server.new(
|
server = MCP::Server.new(
|
||||||
name: "example_http_server",
|
name: "example_http_server",
|
||||||
|
version: "1.0.0",
|
||||||
tools: [ExampleTool],
|
tools: [ExampleTool],
|
||||||
prompts: [ExamplePrompt],
|
prompts: [ExamplePrompt],
|
||||||
resources: [
|
resources: [
|
||||||
|
|
@ -169,7 +170,9 @@ puts <<~MESSAGE
|
||||||
Starting MCP HTTP server on http://localhost:9292
|
Starting MCP HTTP server on http://localhost:9292
|
||||||
Use POST requests to initialize and send JSON-RPC commands
|
Use POST requests to initialize and send JSON-RPC commands
|
||||||
Example initialization:
|
Example initialization:
|
||||||
curl -i http://localhost:9292 --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
curl -i http://localhost:9292 \\
|
||||||
|
-H "Accept: application/json, text/event-stream" \\
|
||||||
|
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||||
|
|
||||||
The server will return a session ID in the Mcp-Session-Id header.
|
The server will return a session ID in the Mcp-Session-Id header.
|
||||||
Use this session ID for subsequent requests.
|
Use this session ID for subsequent requests.
|
||||||
|
|
|
||||||
133
examples/modern_http_client.rb
Normal file
133
examples/modern_http_client.rb
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||||
|
require "mcp"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
# Client example for the 2026-07-28 modern lifecycle (SEP-2575), which replaces the `initialize` handshake
|
||||||
|
# and per-session state with sessionless, self-contained requests.
|
||||||
|
class MCPModernHTTPClient
|
||||||
|
def initialize(base_url = "http://localhost:9494")
|
||||||
|
@transport = MCP::Client::HTTP.new(url: base_url)
|
||||||
|
@client = MCP::Client.new(transport: @transport)
|
||||||
|
end
|
||||||
|
|
||||||
|
# `server/discover` (SEP-2575) is sessionless capability discovery that works before, or instead of, `connect`.
|
||||||
|
def discover
|
||||||
|
puts "=== Discovering server capabilities (server/discover) ==="
|
||||||
|
result = @client.discover
|
||||||
|
puts <<~MESSAGE
|
||||||
|
Supported versions: #{result.supported_versions.inspect}
|
||||||
|
Server: #{result.server_info}
|
||||||
|
Cache hints: ttlMs=#{result.ttl_ms}, cacheScope=#{result.cache_scope} (SEP-2549)
|
||||||
|
Instructions: #{result.instructions}
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
# The handler answers `elicitation/create` requests embedded in SEP-2322 `input_required` results; once registered,
|
||||||
|
# `call_tool` resumes those results automatically.
|
||||||
|
# Register it before `connect` so the connection never needs a server-to-client route.
|
||||||
|
def register_elicitation_handler
|
||||||
|
@client.on_elicitation do |params|
|
||||||
|
puts " Server asked: #{params["message"]}"
|
||||||
|
|
||||||
|
{ action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# `mode: :modern` requires the modern lifecycle and fails on legacy-only servers;
|
||||||
|
# the default `:auto` probes `server/discover` first and falls back to the legacy `initialize` handshake.
|
||||||
|
# The `elicitation` capability must be declared before the server may embed elicitation requests.
|
||||||
|
def connect
|
||||||
|
puts "=== Adopting the modern lifecycle ==="
|
||||||
|
result = @client.connect(
|
||||||
|
client_info: { name: "modern-http-client", version: "1.0" },
|
||||||
|
capabilities: { elicitation: {} },
|
||||||
|
mode: :modern,
|
||||||
|
)
|
||||||
|
puts <<~MESSAGE
|
||||||
|
Modern connection: #{@transport.modern?}
|
||||||
|
Protocol version: #{@client.protocol_version}
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
# Modern requests are self-contained: the SDK stamps the `_meta` envelope (protocol version,
|
||||||
|
# client info, capabilities) and the Mcp-Method and Mcp-Name headers on every request automatically.
|
||||||
|
def list_tools
|
||||||
|
puts "=== Listing tools ==="
|
||||||
|
page = @client.list_tools
|
||||||
|
page.tools.each { |tool| puts " - #{tool.name}: #{tool.description}" }
|
||||||
|
puts "Cache hints: ttlMs=#{page.ttl_ms}, cacheScope=#{page.cache_scope}"
|
||||||
|
|
||||||
|
page
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_tool(name, arguments)
|
||||||
|
puts "=== Calling tool: #{name} ==="
|
||||||
|
response = @client.call_tool(name: name, arguments: arguments)
|
||||||
|
puts <<~MESSAGE
|
||||||
|
resultType: #{response.dig("result", "resultType")}
|
||||||
|
Response: #{response.dig("result", "content", 0, "text")}
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
# The modern lifecycle removed `initialize`, `ping`, `logging/setLevel`, and resource subscriptions (SEP-2575);
|
||||||
|
# calling one is an error.
|
||||||
|
def ping
|
||||||
|
puts "=== Calling removed method: ping ==="
|
||||||
|
@client.ping
|
||||||
|
rescue MCP::Client::RequestHandlerError => e
|
||||||
|
puts "ping was removed by the modern lifecycle (SEP-2575): #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def close
|
||||||
|
# No session exists on a modern connection; this only clears local state.
|
||||||
|
@transport.close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts <<~MESSAGE
|
||||||
|
MCP Modern Lifecycle Client (2026-07-28)
|
||||||
|
Make sure the server is running (ruby examples/modern_http_server.rb)
|
||||||
|
#{"=" * 60}
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
client = MCPModernHTTPClient.new
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Probe the server before adopting a lifecycle.
|
||||||
|
client.discover
|
||||||
|
|
||||||
|
# Answer the elicitation requests the server embeds in its results.
|
||||||
|
client.register_elicitation_handler
|
||||||
|
|
||||||
|
# Adopt the modern lifecycle.
|
||||||
|
client.connect
|
||||||
|
|
||||||
|
# List available tools.
|
||||||
|
client.list_tools
|
||||||
|
|
||||||
|
# Call a plain single round-trip tool.
|
||||||
|
client.call_tool("greet", { name: "MCP" })
|
||||||
|
|
||||||
|
# Call a multi round-trip tool (SEP-2322): the driver fulfills the embedded elicitation via the handler registered above
|
||||||
|
# and re-issues the call with `inputResponses` plus the echoed `requestState`. Without a handler, `call_tool` raises
|
||||||
|
# `MCP::Client::InputRequiredError` for manual driving via the `input_responses:` / `request_state:` keywords.
|
||||||
|
client.call_tool("deploy", { app: "storefront" })
|
||||||
|
|
||||||
|
# Call a method the modern lifecycle removed.
|
||||||
|
client.ping
|
||||||
|
rescue => e
|
||||||
|
puts <<~MESSAGE
|
||||||
|
Error: #{e.message}
|
||||||
|
#{e.backtrace.join("\n")}
|
||||||
|
MESSAGE
|
||||||
|
ensure
|
||||||
|
client.close
|
||||||
|
end
|
||||||
126
examples/modern_http_server.rb
Normal file
126
examples/modern_http_server.rb
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||||
|
require "mcp"
|
||||||
|
require "rackup"
|
||||||
|
require "json"
|
||||||
|
require "logger"
|
||||||
|
|
||||||
|
# Example server for the 2026-07-28 modern lifecycle (SEP-2575).
|
||||||
|
#
|
||||||
|
# The modern lifecycle is sessionless: there is no `initialize` handshake and no `Mcp-Session-Id`.
|
||||||
|
# Clients probe the server with `server/discover` and then send self-contained requests that carry
|
||||||
|
# a `_meta` envelope (protocol version, client info, capabilities) plus `MCP-Protocol-Version` / `Mcp-Method` headers.
|
||||||
|
#
|
||||||
|
# The same server still accepts the legacy `initialize` flow: the transport routes each request to
|
||||||
|
# the legacy or modern lifecycle by its `MCP-Protocol-Version` header, so no constructor flag is needed.
|
||||||
|
server = MCP::Server.new(
|
||||||
|
name: "modern_lifecycle_server",
|
||||||
|
version: "1.0.0",
|
||||||
|
instructions: "Example server demonstrating the 2026-07-28 modern lifecycle.",
|
||||||
|
# SEP-2549 cache hints, advertised in `server/discover` and stamped on cacheable results.
|
||||||
|
# The defaults are 0 / "private"; non-default values are used here so they are visible
|
||||||
|
# in the example output.
|
||||||
|
ttl_ms: 60_000,
|
||||||
|
cache_scope: "public",
|
||||||
|
)
|
||||||
|
|
||||||
|
server.define_tool(
|
||||||
|
name: "greet",
|
||||||
|
description: "Greets a name; a plain single round-trip tool",
|
||||||
|
input_schema: { properties: { name: { type: "string" } }, required: ["name"] },
|
||||||
|
) do |name:|
|
||||||
|
MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}!" }])
|
||||||
|
end
|
||||||
|
|
||||||
|
# A multi round-trip tool (SEP-2322). The first round returns an `input_required` result asking
|
||||||
|
# the client to answer an embedded `elicitation/create` request; the client re-issues the call with
|
||||||
|
# `inputResponses` plus the echoed opaque `requestState`, and the handler re-runs from the start
|
||||||
|
# and reads the answer via `server_context`.
|
||||||
|
# Over a legacy connection the default `input_required_legacy_shim` fulfills the same result through
|
||||||
|
# a real server-to-client elicitation request instead.
|
||||||
|
server.define_tool(
|
||||||
|
name: "deploy",
|
||||||
|
description: "Deploys an app; asks which environment via a multi round-trip elicitation (SEP-2322)",
|
||||||
|
input_schema: { properties: { app: { type: "string" } }, required: ["app"] },
|
||||||
|
) do |app:, server_context:|
|
||||||
|
answer = server_context.input_response("environment")
|
||||||
|
|
||||||
|
if answer.nil?
|
||||||
|
MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
"environment" => {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "Which environment should #{app} deploy to?",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
environment: { type: "string", title: "Environment", default: "staging" },
|
||||||
|
},
|
||||||
|
required: ["environment"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request_state: JSON.generate(app: app),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
# Transports parse JSON with `symbolize_names: true`, so answers normally arrive symbol-keyed;
|
||||||
|
# the string fallback covers custom transports.
|
||||||
|
action = answer[:action] || answer["action"]
|
||||||
|
|
||||||
|
if action == "accept"
|
||||||
|
environment = answer.dig(:content, :environment) || answer.dig("content", "environment")
|
||||||
|
MCP::Tool::Response.new([{ type: "text", text: "Deployed #{app} to #{environment}" }])
|
||||||
|
else
|
||||||
|
MCP::Tool::Response.new([{ type: "text", text: "Deployment of #{app} was cancelled (#{action})" }])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
||||||
|
|
||||||
|
# `StreamableHTTPTransport` responds to `call(env)`, so it can be used directly as a Rack app.
|
||||||
|
# See http_server.rb for a CORS setup for browser-based clients.
|
||||||
|
rack_app = Rack::Builder.new do
|
||||||
|
use(Rack::CommonLogger, Logger.new($stdout))
|
||||||
|
use(Rack::ShowExceptions)
|
||||||
|
|
||||||
|
run(transport)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts <<~MESSAGE
|
||||||
|
=== MCP Modern Lifecycle Server (2026-07-28) ===
|
||||||
|
|
||||||
|
Starting server on http://localhost:9494
|
||||||
|
|
||||||
|
Run the SDK client against it:
|
||||||
|
ruby examples/modern_http_client.rb
|
||||||
|
|
||||||
|
Or walk the modern lifecycle with cURL:
|
||||||
|
|
||||||
|
1. Probe capabilities (sessionless, works without any protocol headers):
|
||||||
|
curl -s http://localhost:9494 \\
|
||||||
|
-H "Accept: application/json, text/event-stream" \\
|
||||||
|
--json '{"jsonrpc":"2.0","id":0,"method":"server/discover"}'
|
||||||
|
|
||||||
|
2. Modern tools/list (the MCP-Protocol-Version header selects the era, the
|
||||||
|
Mcp-Method header mirrors the method, and params._meta carries the
|
||||||
|
SEP-2575 envelope):
|
||||||
|
curl -s http://localhost:9494 \\
|
||||||
|
-H "Accept: application/json, text/event-stream" \\
|
||||||
|
-H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" \\
|
||||||
|
--json '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
|
||||||
|
|
||||||
|
3. Modern tools/call (name-bearing methods additionally mirror the name in
|
||||||
|
the Mcp-Name header; no Mcp-Session-Id is ever sent):
|
||||||
|
curl -s http://localhost:9494 \\
|
||||||
|
-H "Accept: application/json, text/event-stream" \\
|
||||||
|
-H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: greet" \\
|
||||||
|
--json '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"curl"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
|
||||||
|
|
||||||
|
Press Ctrl+C to stop the server
|
||||||
|
MESSAGE
|
||||||
|
|
||||||
|
Rackup::Handler.get("puma").run(rack_app, Port: 9494, Host: "localhost")
|
||||||
|
|
@ -36,7 +36,7 @@ per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` line
|
||||||
```console
|
```console
|
||||||
SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \
|
SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \
|
||||||
-H "Accept: application/json, text/event-stream" \
|
-H "Accept: application/json, text/event-stream" \
|
||||||
--json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
|
--json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
|
||||||
| grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
|
| grep -i "^mcp-session-id:" | cut -d' ' -f2 | tr -d '\r')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ def create_logger
|
||||||
logger
|
logger
|
||||||
end
|
end
|
||||||
|
|
||||||
# The SDK does not yet implement the optional GET SSE stream, so this example
|
# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler
|
||||||
# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream.
|
# (e.g. `client.on_elicitation`) is registered. This example instead opens the stream with
|
||||||
|
# raw `Net::HTTP` to show the SSE wire format. The GET stream carries server-initiated messages
|
||||||
|
# that are not tied to a client request; request-scoped notifications such as progress arrive on
|
||||||
|
# the POST's own SSE response stream (see `stream_tool_call` below).
|
||||||
def connect_sse(session_id, logger)
|
def connect_sse(session_id, logger)
|
||||||
uri = URI(SERVER_URL)
|
uri = URI(SERVER_URL)
|
||||||
|
|
||||||
|
|
@ -64,6 +67,42 @@ def print_response(response)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Calls a tool with a raw streaming POST so each SSE event on the response stream is printed as
|
||||||
|
# it arrives: the `notifications/progress` events requested via `_meta.progressToken`,
|
||||||
|
# then the final JSON-RPC response. `MCP::Client#call_tool` reads the same stream but consumes
|
||||||
|
# the progress notifications internally, so the raw request makes them visible here.
|
||||||
|
def stream_tool_call(session_id, request_id, name:, arguments:, logger:)
|
||||||
|
uri = URI(SERVER_URL)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: request_id,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: name,
|
||||||
|
arguments: arguments,
|
||||||
|
_meta: { progressToken: "streamable-http-client" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Net::HTTP.start(uri.host, uri.port) do |http|
|
||||||
|
request = Net::HTTP::Post.new(uri)
|
||||||
|
request["Content-Type"] = "application/json"
|
||||||
|
request["Accept"] = "application/json, text/event-stream"
|
||||||
|
request["Mcp-Session-Id"] = session_id
|
||||||
|
request.body = JSON.generate(body)
|
||||||
|
|
||||||
|
http.request(request) do |response|
|
||||||
|
parser = EventStreamParser::Parser.new
|
||||||
|
response.read_body do |chunk|
|
||||||
|
parser.feed(chunk) do |_type, data, _id|
|
||||||
|
logger.info("SSE event: #{data}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def main
|
def main
|
||||||
logger = create_logger
|
logger = create_logger
|
||||||
|
|
||||||
|
|
@ -76,11 +115,17 @@ def main
|
||||||
http_transport = MCP::Client::HTTP.new(url: SERVER_URL)
|
http_transport = MCP::Client::HTTP.new(url: SERVER_URL)
|
||||||
client = MCP::Client.new(transport: http_transport)
|
client = MCP::Client.new(transport: http_transport)
|
||||||
sse_thread = nil
|
sse_thread = nil
|
||||||
|
raw_request_id = 0
|
||||||
|
|
||||||
begin
|
begin
|
||||||
puts "=== Initializing session ==="
|
puts "=== Initializing session ==="
|
||||||
|
|
||||||
|
# The default `mode: :auto` adopts the sessionless modern lifecycle (2026-07-28)
|
||||||
|
# when the server supports it. This example demonstrates the legacy session's SSE stream,
|
||||||
|
# so pin the legacy `initialize` handshake.
|
||||||
server_info = client.connect(
|
server_info = client.connect(
|
||||||
client_info: { name: "streamable-http-client", version: "1.0" },
|
client_info: { name: "streamable-http-client", version: "1.0" },
|
||||||
|
mode: :legacy,
|
||||||
)
|
)
|
||||||
|
|
||||||
puts <<~MESSAGE
|
puts <<~MESSAGE
|
||||||
|
|
@ -104,8 +149,6 @@ def main
|
||||||
sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) }
|
sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) }
|
||||||
sleep(1)
|
sleep(1)
|
||||||
|
|
||||||
# Once the optional SSE stream is active, POST requests may receive only a
|
|
||||||
# 202 ACK while the actual JSON-RPC response is delivered over SSE.
|
|
||||||
loop do
|
loop do
|
||||||
puts <<~MENU.chomp
|
puts <<~MENU.chomp
|
||||||
|
|
||||||
|
|
@ -126,12 +169,15 @@ def main
|
||||||
print("Enter delay in seconds (0 for immediate): ")
|
print("Enter delay in seconds (0 for immediate): ")
|
||||||
delay = gets.chomp.to_f
|
delay = gets.chomp.to_f
|
||||||
|
|
||||||
puts "=== Calling tool: notification_tool ==="
|
puts "=== Calling tool: notification_tool (raw streaming POST) ==="
|
||||||
response = client.call_tool(
|
raw_request_id += 1
|
||||||
tool: notification_tool,
|
stream_tool_call(
|
||||||
|
http_transport.session_id,
|
||||||
|
"raw-#{raw_request_id}",
|
||||||
|
name: notification_tool.name,
|
||||||
arguments: { message: message, delay: delay },
|
arguments: { message: message, delay: delay },
|
||||||
|
logger: logger,
|
||||||
)
|
)
|
||||||
print_response(response)
|
|
||||||
else
|
else
|
||||||
puts "notification_tool not available"
|
puts "notification_tool not available"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue