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 1/3] 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. From da19abc3d6bbd64185b5e2506bc2d86c985db74c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:46:08 +0000 Subject: [PATCH 2/3] Align scope selection with the spec chain and stop mutating caller metadata Drop the authorization-server scopes_supported tier from scope selection. That list is the server's catalog rather than what the resource needs, so falling back to it could request every scope the server supports when the protected resource metadata published an empty list. The chain is now WWW-Authenticate scope, then PRM scopes_supported, then the caller-configured scope, then omit; an empty published list falls through instead of pinning an empty scope. AS metadata is still consulted for whether offline_access may be added. The provider now works on a copy of the caller's OAuthClientMetadata, so the flow's scope selection no longer rewrites the caller's model and the configured-scope snapshot cannot pick up another provider's discovered scopes when metadata is reused across providers. --- docs/client/oauth-clients.md | 2 +- docs/migration.md | 7 ++++ .../auth/extensions/client_credentials.py | 4 +- src/mcp/client/auth/oauth2.py | 4 +- src/mcp/client/auth/utils.py | 36 +++++++++--------- tests/client/test_auth.py | 38 ++++++++++++++++--- tests/interaction/_requirements.py | 8 ++-- tests/interaction/auth/test_lifecycle.py | 27 +++++++++---- 8 files changed, 88 insertions(+), 38 deletions(-) diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 83b8476d1c..cc3284c952 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. It is the fallback: a scope the server names in its `WWW-Authenticate` challenge or publishes in `scopes_supported` takes precedence. +* `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 0376f95c64..378cd701d4 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 advertises no scopes. 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. v1 fell back to that list, which is the server's whole catalog rather than what the resource needs, so a resource publishing an empty `scopes_supported` could trigger a request for every scope the authorization server supports. 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)). + ### 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 3ca80fd369..bf5b005641 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -59,7 +59,7 @@ def __init__( Either "client_secret_basic" (default) or "client_secret_post". 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. + protected resource's `scopes_supported` takes precedence. """ # Build minimal client_metadata for the base class client_metadata = OAuthClientMetadata( @@ -275,7 +275,7 @@ def __init__( callback for workload identity federation. 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. + 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 e8c62368a0..68663d39f4 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -272,7 +272,9 @@ 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, diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 7bb8fa8d59..e9bd974a42 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -95,6 +95,11 @@ 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, @@ -104,26 +109,23 @@ def get_client_metadata_scopes( ) -> str | None: """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". + 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. """ - selected_scope: str | None = None - - # MCP spec scope selection priority: + # Scope selection priority (spec, then SDK fallback): # 1. WWW-Authenticate header scope # 2. PRM scopes_supported - # 3. AS scopes_supported (SDK fallback) - # 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: - selected_scope = " ".join(protected_resource_metadata.scopes_supported) - 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 + # 3. Scope the caller configured on the provider (SDK fallback) + # 4. Omit scope parameter + 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 09fc1dfa73..ffbbae1812 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.""" @@ -2836,24 +2835,51 @@ def test_advertised_scopes_take_precedence_over_the_configured_scope(): 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.""" +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=None, + 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 6c797f737b..3a29f232b3 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3798,10 +3798,10 @@ def __post_init__(self) -> None: note="OAuth is HTTP-only.", divergence=Divergence( note=( - "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." + "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 27fdf79c4b..d525f3de21 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 @@ -403,15 +404,27 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_ 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). + Both metadata documents omit `scopes_supported` (absent, not empty - an empty list would + itself mean "request none"), 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(), @@ -424,9 +437,9 @@ async def test_client_credentials_provider_requests_its_configured_scope_when_th async with connect_with_oauth( server, provider=provider, - settings=auth_settings(required_scopes=[]), + settings=auth_settings(required_scopes=["ops"]), auth=auth, - app_shim=m2m_token_shim(provider, scopes=["ops"]), + 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() From aeac39f67da5e40f1de7582f5923fd88cb0c2da3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:56:56 +0000 Subject: [PATCH 3/3] Correct the scope-selection migration note and a stale test docstring The migration entry named the wrong v1 trigger for the removed authorization-server fallback: it fired when the protected resource metadata was absent or omitted scopes_supported, not when it published an empty list. Also note that the offline_access append needs a base scope, so flows that previously drew only on the authorization server's list no longer request it. No-Verification-Needed: doc- and comment-only edits --- docs/migration.md | 4 ++-- tests/interaction/auth/test_lifecycle.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 378cd701d4..6db21d8bdd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1995,8 +1995,8 @@ ClientCredentialsOAuthProvider(..., scope="read write") 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 advertises no scopes. 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. v1 fell back to that list, which is the server's whole catalog rather than what the resource needs, so a resource publishing an empty `scopes_supported` could trigger a request for every scope the authorization server supports. 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)). +* 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` diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index d525f3de21..3ae75deca2 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -404,10 +404,10 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_ 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 omit `scopes_supported` (absent, not empty - an empty list would - itself mean "request none"), 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). + 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()