diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 2ce67f815..83b8476d1 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 d7da71c8f..3ca80fd36 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 073e6a803..e8c62368a 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 fc87c9e46..7bb8fa8d5 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 9e9599f86..09fc1dfa7 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 17bc3a4b5..6c797f737 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 8f45a0151..27fdf79c4 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.