Skip to content

Commit da19abc

Browse files
committed
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.
1 parent 218510e commit da19abc

8 files changed

Lines changed: 88 additions & 38 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. It is the fallback: a scope the server names in its `WWW-Authenticate` challenge or publishes in `scopes_supported` takes precedence.
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 its protected-resource `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.

docs/migration.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,6 +1991,13 @@ ClientCredentialsOAuthProvider(..., scopes="read write")
19911991
ClientCredentialsOAuthProvider(..., scope="read write")
19921992
```
19931993

1994+
### Scope selection follows the spec's chain, with the configured scope as the fallback
1995+
1996+
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:
1997+
1998+
* 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.
1999+
* 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)).
2000+
19942001
### Client rejects authorization server metadata with a mismatched `issuer`
19952002

19962003
During OAuth discovery, `OAuthClientProvider` now validates that the authorization server

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def __init__(
5959
Either "client_secret_basic" (default) or "client_secret_post".
6060
scope: Optional space-separated list of scopes to request. Used when the server
6161
advertises no scopes; a scope from the `WWW-Authenticate` challenge or the
62-
server's published `scopes_supported` takes precedence.
62+
protected resource's `scopes_supported` takes precedence.
6363
"""
6464
# Build minimal client_metadata for the base class
6565
client_metadata = OAuthClientMetadata(
@@ -275,7 +275,7 @@ def __init__(
275275
callback for workload identity federation.
276276
scope: Optional space-separated list of scopes to request. Used when the server
277277
advertises no scopes; a scope from the `WWW-Authenticate` challenge or the
278-
server's published `scopes_supported` takes precedence.
278+
protected resource's `scopes_supported` takes precedence.
279279
"""
280280
# Build minimal client_metadata for the base class
281281
client_metadata = OAuthClientMetadata(

src/mcp/client/auth/oauth2.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ def __init__(
272272

273273
self.context = OAuthContext(
274274
server_url=server_url,
275-
client_metadata=client_metadata,
275+
# The flow writes its selected scope into client_metadata, so work on a copy
276+
# rather than mutating the caller's model out from under them.
277+
client_metadata=client_metadata.model_copy(),
276278
storage=storage,
277279
redirect_handler=redirect_handler,
278280
callback_handler=callback_handler,

src/mcp/client/auth/utils.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, s
9595
return urls
9696

9797

98+
def _join_scopes(scopes: list[str] | None) -> str | None:
99+
"""Join a scope list into the space-separated wire format; an empty or absent list is None."""
100+
return " ".join(scopes) if scopes else None
101+
102+
98103
def get_client_metadata_scopes(
99104
www_authenticate_scope: str | None,
100105
protected_resource_metadata: ProtectedResourceMetadata | None,
@@ -104,26 +109,23 @@ def get_client_metadata_scopes(
104109
) -> str | None:
105110
"""Select effective scopes and augment for refresh token support.
106111
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".
112+
Follows the spec's scope-selection strategy, with the scope the caller configured on the
113+
provider as an SDK fallback beneath it. A source that yields no scopes (absent or an empty
114+
list) offers no guidance, so selection falls through to the next source. The authorization
115+
server's `scopes_supported` is its catalog, not what this resource needs, so it never
116+
supplies the requested scope; it is consulted only for `offline_access` support below.
110117
"""
111-
selected_scope: str | None = None
112-
113-
# MCP spec scope selection priority:
118+
# Scope selection priority (spec, then SDK fallback):
114119
# 1. WWW-Authenticate header scope
115120
# 2. PRM scopes_supported
116-
# 3. AS scopes_supported (SDK fallback)
117-
# 4. Scope the caller configured on the provider (SDK fallback)
118-
# 5. Omit scope parameter
119-
if www_authenticate_scope:
120-
selected_scope = www_authenticate_scope
121-
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported:
122-
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
123-
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported:
124-
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
125-
else:
126-
selected_scope = configured_scope or None
121+
# 3. Scope the caller configured on the provider (SDK fallback)
122+
# 4. Omit scope parameter
123+
selected_scope = (
124+
www_authenticate_scope
125+
or _join_scopes(protected_resource_metadata.scopes_supported if protected_resource_metadata else None)
126+
or configured_scope
127+
or None
128+
)
127129

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

tests/client/test_auth.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2456,9 +2456,8 @@ def test_offline_access_not_added_when_no_scopes_selected(self):
24562456
authorization_server_metadata=asm,
24572457
client_grant_types=["authorization_code", "refresh_token"],
24582458
)
2459-
# When AS scopes are the only source and include offline_access,
2460-
# the base scope is "offline_access" and no duplication happens
2461-
assert scopes == "offline_access"
2459+
# The AS catalog never supplies the requested scope, so no base scope is selected.
2460+
assert scopes is None
24622461

24632462
def test_offline_access_not_added_when_as_scopes_supported_is_none(self):
24642463
"""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():
28362835
assert scopes == "read"
28372836

28382837

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."""
2838+
def test_empty_scopes_supported_falls_through_to_the_configured_scope_not_the_as_catalog():
2839+
"""An empty PRM scopes_supported offers no guidance, so the configured scope is requested.
2840+
2841+
The authorization server's scopes_supported is its catalog rather than what the resource
2842+
needs, so it never supplies the requested scope and cannot widen the request beyond the
2843+
caller's configuration.
2844+
"""
28412845
prm = ProtectedResourceMetadata(
28422846
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
28432847
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
28442848
scopes_supported=[],
28452849
)
2850+
asm = OAuthMetadata(
2851+
issuer=AnyHttpUrl("https://auth.example.com"),
2852+
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
2853+
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
2854+
scopes_supported=["read", "write", "admin"],
2855+
)
28462856

28472857
scopes = get_client_metadata_scopes(
28482858
www_authenticate_scope=None,
28492859
protected_resource_metadata=prm,
2850-
authorization_server_metadata=None,
2860+
authorization_server_metadata=asm,
28512861
configured_scope="user",
28522862
)
28532863

28542864
assert scopes == "user"
28552865

28562866

2867+
def test_provider_does_not_mutate_the_callers_client_metadata(mock_storage: MockTokenStorage):
2868+
"""Scope selection is written to the provider's own copy of the metadata, never the caller's model.
2869+
2870+
Two providers built from one metadata object each snapshot the caller's configured scope,
2871+
so scopes discovered by the first cannot leak into the second's fallback tier.
2872+
"""
2873+
metadata = OAuthClientMetadata(redirect_uris=[AnyUrl("http://localhost:3030/callback")], scope="user")
2874+
2875+
first = OAuthClientProvider(server_url="https://a.example.com/mcp", client_metadata=metadata, storage=mock_storage)
2876+
first.context.client_metadata.scope = "discovered read write"
2877+
second = OAuthClientProvider(server_url="https://b.example.com/mcp", client_metadata=metadata, storage=mock_storage)
2878+
2879+
assert metadata.scope == "user"
2880+
assert second.context.configured_scope == "user"
2881+
2882+
28572883
def test_credentials_match_issuer_same_issuer():
28582884
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as")
28592885
assert credentials_match_issuer(info, "https://as", None) is True

tests/interaction/_requirements.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3798,10 +3798,10 @@ def __post_init__(self) -> None:
37983798
note="OAuth is HTTP-only.",
37993799
divergence=Divergence(
38003800
note=(
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."
3801+
"The SDK inserts one extra fallback step between PRM and omit (client/auth/utils.py): "
3802+
"the scope the caller configured on the provider is requested when the challenge and "
3803+
"PRM yield nothing. This is beyond the spec's two-step chain; the TypeScript SDK adds "
3804+
"the same tier."
38053805
),
38063806
),
38073807
),

tests/interaction/auth/test_lifecycle.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from mcp import MCPError
2121
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
2222
from mcp.server import Server, ServerRequestContext
23-
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
23+
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, ProtectedResourceMetadata
2424
from tests.interaction._connect import BASE_URL
2525
from tests.interaction._requirements import requirement
2626
from tests.interaction.auth._harness import (
@@ -33,6 +33,7 @@
3333
metadata_body,
3434
record_requests,
3535
shim,
36+
shimmed_app,
3637
step_up_shim,
3738
)
3839
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
@@ -403,15 +404,27 @@ async def test_client_credentials_provider_obtains_a_token_without_an_authorize_
403404
async def test_client_credentials_provider_requests_its_configured_scope_when_the_server_advertises_none() -> None:
404405
"""With no scopes advertised, the provider's configured `scope` reaches the `/token` body.
405406
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).
407+
Both metadata documents omit `scopes_supported` (absent, not empty - an empty list would
408+
itself mean "request none"), so the spec's WWW-Authenticate/PRM chain yields nothing and
409+
the SDK falls back to the scope the caller configured on the provider — an SDK-defined tier
410+
below the spec's chain (see the divergence on the requirement).
410411
"""
411412
recorded, on_request = record_requests()
412413
provider = InMemoryAuthorizationServerProvider()
413414
server = Server("guarded", on_list_tools=list_tools)
414415

416+
prm = ProtectedResourceMetadata(
417+
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(f"{BASE_URL}/")]
418+
)
419+
asm = OAuthMetadata(
420+
issuer=AnyHttpUrl(f"{BASE_URL}/"),
421+
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
422+
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
423+
grant_types_supported=["client_credentials"],
424+
)
425+
assert prm.scopes_supported is None and asm.scopes_supported is None
426+
serve = {PRM_PATH: metadata_body(prm), ASM_PATH: metadata_body(asm)}
427+
415428
auth = ClientCredentialsOAuthProvider(
416429
server_url=f"{BASE_URL}/mcp",
417430
storage=InMemoryTokenStorage(),
@@ -424,9 +437,9 @@ async def test_client_credentials_provider_requests_its_configured_scope_when_th
424437
async with connect_with_oauth(
425438
server,
426439
provider=provider,
427-
settings=auth_settings(required_scopes=[]),
440+
settings=auth_settings(required_scopes=["ops"]),
428441
auth=auth,
429-
app_shim=m2m_token_shim(provider, scopes=["ops"]),
442+
app_shim=lambda app: shimmed_app(m2m_token_shim(provider, scopes=["ops"])(app), serve=serve),
430443
on_request=on_request,
431444
) as (client, _):
432445
result = await client.list_tools()

0 commit comments

Comments
 (0)