Skip to content
Open
Show file tree
Hide file tree
Changes from all 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(..., 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Migration guidance incorrectly guarantees no refresh token when offline_access is omitted. Servers can still issue refresh tokens without that scope; describe this as losing the request for offline_access (and potentially the refresh token) instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 1999:

<comment>Migration guidance incorrectly guarantees no refresh token when `offline_access` is omitted. Servers can still issue refresh tokens without that scope; describe this as losing the request for `offline_access` (and potentially the refresh token) instead.</comment>

<file context>
@@ -1995,8 +1995,8 @@ ClientCredentialsOAuthProvider(..., scope="read write")
-* 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`
</file context>
Suggested change
* 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.
* 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`, which can prevent a refresh token from being issued — 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
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 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.
Expand Down