Skip to content

Commit fdca6f5

Browse files
committed
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.
1 parent f6e43d9 commit fdca6f5

5 files changed

Lines changed: 229 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
19131913
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
19141914
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.
19151915
- 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).
1916+
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
1917+
`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
1918+
`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.
19161919

19171920
```ruby
19181921
require "mcp"

conformance/client.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def build_oauth_provider(context)
8282
client_metadata: {
8383
client_name: "ruby-sdk-conformance-client",
8484
redirect_uris: [redirect_uri],
85-
grant_types: ["authorization_code"],
85+
grant_types: ["authorization_code", "refresh_token"],
8686
response_types: ["code"],
8787
token_endpoint_auth_method: "none",
8888
},

conformance/expected_failures.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,3 @@ client:
1212
- auth/client-credentials-jwt
1313
- auth/client-credentials-basic
1414
- auth/cross-app-access-complete-flow
15-
- auth/offline-access-scope

lib/mcp/client/oauth/flow.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil)
5959
client_info = ensure_client_registered(as_metadata: as_metadata)
6060

6161
effective_scope = resolve_scope(scope: scope, prm: prm)
62+
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
6263
pkce = PKCE.generate
6364
state = SecureRandom.urlsafe_base64(32)
6465

@@ -403,6 +404,48 @@ def resolve_scope(scope:, prm:)
403404
nil
404405
end
405406

407+
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
408+
#
409+
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
410+
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
411+
# when the authorization server advertises it in metadata `scopes_supported`.
412+
# When the server advertises it and the client opted in, add it if absent.
413+
#
414+
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
415+
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
416+
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
417+
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
418+
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
419+
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
420+
# own request consistent with the AS's advertisement.
421+
#
422+
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
423+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
424+
def normalize_offline_access_scope(scope, as_metadata:)
425+
scopes = scope.to_s.split
426+
427+
if server_supports_offline_access?(as_metadata)
428+
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
429+
else
430+
scopes.delete("offline_access")
431+
end
432+
433+
scopes.empty? ? nil : scopes.join(" ")
434+
end
435+
436+
def server_supports_offline_access?(as_metadata)
437+
supported = as_metadata["scopes_supported"]
438+
439+
supported.is_a?(Array) && supported.include?("offline_access")
440+
end
441+
442+
def wants_refresh_token?
443+
metadata = @provider.client_metadata
444+
grant_types = metadata[:grant_types] || metadata["grant_types"]
445+
446+
Array(grant_types).include?("refresh_token")
447+
end
448+
406449
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
407450
authorization_endpoint = as_metadata["authorization_endpoint"]
408451
unless authorization_endpoint

test/mcp/client/oauth/flow_test.rb

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ def teardown
6868
WebMock.reset!
6969
end
7070

71+
# Runs the full authorization flow and returns the `scope` query parameter
72+
# sent on the authorization request. The caller stubs the AS metadata;
73+
# this helper supplies a provider whose `grant_types` and optional pre-set
74+
# `scope` drive the SEP-2207 offline_access decision.
75+
def capture_authorization_scope(grant_types:, provider_scope: nil)
76+
captured_scope = nil
77+
state_holder = {}
78+
provider = Provider.new(
79+
client_metadata: {
80+
redirect_uris: ["http://localhost:0/callback"],
81+
grant_types: grant_types,
82+
response_types: ["code"],
83+
token_endpoint_auth_method: "none",
84+
},
85+
redirect_uri: "http://localhost:0/callback",
86+
redirect_handler: ->(url) {
87+
query = URI.decode_www_form(url.query).to_h
88+
captured_scope = query["scope"]
89+
state_holder[:state] = query.fetch("state")
90+
},
91+
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
92+
scope: provider_scope,
93+
)
94+
95+
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
96+
captured_scope
97+
end
98+
7199
def test_run_completes_full_authorization_flow
72100
captured_authorization_url = nil
73101
state_value = nil
@@ -112,6 +140,160 @@ def test_run_completes_full_authorization_flow
112140
end
113141
end
114142

143+
def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared
144+
# SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access`
145+
# when the AS advertises it, so it can obtain a refresh token.
146+
stub_request(:get, @as_metadata_url).to_return(
147+
status: 200,
148+
headers: { "Content-Type" => "application/json" },
149+
body: JSON.generate(
150+
issuer: @auth_base,
151+
authorization_endpoint: "#{@auth_base}/authorize",
152+
token_endpoint: "#{@auth_base}/token",
153+
registration_endpoint: "#{@auth_base}/register",
154+
response_types_supported: ["code"],
155+
grant_types_supported: ["authorization_code", "refresh_token"],
156+
code_challenge_methods_supported: ["S256"],
157+
token_endpoint_auth_methods_supported: ["none"],
158+
scopes_supported: ["mcp:basic", "offline_access"],
159+
),
160+
)
161+
162+
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
163+
164+
assert_includes(captured.split, "offline_access")
165+
end
166+
167+
def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared
168+
# The AS advertises offline_access, but the client did not opt into refresh tokens,
169+
# so the scope is not requested.
170+
stub_request(:get, @as_metadata_url).to_return(
171+
status: 200,
172+
headers: { "Content-Type" => "application/json" },
173+
body: JSON.generate(
174+
issuer: @auth_base,
175+
authorization_endpoint: "#{@auth_base}/authorize",
176+
token_endpoint: "#{@auth_base}/token",
177+
registration_endpoint: "#{@auth_base}/register",
178+
response_types_supported: ["code"],
179+
grant_types_supported: ["authorization_code"],
180+
code_challenge_methods_supported: ["S256"],
181+
token_endpoint_auth_methods_supported: ["none"],
182+
scopes_supported: ["mcp:basic", "offline_access"],
183+
),
184+
)
185+
186+
captured = capture_authorization_scope(grant_types: ["authorization_code"])
187+
188+
refute_includes(captured.to_s.split, "offline_access")
189+
end
190+
191+
def test_run_does_not_request_offline_access_when_server_does_not_advertise_it
192+
# SEP-2207 forbids requesting offline_access when the AS does not list it,
193+
# even if the client declared the refresh_token grant type.
194+
stub_request(:get, @as_metadata_url).to_return(
195+
status: 200,
196+
headers: { "Content-Type" => "application/json" },
197+
body: JSON.generate(
198+
issuer: @auth_base,
199+
authorization_endpoint: "#{@auth_base}/authorize",
200+
token_endpoint: "#{@auth_base}/token",
201+
registration_endpoint: "#{@auth_base}/register",
202+
response_types_supported: ["code"],
203+
grant_types_supported: ["authorization_code", "refresh_token"],
204+
code_challenge_methods_supported: ["S256"],
205+
token_endpoint_auth_methods_supported: ["none"],
206+
scopes_supported: ["mcp:basic", "mcp:read"],
207+
),
208+
)
209+
210+
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
211+
212+
refute_includes(captured.to_s.split, "offline_access")
213+
end
214+
215+
def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it
216+
# SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope
217+
# (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope.
218+
# SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's
219+
# defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access`
220+
# even though the AS has not opted in.
221+
stub_request(:get, @as_metadata_url).to_return(
222+
status: 200,
223+
headers: { "Content-Type" => "application/json" },
224+
body: JSON.generate(
225+
issuer: @auth_base,
226+
authorization_endpoint: "#{@auth_base}/authorize",
227+
token_endpoint: "#{@auth_base}/token",
228+
registration_endpoint: "#{@auth_base}/register",
229+
response_types_supported: ["code"],
230+
grant_types_supported: ["authorization_code", "refresh_token"],
231+
code_challenge_methods_supported: ["S256"],
232+
token_endpoint_auth_methods_supported: ["none"],
233+
scopes_supported: ["mcp:basic"],
234+
),
235+
)
236+
237+
captured = capture_authorization_scope(
238+
grant_types: ["authorization_code", "refresh_token"],
239+
provider_scope: "mcp:basic offline_access",
240+
)
241+
242+
refute_includes(captured.to_s.split, "offline_access")
243+
assert_includes(captured.to_s.split, "mcp:basic")
244+
end
245+
246+
def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it
247+
# When stripping leaves an empty scope, no `scope` parameter is sent.
248+
stub_request(:get, @as_metadata_url).to_return(
249+
status: 200,
250+
headers: { "Content-Type" => "application/json" },
251+
body: JSON.generate(
252+
issuer: @auth_base,
253+
authorization_endpoint: "#{@auth_base}/authorize",
254+
token_endpoint: "#{@auth_base}/token",
255+
registration_endpoint: "#{@auth_base}/register",
256+
response_types_supported: ["code"],
257+
grant_types_supported: ["authorization_code", "refresh_token"],
258+
code_challenge_methods_supported: ["S256"],
259+
token_endpoint_auth_methods_supported: ["none"],
260+
scopes_supported: ["mcp:basic"],
261+
),
262+
)
263+
264+
captured = capture_authorization_scope(
265+
grant_types: ["authorization_code", "refresh_token"],
266+
provider_scope: "offline_access",
267+
)
268+
269+
assert_nil(captured)
270+
end
271+
272+
def test_run_does_not_duplicate_offline_access_already_in_scope
273+
stub_request(:get, @as_metadata_url).to_return(
274+
status: 200,
275+
headers: { "Content-Type" => "application/json" },
276+
body: JSON.generate(
277+
issuer: @auth_base,
278+
authorization_endpoint: "#{@auth_base}/authorize",
279+
token_endpoint: "#{@auth_base}/token",
280+
registration_endpoint: "#{@auth_base}/register",
281+
response_types_supported: ["code"],
282+
grant_types_supported: ["authorization_code", "refresh_token"],
283+
code_challenge_methods_supported: ["S256"],
284+
token_endpoint_auth_methods_supported: ["none"],
285+
scopes_supported: ["mcp:basic", "offline_access"],
286+
),
287+
)
288+
289+
captured = capture_authorization_scope(
290+
grant_types: ["authorization_code", "refresh_token"],
291+
provider_scope: "mcp:basic offline_access",
292+
)
293+
294+
assert_equal(1, captured.split.count("offline_access"))
295+
end
296+
115297
def test_run_raises_on_state_mismatch
116298
provider = Provider.new(
117299
client_metadata: {

0 commit comments

Comments
 (0)