Skip to content

Commit 1d589ea

Browse files
committed
Add OAuth 2.1 resource server support
## Motivation and Context Per the MCP authorization specification, an HTTP-based MCP server acts as an OAuth 2.1 resource server, but the SDK offered no server-side pieces for that role even though its client side already runs the full authorization flow. This lands the self-contained primitives: bearer token verification (JWT and RFC 7662 introspection verifiers plus a custom-verifier contract), RFC 6750 `WWW-Authenticate` challenges, RFC 9728 Protected Resource Metadata serving, and a Rack middleware composing them. Token issuance is intentionally out of scope; deployments bring their own authorization server. The document is served by `ProtectedResourceMetadataMiddleware`, used at the top of the Rack stack, which answers its well-known path itself and passes everything else down, the way the TypeScript SDK's metadata router and the Python SDK's metadata routes do, so no mount at a hand-written path is needed. The middleware accepts the document class alone, so nothing that merely names a path is ever published as the document. ### Why token issuance stays out Leaving the authorization server role out follows the specification's own move: the 2025-03-26 revision had the MCP server issue tokens itself, 2025-06-18 turned it into a resource server that discovers an external authorization server through RFC 9728, and the reference SDKs went the same way (the TypeScript SDK froze its authorization server helpers in a legacy package for v2 and points new servers at a dedicated identity provider; the Python SDK keeps its embedded provider for compatibility only and tells new servers not to use it). ### Token verification The RFC 8707 audience check is mandatory on both built-in verifiers, JWT tokens must carry an expiry, JWKS and introspection response bodies are read under a byte cap, refreshing a JWKS cache no longer blocks other verifications on network I/O, oversized tokens are rejected before verification, and challenges answering credential-less requests omit the error code per RFC 6750. Scope checks accept a pluggable matcher because the 2026-07-28 revision requires honoring scope hierarchies. The matcher rides on the verified token, so `require_scopes!` and `scope?` inside handlers judge a hierarchy the same way the endpoint gate does. A custom verifier's own result object, which carries no scopes list, keeps its own `scope?` judgement at the gate even when a matcher is configured, and `required_scopes: nil` imposes no scope requirement, the same as leaving the option out. `JWTVerifier` refuses at construction what would otherwise weaken it silently: a missing document as its source of `iss` and `aud` (the jwt gem skips the check whose expected value is nil), `none` in the algorithm allowlist, an allowlist mixing HMAC with asymmetric algorithms, and a String key for asymmetric algorithms, so a token signed with the public key bytes as an HMAC secret never verifies. `ProtectedResourceMetadata` rejects an `extra` member that would override a validated one such as `resource` or `authorization_servers`. Both built-in verifiers take that document as `resource_metadata:` and read the expected `aud` from its `resource` and, for `JWTVerifier`, the expected `iss` from its authorization server, so neither value is configured a second time and neither can drift from what the server publishes; each verifier serves one authorization server, and `JWTVerifier` refuses a document naming several rather than advertise a server whose tokens it would reject. A stand-in document is held to the shapes the class guarantees, a String `resource` and an Array of String servers, so a wrong one fails by member name at construction. The `jwt` gem stays out of the gemspec: `JWTVerifier` lazily requires it, so only deployments that verify JWTs locally need it. ### Transport enforcement and session binding The Streamable HTTP transport gains a `token_verifier` option that enforces bearer authentication on every legacy and modern route ahead of any body parsing, with DNS rebinding checks still running first so that an unauthenticated probe never triggers verifier work. The verified token travels to handlers as `server_context.auth_info`, giving tools, prompts, resources, and completion handlers an authenticated identity to act on, and `server_context.require_scopes!` rejects an operation whose token lacks a scope, naming the missing scopes in the JSON-RPC error. Authentication is checked per HTTP request and never cached on the session, and each session is additionally bound to the token identity that initialized it, so a leaked session ID cannot be driven with another principal's token even when a custom `session_request_validator` is permissive. Composition with the OAuth Rack middleware keeps working: the transport falls back to the token the middleware stored in the Rack env. The middleware answers verification failures alone: a failure inside the wrapped app reaches that app's own error handling. The session binding covers the token's issuer as well, so a subject and client id pair repeated across identity providers behind a custom verifier does not collide, and a mismatch answers 404 exactly like an unknown session does, so a guessed session id is not confirmed to exist. The gate runs before the session's idle timer is touched, so a rejected request cannot keep someone else's session alive. `ServerSession#handle` honors its `auth_info` keyword only alongside a positional request. In the bare-keyword calling style every keyword belongs to the attacker-authored request body, so a stray `auth_info` member is folded back into the request instead of being trusted as a verified credential. ### Hardening Four further hardening measures are folded in. A stream whose token expires while it stays open (the legacy GET stream and the `subscriptions/listen` stream alike) is closed at its next keepalive check instead of outliving its credential. A malformed `exp` member from the authorization server is rejected as an invalid token instead of raising on comparison. Challenge parameters are scrubbed of invalid byte sequences, so a custom verifier's message cannot turn the 401 into a 500. The example refuses to boot without either a JWKS configuration or an explicit `DEV_MODE=1` opt-in, and its HS256 secret is generated per boot instead of shipping with the repository. It also refuses `JWKS_URI` together with `DEV_MODE=1`, since the demo token it prints is signed with the development secret. `AccessToken#inspect` omits the raw token as `to_h` already did, and a stream retains only the expiry of its token, never the credential. A verifier's other `OAuth::Error`s answer 401 like an invalid token instead of escaping the Rack call, and the bearer header is parsed as bytes, so an invalid byte sequence in it is rejected instead of raising. A JWK handed to `JWTVerifier` as `key:` is refused at construction, a failed unknown-`kid` refetch starts the cooldown all the same, and refresh failures of every kind, connection errors included, fall back to the cached keys for at most `jwks_max_stale:` seconds past the TTL (3600 by default) before verification fails. `IntrospectionVerifier` form-encodes its client credentials per RFC 6749 Section 2.3.1 and takes its `aud` from the document like `JWTVerifier` does. The stale bound holds under concurrent traffic as well: a request that finds another thread refreshing the key set waits for that refresh once the cached keys are past `jwks_max_stale:`, instead of serving them, and a refresh that failed is not retried on every request while the endpoint stays down. A JWKS document without a `keys` array never replaces a good cache, and a malformed HTTP response from the endpoint is treated like any other refresh failure. A JWT whose `exp` or `nbf` claim is not a number is rejected as an invalid token before the jwt gem compares it with the clock, an introspection `exp` that parses to infinity is rejected the same way, and both verifiers refuse at construction a `leeway:`, cache TTL, stale bound, or timeout that is negative or not finite, and a timeout of zero. A `jwks_max_stale:` of nil is refused there too, since the bound arithmetic has no meaning for it. A key set the jwt gem cannot load, a member that is not a JSON object or one it rejects outright, counts as a failed refresh instead of replacing the cache: the gem refuses such a set as a whole and never asks for a refetch over it, so the cache would otherwise fail every token until the TTL lapsed. The key set and its fetch time are judged as one snapshot, so a refresh another thread completes in the meantime cannot lend its fetch time to a retired set, and a thread that waited for that refresh serves its result when the fetch time moved and judges an unchanged set by the bound. `ProtectedResourceMetadata` drops `offline_access` from `scopes_supported`, which the specification tells a protected resource not to advertise and which the challenges already leave out, omits the member when nothing is left rather than advertising an empty list, and requires an Array so a bare String cannot pass for one scope. `OAuth::Error` defaults its `error_code` to `invalid_token`, so a custom verifier can subclass it and raise without knowing about the keyword. An authenticated SSE stream is closed after `max_stream_lifetime:` seconds, 30 minutes by default like `session_idle_timeout:`, whichever of that and the token's own expiry comes first: `exp` is optional in an introspection response, and without the cap a stream opened with an expiry-less token would outlive every later token check. An expiry that is not a number is ignored there instead of failing the stream. ### Documentation and example Document the resource-server role on an Authorization page of the documentation site, and ship a runnable example protected by the transport's built-in bearer enforcement. The example runs CORS ahead of authentication because a browser preflight carries no `Authorization` header, and a 401 answered to the preflight would fail CORS closed for browser-based clients. The session-ownership and modern-lifecycle notes on the transports page now account for the transport-level bearer enforcement instead of deferring authorization wholly to the deploying application. The server overview's feature list gains the matching bullet. The page also lists what the authorization server must be set up with and shows an introspection-backed verifier next to the JWT one. The Authorization page also spells out two limits left to deployers: a token carrying neither `sub` nor `client_id` binds no session, and introspection forwards unauthenticated traffic to the authorization server one for one, so rate limiting belongs ahead of the transport. It also gives the token length and response body caps, notes that revocation reaches an open session only through introspection, and marks the challenge parameters that depend on configuration. It tells deployers to publish `resource:` without a trailing slash, the canonical form the specification prefers, which the verifiers check `aud` against. The page's advice to build the verifier and the document once now names the reason beyond the JWKS cache: a document assembled per request from what the request says would hand the sender the `aud` and `iss` the verifier checks against. ## How Has This Been Tested? The end-to-end test drives the whole stack with the SDK's own OAuth client: 401 challenge, Protected Resource Metadata discovery, token grant, and an authenticated tool call observing the token. Unit tests cover each primitive: the JWT and introspection verifiers, the challenge builder, the metadata document and middleware, the authenticator, the middleware, and `AccessToken`. Transport tests cover bearer enforcement ahead of any body read on every route, `subscriptions/listen` included, the session binding across POST, GET, and DELETE together with its issuer member, the 404 answer being indistinguishable from an unknown session, the GET gate leaving the idle timer untouched, and streams closing at the keepalive once their token has expired. The hardening has tests of its own: a malformed `exp` is rejected as an invalid token, challenges stay valid UTF-8 for UTF-8 and binary messages alike, and `inspect` omits the token. Constructor validation and the handler-side matcher are covered too: each rejected `JWTVerifier` configuration, the reserved `extra` members, and a `require_scopes!` satisfied through the transport's matcher end to end. A verifier raising its own `OAuth::Error` and an `Authorization` header with invalid bytes are covered through the authenticator, the middleware, and the transport; the JWKS tests cover the refused JWK key, the cooldown after a failed refetch, the connection-error fallback, and the stale bound; the introspection tests cover the encoded credentials and the audience check; a middleware-wrapped transport carries the scope matcher into `require_scopes!`. Further tests pin the dropped `offline_access` and the omitted empty member, the String `scopes_supported` rejection, an `OAuth::Error` subclass raised without the keyword answering 401, and the stream cap: an expiry-less token bounded by it, a nearer token expiry winning, `nil` removing it, a token-less stream never capped, a non-number expiry leaving only the cap, and 0 or a negative value refused. The JWKS tests also cover the stale bound during a concurrent refresh, within it and beyond it, the cooldown after a failed TTL refresh, a document without a `keys` array leaving the cache intact, and a malformed HTTP response bridged by the cached keys; a wrongly typed `exp` or `nbf` claim in a JWT, an infinite introspection `exp`, and negative, non-finite, zero, or non-numeric options are rejected. A duck-typed verifier result is left to its own scope check under a matcher, `required_scopes: nil` imposes no requirement, an error raised inside the wrapped app passes through the middleware, and a GET stream opened without a token still runs the keepalive that detects a dropped peer. A nil `jwks_max_stale:` is refused, a key set the gem cannot load leaves the cache intact and is an infrastructure error on a cold start, and a retired snapshot is not served after another thread's refresh. The middleware tests cover the document, preflight, and method handling at the well-known path, other paths passing down, composition above a mounted endpoint, and the document requirement; the verifier tests cover the `iss` and `aud` taken from the document, a document naming several authorization servers being refused, and non-documents being rejected. A stand-in document with wrongly shaped members and a path-naming look-alike handed to the middleware are refused by name. The example was booted in development mode and driven with curl through the metadata document, the bare 401 challenge, an authenticated `initialize`, and a `whoami` tool call; without configuration it refuses to start. ## Breaking Changes None. A transport constructed without `token_verifier` behaves as before: the ownership gate keeps answering Origin and validator rejections with 403, the principal binding stays inactive without bearer authentication, and the `auth_info` keywords on `Server#handle`, `Server#handle_json`, and `ServerSession#handle` are additive. The `jwt` gem is a test-group dependency only.
1 parent 44af58c commit 1d589ea

42 files changed

Lines changed: 5311 additions & 90 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1"
2727
group :test do
2828
gem "event_stream_parser", ">= 1.0"
2929
gem "faraday", ">= 2.0"
30+
gem "jwt"
3031
gem "minitest", "~> 5.1", require: false
3132
gem "mocha"
3233
gem "webmock"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io.
66

77
## Features
88

9-
- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host
9+
- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host, with OAuth 2.1 resource-server protection
1010
- Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization
1111
- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration
1212
- Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination

docs/_client/authorization.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,8 @@ An authorization server that changes between authorization and refresh is caught
329329
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.
330330
The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse.
331331
Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization.
332+
333+
## Server Side
334+
335+
Protecting a server as an OAuth 2.1 resource server, verifying bearer tokens and serving the Protected Resource Metadata that this client discovers,
336+
is documented on the server [Authorization](/server/authorization/) page.

docs/_server/authorization.md

Lines changed: 329 additions & 0 deletions
Large diffs are not rendered by default.

docs/_server/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
layout: default
33
title: Configuration
4-
nav_order: 20
4+
nav_order: 21
55
---
66

77
# Configuration

docs/_server/custom-methods.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
layout: default
33
title: Custom Methods
4-
nav_order: 21
4+
nav_order: 22
55
---
66

77
# Custom Methods

docs/_server/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ It implements the Model Context Protocol specification, handling model context r
2525
- Supports roots (server-to-client filesystem boundary queries; deprecated as of 2026-07-28)
2626
- Supports sampling (server-to-client LLM completion requests; deprecated as of 2026-07-28)
2727
- Supports cursor-based pagination for list operations
28+
- Supports OAuth 2.1 resource-server protection (bearer verification, RFC 9728 Protected Resource Metadata, RFC 6750 challenges);
29+
see [Authorization](/server/authorization/)
2830
- Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
2931

3032
## Supported Methods

docs/_server/server-context.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
layout: default
33
title: Server Context
4-
nav_order: 19
4+
nav_order: 20
55
---
66

77
# Server Context
@@ -29,6 +29,14 @@ Note that the exception reporter does not receive this user-defined hash, and in
2929
callbacks omit it unless you opt in with `instrument_server_context`.
3030
See the [Configuration](/server/configuration/) page for the arguments they receive.
3131

32+
## Authenticated Identity
33+
34+
When the Streamable HTTP transport verifies bearer tokens (see [Authorization](/server/authorization/)), the verified
35+
`MCP::Server::OAuth::AccessToken` is available to handlers as `server_context.auth_info` (`server_context[:auth_info]` when the context
36+
is a plain Hash), and `server_context.require_scopes!("some:scope")` rejects the current operation with a JSON-RPC error when the token
37+
lacks a scope. Without bearer authentication `auth_info` is `nil` and `require_scopes!` fails closed;
38+
see [Accessing the Token in Handlers](/server/authorization/#accessing-the-token-in-handlers).
39+
3240
## Request-specific `_meta` Parameter
3341

3442
The MCP protocol supports a special [`_meta` parameter](https://modelcontextprotocol.io/specification/latest/basic#general-fields) in requests that allows clients to pass request-specific metadata. The server automatically extracts this parameter and makes it available to tools and prompts as a nested field within the `server_context`.

docs/_server/transports.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,10 @@ by `max_listen_subscriptions:`.
245245
### Session Ownership
246246

247247
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session
248-
existence and idle timeout only. It does not bind a session to a user, because the transport never receives
249-
an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
250-
so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD).
248+
existence and idle timeout only. Without bearer authentication it does not bind a session to a user, because the transport
249+
then receives no authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
250+
so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD);
251+
with `token_verifier:` configured the transport does it itself, as described at the end of this section.
251252

252253
The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }`
253254
on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs,
@@ -262,15 +263,18 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(
262263
)
263264
```
264265

265-
Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication),
266+
Without a validator or bearer authentication the transport does not enforce ownership. As a limited defense in depth (not authentication),
266267
it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only
267268
when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check.
268269
Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
270+
Bearer authentication configured with `token_verifier:` supplies one automatically: each session is then also bound
271+
to the token identity that initialized it; see [Authorization](/server/authorization/).
269272

270273
Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session,
271274
so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them
272275
(the per-request `Origin` validation of the DNS rebinding protection above still applies);
273-
on that path, authorization is enforced per request by the deploying application.
276+
on that path, bearer enforcement configured with `token_verifier:` still applies to every request; without it,
277+
authorization is enforced per request by the deploying application.
274278

275279
### Request Size Limits
276280

docs/examples.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Runnable examples live in [`examples/`](https://github.com/modelcontextprotocol/
1616
- [`http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_server.rb) - a Rack-based Streamable HTTP server with session management and SSE support
1717
- [`http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/http_client.rb) - a client driving the HTTP server through all MCP protocol methods
1818
- [`streamable_http_server.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server.rb) - an SSE-focused server with tools that trigger notifications and progress updates
19+
- [`streamable_http_server_oauth.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server_oauth.rb) - a Streamable HTTP server protected as an OAuth 2.1 resource server, with a `whoami` tool that reads the verified token; `DEV_MODE=1` signs demo tokens locally
1920
- [`streamable_http_client.rb`](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_client.rb) - an interactive, menu-driven client for testing the SSE stream
2021

2122
Each script is standalone and run from the repository root:

0 commit comments

Comments
 (0)