From 218510ee1c972ca2c5296ae6eadac30e942ebbbe Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:47:59 +0000 Subject: [PATCH] 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. --- docs/client/oauth-clients.md | 2 +- .../auth/extensions/client_credentials.py | 8 ++- src/mcp/client/auth/oauth2.py | 8 +++ src/mcp/client/auth/utils.py | 19 +++++-- tests/client/test_auth.py | 57 +++++++++++++++++++ tests/interaction/_requirements.py | 7 ++- tests/interaction/auth/test_lifecycle.py | 38 +++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 2ce67f815a..83b8476d1c 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -112,7 +112,7 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli What changed: * 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. -* `scope` is a space-separated string, the OAuth wire format. +* `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. * Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`. 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. diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index d7da71c8f5..3ca80fd369 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -57,7 +57,9 @@ def __init__( client_secret: The OAuth client secret. token_endpoint_auth_method: Authentication method for token endpoint. Either "client_secret_basic" (default) or "client_secret_post". - scope: Optional space-separated list of scopes to request. + scope: Optional space-separated list of scopes to request. Used when the server + advertises no scopes; a scope from the `WWW-Authenticate` challenge or the + server's published `scopes_supported` takes precedence. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( @@ -271,7 +273,9 @@ def __init__( `SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs, `static_assertion_provider()` for pre-built JWTs, or provide your own callback for workload identity federation. - scope: Optional space-separated list of scopes to request. + scope: Optional space-separated list of scopes to request. Used when the server + advertises no scopes; a scope from the `WWW-Authenticate` challenge or the + server's published `scopes_supported` takes precedence. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 073e6a8039..e8c62368a0 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -106,6 +106,11 @@ class OAuthContext: timeout: float = 300.0 client_metadata_url: str | None = None + # The scope the caller configured on the provider, kept separate from + # client_metadata.scope: discovery overwrites the latter, and this survives + # as the fallback when the server advertises no scopes. + configured_scope: str | None = None + # Discovered metadata protected_resource_metadata: ProtectedResourceMetadata | None = None oauth_metadata: OAuthMetadata | None = None @@ -273,6 +278,7 @@ def __init__( callback_handler=callback_handler, timeout=timeout, client_metadata_url=client_metadata_url, + configured_scope=client_metadata.scope, ) self._validate_resource_url_callback = validate_resource_url self._initialized = False @@ -643,6 +649,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx self.context.protected_resource_metadata, self.context.oauth_metadata, self.context.client_metadata.grant_types, + self.context.configured_scope, ) # 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 self.context.protected_resource_metadata, self.context.oauth_metadata, self.context.client_metadata.grant_types, + self.context.configured_scope, ) granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index fc87c9e469..7bb8fa8d59 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -100,21 +100,30 @@ def get_client_metadata_scopes( protected_resource_metadata: ProtectedResourceMetadata | None, authorization_server_metadata: OAuthMetadata | None = None, client_grant_types: list[str] | None = None, + configured_scope: str | None = None, ) -> str | None: - """Select effective scopes and augment for refresh token support.""" + """Select effective scopes and augment for refresh token support. + + A source that yields no scopes (absent, null, or an empty list) offers no guidance, so + selection falls through to the next source rather than treating "advertised nothing" as + "request nothing". + """ selected_scope: str | None = None # MCP spec scope selection priority: # 1. WWW-Authenticate header scope # 2. PRM scopes_supported # 3. AS scopes_supported (SDK fallback) - # 4. Omit scope parameter - if www_authenticate_scope is not None: + # 4. Scope the caller configured on the provider (SDK fallback) + # 5. Omit scope parameter + if www_authenticate_scope: selected_scope = www_authenticate_scope - elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: + elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported: selected_scope = " ".join(protected_resource_metadata.scopes_supported) - elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: + elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported: selected_scope = " ".join(authorization_server_metadata.scopes_supported) + else: + selected_scope = configured_scope or None # SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens if ( diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 9e9599f86c..09fc1dfa73 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2797,6 +2797,63 @@ def test_union_scopes(previous: str | None, new: str | None, expected: str | Non assert union_scopes(previous, new) == expected +def test_configured_scope_is_the_fallback_when_the_server_advertises_no_scopes(): + """The scope the caller set on the provider is requested when discovery yields no scopes. + + SDK-defined tier below the spec's chain; without it the configured scope was silently dropped. + """ + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=None, + ) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=None, + configured_scope="user", + ) + + assert scopes == "user" + + +def test_advertised_scopes_take_precedence_over_the_configured_scope(): + """Server-advertised scopes (here PRM) win over the caller-configured fallback.""" + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=["read"], + ) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=None, + configured_scope="user", + ) + + assert scopes == "read" + + +def test_empty_scopes_supported_falls_through_to_the_configured_scope(): + """An empty scopes_supported list advertises nothing, so selection continues to the next source.""" + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=[], + ) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=None, + configured_scope="user", + ) + + assert scopes == "user" + + def test_credentials_match_issuer_same_issuer(): info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as") assert credentials_match_issuer(info, "https://as", None) is True diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 17bc3a4b5a..6c797f737b 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3798,9 +3798,10 @@ def __post_init__(self) -> None: note="OAuth is HTTP-only.", divergence=Divergence( note=( - "The SDK inserts an extra fallback step between PRM and omit: if the authorization " - "server metadata advertises scopes_supported, that list is used (client/auth/utils.py). " - "This is beyond the spec's two-step chain." + "The SDK inserts two extra fallback steps between PRM and omit (client/auth/utils.py): " + "if the authorization server metadata advertises scopes_supported, that list is used; " + "otherwise the scope the caller configured on the provider is requested. This is beyond " + "the spec's two-step chain." ), ), ), diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 8f45a01510..27fdf79c4b 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -399,6 +399,44 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_ assert decoded == "m2m-client:m2m-secret" +@requirement("client-auth:scope-selection:priority") +async def test_client_credentials_provider_requests_its_configured_scope_when_the_server_advertises_none() -> None: + """With no scopes advertised, the provider's configured `scope` reaches the `/token` body. + + The server publishes no `scopes_supported` (empty `required_scopes`), so the spec's + WWW-Authenticate/PRM chain yields nothing and the SDK falls back to the scope the caller + configured on the provider — an SDK-defined tier below the spec's chain (see the divergence + on the requirement). + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + + auth = ClientCredentialsOAuthProvider( + server_url=f"{BASE_URL}/mcp", + storage=InMemoryTokenStorage(), + client_id="m2m-client", + client_secret="m2m-secret", + scope="ops", + ) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + settings=auth_settings(required_scopes=[]), + auth=auth, + app_shim=m2m_token_shim(provider, scopes=["ops"]), + on_request=on_request, + ) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + + [token_req] = find(recorded, "POST", "/token") + assert form_body(token_req)["scope"] == "ops" + + @requirement("client-auth:private-key-jwt") async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_assertion() -> None: """The private-key-JWT provider sends a `client_assertion` on the token request, with the issuer as audience.