Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1991,6 +1991,13 @@
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)).
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

Check warning on line 2000 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

Empty scopes_supported semantics misdescribed in migration entry and test docstring

Two prose statements added by this PR misdescribe the empty `scopes_supported=[]` case: the migration bullet claims v1's AS-catalog fallback could be triggered by a resource publishing an *empty* `scopes_supported` (in v1 that fallback only fired when the field was *absent*/`None` — an empty list produced an empty, falsy scope string that was simply omitted), and the new interaction test's docstring in `tests/interaction/auth/test_lifecycle.py` (~lines 407-408) says an empty list "would itself m
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
### Client rejects authorization server metadata with a mismatched `issuer`

During OAuth discovery, `OAuthClientProvider` now validates that the authorization server
Expand Down
8 changes: 6 additions & 2 deletions src/mcp/client/auth/extensions/client_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 21 additions & 10 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
89 changes: 86 additions & 3 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
),
),
Expand Down
53 changes: 52 additions & 1 deletion tests/interaction/auth/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -33,6 +33,7 @@
metadata_body,
record_requests,
shim,
shimmed_app,
step_up_shim,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
Expand Down Expand Up @@ -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 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(),
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.
Expand Down