Skip to content

Commit 218510e

Browse files
committed
Fall back to the caller-configured scope when the server advertises none
The OAuth client's scope-selection step overwrote the scope a caller set on the provider on every 401, and when the server advertised no scopes at all it left the token request with no scope. The configured scope is now the last-resort tier after the WWW-Authenticate challenge and the server's scopes_supported, matching the TypeScript SDK. A source that yields no scopes (absent, null, or an empty list) now falls through to the next tier instead of pinning an empty scope.
1 parent e90a66b commit 218510e

7 files changed

Lines changed: 128 additions & 11 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
112112
What changed:
113113

114114
* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
115-
* `scope` is a space-separated string, the OAuth wire format.
115+
* `scope` is a space-separated string, the OAuth wire format. It is the fallback: a scope the server names in its `WWW-Authenticate` challenge or publishes in `scopes_supported` takes precedence.
116116
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
117117

118118
By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two.

src/mcp/client/auth/extensions/client_credentials.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def __init__(
5757
client_secret: The OAuth client secret.
5858
token_endpoint_auth_method: Authentication method for token endpoint.
5959
Either "client_secret_basic" (default) or "client_secret_post".
60-
scope: Optional space-separated list of scopes to request.
60+
scope: Optional space-separated list of scopes to request. Used when the server
61+
advertises no scopes; a scope from the `WWW-Authenticate` challenge or the
62+
server's published `scopes_supported` takes precedence.
6163
"""
6264
# Build minimal client_metadata for the base class
6365
client_metadata = OAuthClientMetadata(
@@ -271,7 +273,9 @@ def __init__(
271273
`SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs,
272274
`static_assertion_provider()` for pre-built JWTs, or provide your own
273275
callback for workload identity federation.
274-
scope: Optional space-separated list of scopes to request.
276+
scope: Optional space-separated list of scopes to request. Used when the server
277+
advertises no scopes; a scope from the `WWW-Authenticate` challenge or the
278+
server's published `scopes_supported` takes precedence.
275279
"""
276280
# Build minimal client_metadata for the base class
277281
client_metadata = OAuthClientMetadata(

src/mcp/client/auth/oauth2.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ class OAuthContext:
106106
timeout: float = 300.0
107107
client_metadata_url: str | None = None
108108

109+
# The scope the caller configured on the provider, kept separate from
110+
# client_metadata.scope: discovery overwrites the latter, and this survives
111+
# as the fallback when the server advertises no scopes.
112+
configured_scope: str | None = None
113+
109114
# Discovered metadata
110115
protected_resource_metadata: ProtectedResourceMetadata | None = None
111116
oauth_metadata: OAuthMetadata | None = None
@@ -273,6 +278,7 @@ def __init__(
273278
callback_handler=callback_handler,
274279
timeout=timeout,
275280
client_metadata_url=client_metadata_url,
281+
configured_scope=client_metadata.scope,
276282
)
277283
self._validate_resource_url_callback = validate_resource_url
278284
self._initialized = False
@@ -643,6 +649,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
643649
self.context.protected_resource_metadata,
644650
self.context.oauth_metadata,
645651
self.context.client_metadata.grant_types,
652+
self.context.configured_scope,
646653
)
647654

648655
# Step 4: Register client or use URL-based client ID (CIMD)
@@ -717,6 +724,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
717724
self.context.protected_resource_metadata,
718725
self.context.oauth_metadata,
719726
self.context.client_metadata.grant_types,
727+
self.context.configured_scope,
720728
)
721729
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
722730
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)

src/mcp/client/auth/utils.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,21 +100,30 @@ def get_client_metadata_scopes(
100100
protected_resource_metadata: ProtectedResourceMetadata | None,
101101
authorization_server_metadata: OAuthMetadata | None = None,
102102
client_grant_types: list[str] | None = None,
103+
configured_scope: str | None = None,
103104
) -> str | None:
104-
"""Select effective scopes and augment for refresh token support."""
105+
"""Select effective scopes and augment for refresh token support.
106+
107+
A source that yields no scopes (absent, null, or an empty list) offers no guidance, so
108+
selection falls through to the next source rather than treating "advertised nothing" as
109+
"request nothing".
110+
"""
105111
selected_scope: str | None = None
106112

107113
# MCP spec scope selection priority:
108114
# 1. WWW-Authenticate header scope
109115
# 2. PRM scopes_supported
110116
# 3. AS scopes_supported (SDK fallback)
111-
# 4. Omit scope parameter
112-
if www_authenticate_scope is not None:
117+
# 4. Scope the caller configured on the provider (SDK fallback)
118+
# 5. Omit scope parameter
119+
if www_authenticate_scope:
113120
selected_scope = www_authenticate_scope
114-
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
121+
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported:
115122
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
116-
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
123+
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported:
117124
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
125+
else:
126+
selected_scope = configured_scope or None
118127

119128
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
120129
if (

tests/client/test_auth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2797,6 +2797,63 @@ def test_union_scopes(previous: str | None, new: str | None, expected: str | Non
27972797
assert union_scopes(previous, new) == expected
27982798

27992799

2800+
def test_configured_scope_is_the_fallback_when_the_server_advertises_no_scopes():
2801+
"""The scope the caller set on the provider is requested when discovery yields no scopes.
2802+
2803+
SDK-defined tier below the spec's chain; without it the configured scope was silently dropped.
2804+
"""
2805+
prm = ProtectedResourceMetadata(
2806+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
2807+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2808+
scopes_supported=None,
2809+
)
2810+
2811+
scopes = get_client_metadata_scopes(
2812+
www_authenticate_scope=None,
2813+
protected_resource_metadata=prm,
2814+
authorization_server_metadata=None,
2815+
configured_scope="user",
2816+
)
2817+
2818+
assert scopes == "user"
2819+
2820+
2821+
def test_advertised_scopes_take_precedence_over_the_configured_scope():
2822+
"""Server-advertised scopes (here PRM) win over the caller-configured fallback."""
2823+
prm = ProtectedResourceMetadata(
2824+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
2825+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2826+
scopes_supported=["read"],
2827+
)
2828+
2829+
scopes = get_client_metadata_scopes(
2830+
www_authenticate_scope=None,
2831+
protected_resource_metadata=prm,
2832+
authorization_server_metadata=None,
2833+
configured_scope="user",
2834+
)
2835+
2836+
assert scopes == "read"
2837+
2838+
2839+
def test_empty_scopes_supported_falls_through_to_the_configured_scope():
2840+
"""An empty scopes_supported list advertises nothing, so selection continues to the next source."""
2841+
prm = ProtectedResourceMetadata(
2842+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
2843+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2844+
scopes_supported=[],
2845+
)
2846+
2847+
scopes = get_client_metadata_scopes(
2848+
www_authenticate_scope=None,
2849+
protected_resource_metadata=prm,
2850+
authorization_server_metadata=None,
2851+
configured_scope="user",
2852+
)
2853+
2854+
assert scopes == "user"
2855+
2856+
28002857
def test_credentials_match_issuer_same_issuer():
28012858
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
28022859
assert credentials_match_issuer(info, "https://as", None) is True

tests/interaction/_requirements.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3798,9 +3798,10 @@ def __post_init__(self) -> None:
37983798
note="OAuth is HTTP-only.",
37993799
divergence=Divergence(
38003800
note=(
3801-
"The SDK inserts an extra fallback step between PRM and omit: if the authorization "
3802-
"server metadata advertises scopes_supported, that list is used (client/auth/utils.py). "
3803-
"This is beyond the spec's two-step chain."
3801+
"The SDK inserts two extra fallback steps between PRM and omit (client/auth/utils.py): "
3802+
"if the authorization server metadata advertises scopes_supported, that list is used; "
3803+
"otherwise the scope the caller configured on the provider is requested. This is beyond "
3804+
"the spec's two-step chain."
38043805
),
38053806
),
38063807
),

tests/interaction/auth/test_lifecycle.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,44 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_
399399
assert decoded == "m2m-client:m2m-secret"
400400

401401

402+
@requirement("client-auth:scope-selection:priority")
403+
async def test_client_credentials_provider_requests_its_configured_scope_when_the_server_advertises_none() -> None:
404+
"""With no scopes advertised, the provider's configured `scope` reaches the `/token` body.
405+
406+
The server publishes no `scopes_supported` (empty `required_scopes`), so the spec's
407+
WWW-Authenticate/PRM chain yields nothing and the SDK falls back to the scope the caller
408+
configured on the provider — an SDK-defined tier below the spec's chain (see the divergence
409+
on the requirement).
410+
"""
411+
recorded, on_request = record_requests()
412+
provider = InMemoryAuthorizationServerProvider()
413+
server = Server("guarded", on_list_tools=list_tools)
414+
415+
auth = ClientCredentialsOAuthProvider(
416+
server_url=f"{BASE_URL}/mcp",
417+
storage=InMemoryTokenStorage(),
418+
client_id="m2m-client",
419+
client_secret="m2m-secret",
420+
scope="ops",
421+
)
422+
423+
with anyio.fail_after(5):
424+
async with connect_with_oauth(
425+
server,
426+
provider=provider,
427+
settings=auth_settings(required_scopes=[]),
428+
auth=auth,
429+
app_shim=m2m_token_shim(provider, scopes=["ops"]),
430+
on_request=on_request,
431+
) as (client, _):
432+
result = await client.list_tools()
433+
434+
assert result.tools[0].name == "echo"
435+
436+
[token_req] = find(recorded, "POST", "/token")
437+
assert form_body(token_req)["scope"] == "ops"
438+
439+
402440
@requirement("client-auth:private-key-jwt")
403441
async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_assertion() -> None:
404442
"""The private-key-JWT provider sends a `client_assertion` on the token request, with the issuer as audience.

0 commit comments

Comments
 (0)