Request offline_access scope when supported (SEP-2207)

## Motivation and Context

SEP-2207 describes how an OAuth client obtains refresh tokens: it
declares the `refresh_token` grant type in its client metadata and
requests the `offline_access` scope, but only when the authorization
server advertises `offline_access` in its metadata `scopes_supported`.
Requesting `offline_access` when the server does not list it is forbidden.

The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never
requested `offline_access`, so it could not obtain a refresh token from a server
that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs
and makes the `auth/offline-access-scope` conformance scenario pass
(previously it reported one warning, `sep-2207-client-metadata-grant-types`,
and was listed in the expected-failures baseline).

The change adds:

- `Flow#augment_scope_with_offline_access`, called from `run!` after
  `resolve_scope`, appends `offline_access` to the requested scope when
  both the client opted into refresh tokens (its registered
  `grant_types` include `refresh_token`) and the authorization server
  advertises `offline_access` in its metadata `scopes_supported`.
  Already-present `offline_access` is not duplicated. Gating on the server
  advertisement keeps the SDK from ever requesting the scope where it is not supported.
- `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection
  of the SDK's refresh support, which clears the SEP-2207 grant-types warning.
- `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`.

## How Has This Been Tested?

New `Flow` tests cover: `offline_access` is requested when the server advertises it
and the client declared the `refresh_token` grant; `offline_access` is NOT requested
when the client did not declare the grant; `offline_access` is NOT requested
when the server does not advertise it (even with the grant declared);
and `offline_access` is not duplicated when it is already part of the resolved scope.

Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings,
and `auth/offline-access-not-supported` still passes 13/13 (no regression).
`bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance`
are all green.

## Breaking Changes

None. `offline_access` is appended only when the client's own `grant_types` opt
into refresh tokens and the authorization server advertises the scope, so existing clients
that do neither see no change in the scope they request.
This commit is contained in:
Koichi ITO 2026-05-26 19:11:29 +09:00
commit fdca6f5a2f
5 changed files with 229 additions and 2 deletions

View file

@ -1913,6 +1913,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.
- 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).
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
```ruby ```ruby
require "mcp" require "mcp"

View file

@ -82,7 +82,7 @@ def build_oauth_provider(context)
client_metadata: { client_metadata: {
client_name: "ruby-sdk-conformance-client", client_name: "ruby-sdk-conformance-client",
redirect_uris: [redirect_uri], redirect_uris: [redirect_uri],
grant_types: ["authorization_code"], grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"], response_types: ["code"],
token_endpoint_auth_method: "none", token_endpoint_auth_method: "none",
}, },

View file

@ -12,4 +12,3 @@ client:
- auth/client-credentials-jwt - auth/client-credentials-jwt
- auth/client-credentials-basic - auth/client-credentials-basic
- auth/cross-app-access-complete-flow - auth/cross-app-access-complete-flow
- auth/offline-access-scope

View file

@ -59,6 +59,7 @@ module MCP
client_info = ensure_client_registered(as_metadata: as_metadata) client_info = ensure_client_registered(as_metadata: as_metadata)
effective_scope = resolve_scope(scope: scope, prm: prm) effective_scope = resolve_scope(scope: scope, prm: prm)
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
pkce = PKCE.generate pkce = PKCE.generate
state = SecureRandom.urlsafe_base64(32) state = SecureRandom.urlsafe_base64(32)
@ -403,6 +404,48 @@ module MCP
nil nil
end end
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
#
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
# when the authorization server advertises it in metadata `scopes_supported`.
# When the server advertises it and the client opted in, add it if absent.
#
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
# own request consistent with the AS's advertisement.
#
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
def normalize_offline_access_scope(scope, as_metadata:)
scopes = scope.to_s.split
if server_supports_offline_access?(as_metadata)
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
else
scopes.delete("offline_access")
end
scopes.empty? ? nil : scopes.join(" ")
end
def server_supports_offline_access?(as_metadata)
supported = as_metadata["scopes_supported"]
supported.is_a?(Array) && supported.include?("offline_access")
end
def wants_refresh_token?
metadata = @provider.client_metadata
grant_types = metadata[:grant_types] || metadata["grant_types"]
Array(grant_types).include?("refresh_token")
end
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:) def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"] authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint unless authorization_endpoint

View file

@ -68,6 +68,34 @@ module MCP
WebMock.reset! WebMock.reset!
end end
# Runs the full authorization flow and returns the `scope` query parameter
# sent on the authorization request. The caller stubs the AS metadata;
# this helper supplies a provider whose `grant_types` and optional pre-set
# `scope` drive the SEP-2207 offline_access decision.
def capture_authorization_scope(grant_types:, provider_scope: nil)
captured_scope = nil
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: grant_types,
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
query = URI.decode_www_form(url.query).to_h
captured_scope = query["scope"]
state_holder[:state] = query.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
scope: provider_scope,
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
captured_scope
end
def test_run_completes_full_authorization_flow def test_run_completes_full_authorization_flow
captured_authorization_url = nil captured_authorization_url = nil
state_value = nil state_value = nil
@ -112,6 +140,160 @@ module MCP
end end
end end
def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared
# SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access`
# when the AS advertises it, so it can obtain a refresh token.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:basic", "offline_access"],
),
)
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
assert_includes(captured.split, "offline_access")
end
def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared
# The AS advertises offline_access, but the client did not opt into refresh tokens,
# so the scope is not requested.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:basic", "offline_access"],
),
)
captured = capture_authorization_scope(grant_types: ["authorization_code"])
refute_includes(captured.to_s.split, "offline_access")
end
def test_run_does_not_request_offline_access_when_server_does_not_advertise_it
# SEP-2207 forbids requesting offline_access when the AS does not list it,
# even if the client declared the refresh_token grant type.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:basic", "mcp:read"],
),
)
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
refute_includes(captured.to_s.split, "offline_access")
end
def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it
# SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope
# (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope.
# SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's
# defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access`
# even though the AS has not opted in.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:basic"],
),
)
captured = capture_authorization_scope(
grant_types: ["authorization_code", "refresh_token"],
provider_scope: "mcp:basic offline_access",
)
refute_includes(captured.to_s.split, "offline_access")
assert_includes(captured.to_s.split, "mcp:basic")
end
def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it
# When stripping leaves an empty scope, no `scope` parameter is sent.
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:basic"],
),
)
captured = capture_authorization_scope(
grant_types: ["authorization_code", "refresh_token"],
provider_scope: "offline_access",
)
assert_nil(captured)
end
def test_run_does_not_duplicate_offline_access_already_in_scope
stub_request(:get, @as_metadata_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:basic", "offline_access"],
),
)
captured = capture_authorization_scope(
grant_types: ["authorization_code", "refresh_token"],
provider_scope: "mcp:basic offline_access",
)
assert_equal(1, captured.split.count("offline_access"))
end
def test_run_raises_on_state_mismatch def test_run_raises_on_state_mismatch
provider = Provider.new( provider = Provider.new(
client_metadata: { client_metadata: {