Compare commits

..
46 changed files with 297 additions and 4223 deletions

View file

@ -26,4 +26,4 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '24' # Specify the latest Node.js version.
- run: bundle exec rake conformance:test
- run: bundle exec rake conformance

View file

@ -15,8 +15,8 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
- `bundle install` - Install dependencies
- `rake test` - Run all tests
- `rake rubocop` - Run linter
- `rake` - Run linting, tests, and the conformance suite (default task)
- `bundle exec rake conformance:test` - Run the MCP conformance suite (see conformance/README.md)
- `rake` - Run tests and linting (default task)
- `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)
- `ruby -I lib -I test test/path/to/specific_test.rb` - Run single test file
- `gem build mcp.gemspec` - Build the gem

View file

@ -7,37 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.5.1] - 2026-09-09
This release keeps the HTTP client working with json 3.0. That release accepts the options of `JSON.parse`
as keywords only, and Faraday's JSON response middleware, through 2.14.3, passes them as a positional Hash,
which Ruby 3 no longer converts, so every JSON body the client received failed as an internal error.
The client now parses response bodies itself, the way it already handled streamed ones, and no longer
registers the middleware. A malformed JSON body delivered whole by an adapter without streaming support
now raises `RequestHandlerError` with `error_type: :parse_error`, as the streamed path already did.
### Fixed
- Parse HTTP client response bodies without Faraday's JSON middleware, which json 3.0 breaks (#546)
## [1.5.0] - 2026-09-05
This release makes the client answer a server's `ping` with the empty result the specification requires.
Earlier clients replied with Method not found over Streamable HTTP and stayed silent over stdio,
so a server that checks liveness dropped their long-lived sessions. On the OAuth side,
the embedding application can refuse an authorization request through `authorization_request_validator`,
and stored tokens are refreshed only against the authorization server that issued them:
when the server named for a session differs, the client reauthorizes instead of refreshing,
and the validator sees the newly named server. Tokens stored by earlier releases carry no issuer and keep refreshing.
### Added
- Add `authorization_request_validator` to let the embedding application refuse an authorization request (#539)
### Fixed
- Answer server-to-client pings in the client (#541)
## [1.4.0] - 2026-08-28
This release repairs the `subscriptions/listen` stream. A host that buffers its responses,

View file

@ -14,50 +14,42 @@ require "rubocop/rake_task"
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)"
task :test do |t|
next unless npx_available?(t.name)
desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
task :conformance do |t|
next unless npx_available?(t.name)
require_relative "conformance/server_runner"
require_relative "conformance/client_runner"
require_relative "conformance/server_runner"
require_relative "conformance/client_runner"
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
options[:verbose] = true if ENV["VERBOSE"]
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
options[:scenario] = ENV["SCENARIO"] if ENV["SCENARIO"]
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
options[:verbose] = true if ENV["VERBOSE"]
Conformance::ServerRunner.new(**options).run
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
end
desc "List available conformance scenarios"
task :list do |t|
next unless npx_available?(t.name)
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
end
desc "Start the conformance server (PORT)"
task :server do
require_relative "conformance/server"
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
Conformance::Server.new(**options).start
end
Conformance::ServerRunner.new(**options).run
Conformance::ClientRunner.new(**options.reject { |key, _value| key == :port }).run
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"
desc "List available conformance scenarios"
task :conformance_list do |t|
next unless npx_available?(t.name)
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
end
desc "Start the conformance server (PORT)"
task :conformance_server do
require_relative "conformance/server"
options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
Conformance::Server.new(**options).start
end
namespace :docs do
desc "Serve the documentation site locally at http://localhost:4000 (PORT)"

View file

@ -12,7 +12,7 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
### Run all scenarios
```bash
bundle exec rake conformance:test
bundle exec rake conformance
```
Runs both server and client conformance tests in sequence. Server conformance starts the
@ -31,20 +31,20 @@ are allowed to fail without affecting the exit code.
```bash
# Run a single server scenario
bundle exec rake conformance:test SCENARIO=ping
bundle exec rake conformance SCENARIO=ping
# 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
bundle exec rake conformance:server PORT=3000
bundle exec rake conformance_server PORT=3000
```
### Start the server and test separately
```bash
# Terminal 1: start the server
bundle exec rake conformance:server
bundle exec rake conformance_server
# Terminal 2: run all scenarios
npx @modelcontextprotocol/conformance@alpha server --url http://localhost:9292/mcp
@ -59,7 +59,7 @@ on a single scenario. Stop the server with Ctrl+C when done.
### List available scenarios
```bash
bundle exec rake conformance:list
bundle exec rake conformance_list
```
Prints all scenario names that can be passed to `SCENARIO`.
@ -76,7 +76,7 @@ repository with the conformance server running:
```bash
# Terminal 1 (this repository): start the conformance server
bundle exec rake conformance:server
bundle exec rake conformance_server
# Terminal 2 (conformance repository): run the tier audit skill as a slash command in Claude Code
/mcp-sdk-tier-audit /path/to/modelcontextprotocol/ruby-sdk http://localhost:9292/mcp
@ -102,4 +102,4 @@ conformance/
Known-failing scenarios are registered in `conformance/expected_failures.yml`. They are allowed to
fail without affecting the exit code and are tracked to catch regressions.
These are shown in the output of `bundle exec rake conformance:test`.
These are shown in the output of `bundle exec rake conformance`.

View file

@ -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),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
- Fall back to the legacy 2025-03-26 discovery when the server publishes no Protected Resource Metadata, matching the TypeScript and Python SDKs: the MCP server's origin acts
as the authorization base URL, its metadata is fetched from `<origin>/.well-known/oauth-authorization-server` and must name that origin as its `issuer` (RFC 8414 Section 3.3),
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.
When no Protected Resource Metadata candidate serves a JSON object, a request that could not reach the server, or that returned a `5xx` or `429`,
raises `Flow::MetadataUnreachableError` instead of triggering that fallback, and a body over the response cap is refused outright.
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
`ClientCredentialsProvider` and `CrossAppAccessProvider` refresh the same way and fall back to their own grant instead;
their refresh also requires the `issuer` the SDK records on the tokens, so tokens stored without it run the grant again.
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
@ -97,16 +93,10 @@ Required keyword arguments to `Provider.new`:
Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
- `http_client_customizer`: Callable invoked with the Faraday connection the SDK builds for the OAuth flow's own requests, after its defaults and before its origin guard.
See [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client).
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
(portable CIMD `client_id`s are kept). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what
lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members
instead drops these bindings with no error.
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
When the authorization server advertises `client_id_metadata_document_supported: true`,
@ -118,14 +108,6 @@ Optional keyword arguments:
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
Defaults to `nil`, which adds nothing. The authorization request is not affected.
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
when the provider is built, and the accepted Hash is copied and frozen.
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
the flow checks the returned value by the same rules and raises the same error before the token request is sent.
{: .warning }
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
@ -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
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant is not expected to issue a refresh token (RFC 6749 Section 4.4.3);
a refresh token the authorization server issues anyway is used on the next `401`.
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
@ -207,7 +167,6 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
)
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
@ -223,15 +182,13 @@ Keyword arguments:
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.
- `scope`, `storage`: Optional, same meaning as on `Provider`.
### Cross-App Access (JWT Bearer) Grant
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
A refresh token the authorization server issues is exchanged on the next `401` with the stored client secret, without calling `assertion_provider` again.
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
@ -262,39 +219,7 @@ Keyword arguments:
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
### Customizing the OAuth HTTP Client
The requests the OAuth flow makes (Protected Resource Metadata discovery on the MCP server's origin, authorization server metadata discovery,
dynamic client registration, and every token request the flow sends, whether the first exchange, a refresh, or a step-up) go over a Faraday connection of their own,
not over the transport's connection: the transport's is bound to the MCP server URL and carries the `headers:` and the customizer block meant for that server.
To add middleware to the OAuth flow's connection, or to swap its adapter, pass `http_client_customizer:` to the provider:
```ruby
provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
http_client_customizer: ->(faraday) { faraday.use MyApp::Middleware::HttpRecorder },
)
```
The callable receives the `Faraday::Connection` after the SDK has applied its defaults and registered the middleware that records the requested URL,
and before the SDK registers its origin guard, the same position the transport's customizer block has on the MCP server connection.
It may be invoked more than once, and from more than one thread at a time, so keep it free of side effects and safe to run concurrently;
today it runs once per authorization attempt, but that is not a promise.
A few constraints follow from the checks described below:
- Do not add redirect-following middleware. Every destination check runs against the URL as written, so a request that middleware added by the customizer would send
to a different origin after the SDK has recorded the requested URL, whether by following a `3xx` or by rewriting the URL, is refused with `Flow::DestinationMismatchError`
before it reaches the adapter, as is a request that reaches the guard without that record. Middleware inserted ahead of the record with `builder.insert(0, ...)`
that rewrites the URL first or rebuilds the environment is outside the guard, as is following done inside an adapter, so leave both off.
- Leave `Accept-Encoding` unset. The response cap below is measured on decoded bytes, and claiming the header turns Net::HTTP's decoding off.
- Do not add Faraday's `raise_error` middleware. The flow reads statuses itself, both to tell an absent metadata document from a failed request
and to turn a token endpoint error into `Flow::InvalidGrantError`.
- With an adapter that does not stream through `on_data`, the response cap is applied once the body has been buffered rather than as it arrives.
- A middleware that records requests sees the client credentials on token requests (`Authorization: Basic`, `client_secret`, `client_assertion`), refresh tokens,
and the access tokens in token responses; redact them before they reach a log.
- `scope`, `storage`: Optional, same meaning as on `Provider`.
### 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
the HTTP client connects to a moment later. The same-origin rule is what protects the `resource_metadata` URL, which is the only one of these a server supplies directly.
On the connection the SDK builds, a middleware that would send a request to a different origin, by following a `3xx` or by rewriting the URL, is refused before the request
goes out (see [Customizing the OAuth HTTP Client](#customizing-the-oauth-http-client)). A connection supplied through `MCP::Client::OAuth::Flow.new(http_client_factory:)`
replaces that one, the provider's `http_client_customizer` and the guard included, so do not add redirect-following middleware to it: every check above runs against
the URL as written, and a connection that follows a `3xx` on its own would reach hosts these rules just refused.
A factory that wants to keep them can return `MCP::Client::OAuth::Flow.build_http_client(customizer)`, the connection the SDK builds for itself.
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
the URL as written, so a connection that follows a `3xx` on its own would reach hosts these rules just refused.
The SDK also bounds what those endpoints may return. A discovery, dynamic client registration, token, or token exchange response is refused once it passes 4 MiB,
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.
Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through
`http_client_factory:` is bounded as well, so there is no way to opt out of it.
### Reviewing the authorization request
The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want
your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes
it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects,
so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on
MCP servers rather than on clients.
`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so:
```ruby
ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"]
provider = MCP::Client::OAuth::Provider.new(
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
authorization_request_validator: ->(request) {
ALLOWED_ISSUERS.include?(request.authorization_server)
},
)
```
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither
the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification
can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide
(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes.
`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized
(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one,
the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce
a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured.
Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path,
and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising.
The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it:
```ruby
ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] }
provider = MCP::Client::OAuth::Provider.new(
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
authorization_request_validator: ->(request) {
ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) }
},
)
```
A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them.
It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential,
or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened,
and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server
the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected.
Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it,
while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure.
The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification
requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for,
which is what every MCP SDK does today.
It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for.
An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to
present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is.
The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse.
Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization.

View file

@ -17,8 +17,7 @@ This class supports:
- Lifecycle negotiation and connection via `MCP::Client#connect`, adopting the modern lifecycle
when the server serves it; see [Lifecycle](/client/lifecycle/)
- Server discovery via the `server/discover` method (`MCP::Client#discover`); see [Explicit Discovery](/client/lifecycle/#explicit-discovery)
- Liveness check via the `ping` method (`MCP::Client#ping`), and automatic answers to server pings;
see [Answering Server Pings](/client/ping/#answering-server-pings)
- Liveness check via the `ping` method (`MCP::Client#ping`)
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
- Resource listing via the `resources/list` method (`MCP::Client#resources`)

View file

@ -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)
propagate as exceptions raised by the transport layer.
## Answering Server Pings
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with
the empty result - no handler is needed. Over HTTP a ping reaches the client only on a stream that is already open
(the SSE response of an in-flight POST, or the GET listening stream); the client never opens a stream just to receive pings.
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when
the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is
dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:`
on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits.
To answer pings yourself, register `transport.on_server_request("ping")` on `MCP::Client::HTTP`; it replaces
the automatic answer and, like any handler registered there, opens the standalone GET listening stream on `connect`,
as described on the [Transports](/client/transports/#server-to-client-requests-elicitation) page.
## Server Side
How servers answer `ping` requests and ping the client themselves is documented on

View file

@ -59,27 +59,12 @@ The stdio transport automatically handles:
- Spawning the server process with `Open3.popen3`
- MCP protocol initialization handshake (`initialize` request + `notifications/initialized`)
- JSON-RPC 2.0 message framing over newline-delimited JSON
- Answering server `ping` requests; see [Answering Server Pings](/client/ping/#answering-server-pings)
{: .note }
> `ping` is the only server-to-client request answered over stdio. A wire-level `elicitation/create`
> or `sampling/createMessage` is ignored on this transport, so a server that sends one waits for an answer
> that never comes; use Streamable HTTP for those, as described below. The SEP-2322 `input_required` route,
> which is how the modern lifecycle asks for the same input, works on every transport, so `on_elicitation`
> and `on_sampling` handlers still fire over stdio; see [Multi Round-Trip Requests](/client/mrtr/).
## HTTP Transport Layer
Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
You'll need to add `faraday` as a dependency in order to use the HTTP transport layer, and `event_stream_parser`
to read SSE (`text/event-stream`) responses.
Whether a response is JSON or SSE is the server's choice, made for each response, and a client must accept both,
so a client written for arbitrary servers needs both gems. This SDK's own server picks SSE by default and always
does on the modern lifecycle, and the listening stream that `on_elicitation` and `on_sampling` open is SSE as well.
`event_stream_parser` is loaded on the first SSE response, so it can be left out only against a server known to
answer with JSON alone, such as this SDK's server in [JSON response mode](/server/transports/#json-response-mode)
serving handshake-lifecycle clients, and only while no listening stream is opened:
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:
```ruby
gem "mcp"
@ -248,15 +233,6 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
end
```
The block customizes only the connection to the MCP server. The connection the OAuth flow uses for its own requests is customized through
the provider's `http_client_customizer:` keyword instead; see [Customizing the OAuth HTTP Client](/client/authorization/#customizing-the-oauth-http-client).
{: .note }
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
> the SSE streaming callback of another response, re-entering the connection on the same thread.
> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter
> with persistent connections must tolerate that re-entry.
## Custom Transports
If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface:

View file

@ -22,8 +22,8 @@ It implements the Model Context Protocol specification, handling model context r
- Supports resource registration and retrieval
- Supports stdio and Streamable HTTP (including SSE) transports
- Supports notifications for list changes (tools, prompts, resources)
- Supports roots (server-to-client filesystem boundary queries; deprecated as of 2026-07-28)
- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28)
- Supports roots (server-to-client filesystem boundary queries)
- Supports sampling (server-to-client LLM completion requests)
- Supports cursor-based pagination for list operations
- 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
the client for additional input instead of performing a server-initiated request; see [Multi Round-Trip Requests](/server/mrtr/)
- `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/call` - Invokes a specific tool with provided arguments
- `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/unsubscribe` - Unsubscribes from updates for a specific resource
- `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)
- `sampling/createMessage` - Requests LLM completion 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)
- `elicitation/create` - Requests user input from the client (server-to-client)

View file

@ -48,5 +48,3 @@ Server-to-client requests are bounded by a timeout on the Streamable HTTP transp
## Client Side
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).

View file

@ -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
The stream is served on the Streamable HTTP modern path; stdio answers `-32601`.

View file

@ -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:
```ruby
gem "mcp"
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.

View file

@ -98,7 +98,7 @@ A specialized HTTP server designed to test and demonstrate Server-Sent Events (S
**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
**Usage:**
@ -124,15 +124,14 @@ An interactive client that connects to the SSE stream and provides a menu-driven
1. Start the SSE test server in one terminal:
```console
$ ruby examples/streamable_http_server.rb
```
```console
$ ruby examples/streamable_http_server.rb
```
2. Run the SSE test client in another terminal:
```console
$ ruby examples/streamable_http_client.rb
```
```console
$ ruby examples/streamable_http_client.rb
```
The client will:
@ -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.
### 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
[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
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":"2025-11-25","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":"2024-11-05","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
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
# Send immediate notification
curl -i http://localhost:9393 \
-H "Accept: application/json, text/event-stream" \
-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
### Protocol Flow
@ -319,15 +236,13 @@ Initialize a session:
```console
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"}}}'
--json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
```
List tools (using the session ID from initialization):
```console
curl -i http://localhost:9292 \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
--json '{"jsonrpc":"2.0","method":"tools/list","id":2}'
```
@ -336,7 +251,6 @@ Call a tool:
```console
curl -i http://localhost:9292 \
-H "Accept: application/json, text/event-stream" \
-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}}}'
```

View file

@ -4,7 +4,7 @@ require "net/http"
require "json"
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
def initialize(base_url = "http://localhost:9292")
@base_url = base_url
@ -30,51 +30,19 @@ class MCPHTTPClient
response = http.request(request)
# Store session ID if provided.
# Store session ID if provided
if response["Mcp-Session-Id"]
@session_id = response["Mcp-Session-Id"]
puts "Session ID: #{@session_id}"
end
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)
JSON.parse(response.body)
end
def initialize_session
puts "=== Initializing session ==="
result = send_request("initialize", {
protocolVersion: "2025-11-25",
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "example_client",
@ -86,14 +54,6 @@ class MCPHTTPClient
result
end
def notify_initialized
puts "=== Sending notifications/initialized ==="
response = send_notification("notifications/initialized")
puts "Response status: #{response.code} #{response.message}"
response
end
def ping
puts "=== Sending ping ==="
result = send_request("ping")
@ -187,40 +147,37 @@ def main
client = MCPHTTPClient.new
begin
# Initialize session.
# Initialize session
client.initialize_session
# Complete the handshake.
client.notify_initialized
# Test ping.
# Test ping
client.ping
# List available tools.
# List available 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 })
# Call the echo tool.
# Call the echo tool
client.call_tool("echo", { message: "Hello from client!" })
# List prompts.
# 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" })
# List resources.
# List resources
client.list_resources
# Read a resource (the URI registered by http_server.rb).
client.read_resource("https://test_resource.invalid")
# Read a resource
client.read_resource("test_resource")
rescue => e
puts "Error: #{e.message}"
puts e.backtrace
ensure
# Clean up session.
# Clean up session
client.close_session
end
end

View file

@ -56,7 +56,6 @@ end
# Set up the server
server = MCP::Server.new(
name: "example_http_server",
version: "1.0.0",
tools: [ExampleTool],
prompts: [ExamplePrompt],
resources: [
@ -170,9 +169,7 @@ puts <<~MESSAGE
Starting MCP HTTP server on http://localhost:9292
Use POST requests to initialize and send JSON-RPC commands
Example initialization:
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"}}}'
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"}}}'
The server will return a session ID in the Mcp-Session-Id header.
Use this session ID for subsequent requests.

View file

@ -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

View file

@ -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")

View file

@ -36,7 +36,7 @@ per the MCP Streamable HTTP transport spec. Responses arrive as SSE `data:` line
```console
SESSION_ID=$(curl -s -D - -o /dev/null http://localhost:9292/mcp \
-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')
```

View file

@ -18,11 +18,8 @@ def create_logger
logger
end
# MCP::Client::HTTP opens the optional GET SSE stream itself once a server-request handler
# (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).
# The SDK does not yet implement the optional GET SSE stream, so this example
# uses MCP::Client for JSON-RPC requests and raw Net::HTTP for the event stream.
def connect_sse(session_id, logger)
uri = URI(SERVER_URL)
@ -67,42 +64,6 @@ def print_response(response)
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
logger = create_logger
@ -115,17 +76,11 @@ def main
http_transport = MCP::Client::HTTP.new(url: SERVER_URL)
client = MCP::Client.new(transport: http_transport)
sse_thread = nil
raw_request_id = 0
begin
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(
client_info: { name: "streamable-http-client", version: "1.0" },
mode: :legacy,
)
puts <<~MESSAGE
@ -149,6 +104,8 @@ def main
sse_thread = Thread.new { connect_sse(http_transport.session_id, logger) }
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
puts <<~MENU.chomp
@ -169,15 +126,12 @@ def main
print("Enter delay in seconds (0 for immediate): ")
delay = gets.chomp.to_f
puts "=== Calling tool: notification_tool (raw streaming POST) ==="
raw_request_id += 1
stream_tool_call(
http_transport.session_id,
"raw-#{raw_request_id}",
name: notification_tool.name,
puts "=== Calling tool: notification_tool ==="
response = client.call_tool(
tool: notification_tool,
arguments: { message: message, delay: delay },
logger: logger,
)
print_response(response)
else
puts "notification_tool not available"
end

Some files were not shown because too many files have changed in this diff Show more