Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 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.
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
server's published `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
server's published `scopes_supported` takes precedence.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@
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 @@ -270,12 +275,13 @@
client_metadata=client_metadata,
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

Check warning on line 284 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

configured_scope snapshot polluted when OAuthClientMetadata is reused across providers

The `configured_scope=client_metadata.scope` snapshot can capture a previously *discovered* scope rather than the caller-configured one: the provider stores the caller's `OAuthClientMetadata` by reference and Step 3 mutates `client_metadata.scope` in place, so a second provider constructed from the same metadata object after the first's 401 flow snapshots server A's discovered scopes and the new tier-4 sends them to server B when B advertises nothing — the same stale-scope leak this snapshot was
Comment thread
claude[bot] marked this conversation as resolved.

async def _handle_protected_resource_response(self, response: httpx2.Response) -> bool:
"""Handle protected resource metadata discovery response.
Expand Down Expand Up @@ -643,6 +649,7 @@
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 +724,7 @@
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
19 changes: 14 additions & 5 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,30 @@
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

Check warning on line 126 in src/mcp/client/auth/utils.py

View check run for this annotation

Claude / Claude Code Review

Behavior change not documented in docs/migration.md

This PR changes wire behavior — a caller-configured `scope` that was previously dropped when the server advertises no scopes is now sent on `/token` and `/authorize` — but adds no `docs/migration.md` entry, which AGENTS.md requires for behavior changes. Consider adding a short note alongside the existing scope-behavior entries (the `scopes=` → `scope=` rename at ~line 1978, or the SEP-2207/SEP-2350 sections), since strict authorization servers that reject un-allowlisted scopes may now fail with

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: Clients that previously relied on an omitted scope will now send their configured value in this fallback case, but this behavior change is not documented in docs/migration.md. Adding a migration entry alongside the existing scope-provider guidance would let users identify and adjust deployments that reject or interpret the newly sent parameter differently.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/utils.py, line 126:

<comment>Clients that previously relied on an omitted `scope` will now send their configured value in this fallback case, but this behavior change is not documented in `docs/migration.md`. Adding a migration entry alongside the existing scope-provider guidance would let users identify and adjust deployments that reject or interpret the newly sent parameter differently.</comment>

<file context>
@@ -100,21 +100,30 @@ def get_client_metadata_scopes(
+    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
</file context>

Comment thread
claude[bot] marked this conversation as resolved.
Outdated

# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
if (
Expand Down
57 changes: 57 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 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."
),
),
),
Expand Down
38 changes: 38 additions & 0 deletions tests/interaction/auth/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading