Compare commits
46 changed files with 297 additions and 4223 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
|
||||||
|
|
|
||||||
|
|
@ -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 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 conformance` - 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,37 +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
|
## [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,11 +14,10 @@ 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 :test do |t|
|
task :conformance do |t|
|
||||||
next unless npx_available?(t.name)
|
next unless npx_available?(t.name)
|
||||||
|
|
||||||
require_relative "conformance/server_runner"
|
require_relative "conformance/server_runner"
|
||||||
|
|
@ -35,7 +34,7 @@ namespace :conformance do
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "List available conformance scenarios"
|
desc "List available conformance scenarios"
|
||||||
task :list do |t|
|
task :conformance_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")
|
||||||
|
|
@ -43,7 +42,7 @@ namespace :conformance do
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Start the conformance server (PORT)"
|
desc "Start the conformance server (PORT)"
|
||||||
task :server do
|
task :conformance_server do
|
||||||
require_relative "conformance/server"
|
require_relative "conformance/server"
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
|
|
@ -51,13 +50,6 @@ namespace :conformance 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: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`.
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,9 @@ 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` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
|
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),
|
||||||
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.
|
||||||
|
|
@ -97,16 +93,10 @@ 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). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what
|
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
|
||||||
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`,
|
||||||
|
|
@ -118,14 +108,6 @@ 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,
|
||||||
|
|
@ -173,33 +155,11 @@ 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 is not expected to issue a refresh token (RFC 6749 Section 4.4.3);
|
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
|
||||||
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(
|
||||||
|
|
@ -207,7 +167,6 @@ 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)
|
||||||
|
|
@ -223,15 +182,13 @@ 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`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
|
- `scope`, `storage`: 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
|
||||||
|
|
@ -262,39 +219,7 @@ 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`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
|
- `scope`, `storage`: 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
|
||||||
|
|
||||||
|
|
@ -326,80 +251,10 @@ 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.
|
||||||
|
|
||||||
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
|
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
|
||||||
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:)`
|
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
|
||||||
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,8 +17,7 @@ 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`), and automatic answers to server pings;
|
- Liveness check via the `ping` method (`MCP::Client#ping`)
|
||||||
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,20 +29,6 @@ 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,27 +59,12 @@ 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, and `event_stream_parser`
|
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:
|
||||||
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"
|
||||||
|
|
@ -248,15 +233,6 @@ 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; deprecated as of 2026-07-28)
|
- Supports roots (server-to-client filesystem boundary queries)
|
||||||
- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28)
|
- Supports sampling (server-to-client LLM completion requests)
|
||||||
- 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 (deprecated as of 2026-07-28)
|
- `logging/setLevel` - Configures the minimum log level for the server
|
||||||
- `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; deprecated as of 2026-07-28)
|
- `roots/list` - Requests filesystem roots from the client (server-to-client)
|
||||||
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client; deprecated as of 2026-07-28)
|
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
|
||||||
- `elicitation/create` - Requests user input from the client (server-to-client)
|
- `elicitation/create` - Requests user input from the client (server-to-client)
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,3 @@ 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,13 +49,6 @@ 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,16 +38,6 @@ $ 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` - Sends progress notifications over SSE, with optional delays
|
- `notification_tool` - Send custom SSE notifications with optional delays
|
||||||
- `echo` - Simple echo tool for basic testing
|
- `echo` - Simple echo tool for basic testing
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
@ -129,7 +129,6 @@ $ ruby examples/streamable_http_server.rb
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
@ -156,34 +155,6 @@ $ 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.
|
||||||
|
|
@ -217,78 +188,24 @@ 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 \
|
||||||
-H "Accept: application/json, text/event-stream" \
|
--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')
|
||||||
--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. Optionally connect the standalone SSE stream, which carries server-initiated messages that are not tied to a request (in another terminal):
|
2. Connect to SSE stream (in one 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. 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:
|
3. Trigger notifications (in another terminal):
|
||||||
|
|
||||||
```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!","delay":0.5},"_meta":{"progressToken":"curl-progress"}}}'
|
--json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
||||||
|
|
@ -319,15 +236,13 @@ Initialize a session:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -i http://localhost:9292 \
|
curl -i http://localhost:9292 \
|
||||||
-H "Accept: application/json, text/event-stream" \
|
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||||
--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}'
|
||||||
```
|
```
|
||||||
|
|
@ -336,7 +251,6 @@ 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,51 +30,19 @@ 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
|
||||||
|
|
||||||
parse_response_body(response)
|
JSON.parse(response.body)
|
||||||
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: "2025-11-25",
|
protocolVersion: "2024-11-05",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: "example_client",
|
name: "example_client",
|
||||||
|
|
@ -86,14 +54,6 @@ 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")
|
||||||
|
|
@ -187,40 +147,37 @@ def main
|
||||||
client = MCPHTTPClient.new
|
client = MCPHTTPClient.new
|
||||||
|
|
||||||
begin
|
begin
|
||||||
# Initialize session.
|
# Initialize session
|
||||||
client.initialize_session
|
client.initialize_session
|
||||||
|
|
||||||
# Complete the handshake.
|
# Test ping
|
||||||
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 (the URI registered by http_server.rb).
|
# Read a resource
|
||||||
client.read_resource("https://test_resource.invalid")
|
client.read_resource("test_resource")
|
||||||
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,7 +56,6 @@ 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: [
|
||||||
|
|
@ -170,9 +169,7 @@ 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 \\
|
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"}}}'
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# 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-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
|
--json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","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,11 +18,8 @@ def create_logger
|
||||||
logger
|
logger
|
||||||
end
|
end
|
||||||
|
|
||||||
# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler
|
# The SDK does not yet implement the optional GET SSE stream, so this example
|
||||||
# (e.g. `client.on_elicitation`) is registered. This example instead opens the stream with
|
# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream.
|
||||||
# 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)
|
||||||
|
|
||||||
|
|
@ -67,42 +64,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -115,17 +76,11 @@ 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
|
||||||
|
|
@ -149,6 +104,8 @@ 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
|
||||||
|
|
||||||
|
|
@ -169,15 +126,12 @@ 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 (raw streaming POST) ==="
|
puts "=== Calling tool: notification_tool ==="
|
||||||
raw_request_id += 1
|
response = client.call_tool(
|
||||||
stream_tool_call(
|
tool: notification_tool,
|
||||||
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