diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 2ce67f815..cc3284c95 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 its protected-resource `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/docs/migration.md b/docs/migration.md index 0376f95c6..6db21d8bd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1991,6 +1991,13 @@ ClientCredentialsOAuthProvider(..., scopes="read write") ClientCredentialsOAuthProvider(..., scope="read write") ``` +### Scope selection follows the spec's chain, with the configured scope as the fallback + +The client selects the scope to request from the `WWW-Authenticate` challenge, then the protected resource's `scopes_supported`, then the scope the caller configured on the provider (`ClientCredentialsOAuthProvider(scope=...)`, `PrivateKeyJWTOAuthProvider(scope=...)`, or `OAuthClientMetadata(scope=...)`), and otherwise omits it. Two v1 behaviours changed: + +* The configured scope is now requested when the server publishes no scopes (its `scopes_supported` absent or empty). v1 silently dropped it and sent the token request scope-less. If a strict authorization server now answers `invalid_scope` for a configured scope it does not allowlist, drop or correct the `scope=` argument. +* The authorization server's `scopes_supported` no longer supplies the requested scope. When the protected resource metadata was absent or omitted `scopes_supported`, v1 requested that entire list — the server's whole catalog rather than what the resource needs. If you relied on it, configure the scope on the provider explicitly. The list is still consulted to decide whether `offline_access` may be added ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)), but that append needs a base scope: a flow whose only scope source was the authorization server's list now also sends no `offline_access` and so receives no refresh token — configure the scope on the provider to keep requesting one. + ### Client rejects authorization server metadata with a mismatched `issuer` During OAuth discovery, `OAuthClientProvider` now validates that the authorization server diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index d7da71c8f..bf5b00564 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 + protected resource's `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 + protected resource's `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..68663d39f 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 @@ -267,12 +272,15 @@ def __init__( self.context = OAuthContext( server_url=server_url, - client_metadata=client_metadata, + # The flow writes its selected scope into client_metadata, so work on a copy + # rather than mutating the caller's model out from under them. + client_metadata=client_metadata.model_copy(), storage=storage, redirect_handler=redirect_handler, 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 +651,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 +726,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..e9bd974a4 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -95,26 +95,37 @@ def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, s return urls +def _join_scopes(scopes: list[str] | None) -> str | None: + """Join a scope list into the space-separated wire format; an empty or absent list is None.""" + return " ".join(scopes) if scopes else None + + def get_client_metadata_scopes( www_authenticate_scope: str | None, 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.""" - selected_scope: str | None = None + """Select effective scopes and augment for refresh token support. - # MCP spec scope selection priority: + Follows the spec's scope-selection strategy, with the scope the caller configured on the + provider as an SDK fallback beneath it. A source that yields no scopes (absent or an empty + list) offers no guidance, so selection falls through to the next source. The authorization + server's `scopes_supported` is its catalog, not what this resource needs, so it never + supplies the requested scope; it is consulted only for `offline_access` support below. + """ + # Scope selection priority (spec, then SDK fallback): # 1. WWW-Authenticate header scope # 2. PRM scopes_supported - # 3. AS scopes_supported (SDK fallback) + # 3. Scope the caller configured on the provider (SDK fallback) # 4. Omit scope parameter - if www_authenticate_scope is not None: - selected_scope = www_authenticate_scope - elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: - selected_scope = " ".join(protected_resource_metadata.scopes_supported) - elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: - selected_scope = " ".join(authorization_server_metadata.scopes_supported) + selected_scope = ( + www_authenticate_scope + or _join_scopes(protected_resource_metadata.scopes_supported if protected_resource_metadata else None) + or 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..ffbbae181 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2456,9 +2456,8 @@ def test_offline_access_not_added_when_no_scopes_selected(self): authorization_server_metadata=asm, client_grant_types=["authorization_code", "refresh_token"], ) - # When AS scopes are the only source and include offline_access, - # the base scope is "offline_access" and no duplication happens - assert scopes == "offline_access" + # The AS catalog never supplies the requested scope, so no base scope is selected. + assert scopes is None def test_offline_access_not_added_when_as_scopes_supported_is_none(self): """offline_access is not added when AS scopes_supported is None.""" @@ -2797,6 +2796,90 @@ 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_not_the_as_catalog(): + """An empty PRM scopes_supported offers no guidance, so the configured scope is requested. + + The authorization server's scopes_supported is its catalog rather than what the resource + needs, so it never supplies the requested scope and cannot widen the request beyond the + caller's configuration. + """ + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=[], + ) + asm = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + scopes_supported=["read", "write", "admin"], + ) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + configured_scope="user", + ) + + assert scopes == "user" + + +def test_provider_does_not_mutate_the_callers_client_metadata(mock_storage: MockTokenStorage): + """Scope selection is written to the provider's own copy of the metadata, never the caller's model. + + Two providers built from one metadata object each snapshot the caller's configured scope, + so scopes discovered by the first cannot leak into the second's fallback tier. + """ + metadata = OAuthClientMetadata(redirect_uris=[AnyUrl("http://localhost:3030/callback")], scope="user") + + first = OAuthClientProvider(server_url="https://a.example.com/mcp", client_metadata=metadata, storage=mock_storage) + first.context.client_metadata.scope = "discovered read write" + second = OAuthClientProvider(server_url="https://b.example.com/mcp", client_metadata=metadata, storage=mock_storage) + + assert metadata.scope == "user" + assert second.context.configured_scope == "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..3a29f232b 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 one extra fallback step between PRM and omit (client/auth/utils.py): " + "the scope the caller configured on the provider is requested when the challenge and " + "PRM yield nothing. This is beyond the spec's two-step chain; the TypeScript SDK adds " + "the same tier." ), ), ), diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index 8f45a0151..3ae75deca 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -20,7 +20,7 @@ from mcp import MCPError from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider from mcp.server import Server, ServerRequestContext -from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata +from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, ProtectedResourceMetadata from tests.interaction._connect import BASE_URL from tests.interaction._requirements import requirement from tests.interaction.auth._harness import ( @@ -33,6 +33,7 @@ metadata_body, record_requests, shim, + shimmed_app, step_up_shim, ) from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider @@ -399,6 +400,56 @@ 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. + + Both metadata documents publish no scopes (an empty list would fall through the same way), + 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) + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(f"{BASE_URL}/")] + ) + asm = OAuthMetadata( + issuer=AnyHttpUrl(f"{BASE_URL}/"), + authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), + token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), + grant_types_supported=["client_credentials"], + ) + assert prm.scopes_supported is None and asm.scopes_supported is None + serve = {PRM_PATH: metadata_body(prm), ASM_PATH: metadata_body(asm)} + + 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=["ops"]), + auth=auth, + app_shim=lambda app: shimmed_app(m2m_token_shim(provider, scopes=["ops"])(app), serve=serve), + 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.