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 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 on lines 278 to 284

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 added to prevent, just across provider instances. Consider client_metadata.model_copy() at construction (or tracking the effective scope in a context field instead of mutating client_metadata.scope) to close the gap.

Extended reasoning...

What the bug is. OAuthClientProvider.__init__ stores the caller's OAuthClientMetadata instance directly into OAuthContext (no model_copy() anywhere under src/mcp/client/auth), and snapshots configured_scope=client_metadata.scope at construction time (src/mcp/client/auth/oauth2.py:281). Step 3 of the 401 flow then mutates that same caller-owned object in place: self.context.client_metadata.scope = get_client_metadata_scopes(...) (oauth2.py:649; the 403 step-up path at oauth2.py:731 does the same). OAuthClientMetadata is a plain non-frozen Pydantic model (src/mcp/shared/auth.py), so the assignment silently rewrites the caller's model. If the caller reuses one metadata object across providers, the "configured" snapshot of a later provider is really whatever the earlier provider's discovery wrote.\n\nCode path. The polluted value reaches the wire through the tier-4 fallback this PR adds in get_client_metadata_scopes (src/mcp/client/auth/utils.py): when WWW-Authenticate, PRM scopes_supported, and AS scopes_supported all yield nothing, selected_scope = configured_scope or None — and configured_scope is the polluted snapshot.\n\nStep-by-step proof.\n\npython\nMETADATA = OAuthClientMetadata(scope=\"user\", redirect_uris=[...]) # module-level constant\n\np1 = OAuthClientProvider(url_a, METADATA, storage_a, ...)\n# p1.context.client_metadata IS METADATA (same object); p1 configured_scope = \"user\"\n# p1's 401 flow: server A advertises scopes_supported=[\"read\", \"write\"]\n# Step 3 (oauth2.py:649): METADATA.scope = \"read write\" <-- caller's object mutated\n\np2 = OAuthClientProvider(url_b, METADATA, storage_b, ...)\n# oauth2.py:281: configured_scope = METADATA.scope = \"read write\" <-- server A's scopes\n# p2's 401 flow: server B advertises nothing (no WWW-Authenticate scope, no scopes_supported)\n# tier 4 (utils.py): selected_scope = \"read write\"\n# -> p2 requests server A's discovered scopes from server B, not the configured \"user\"\n\n\nWhy this is a consequence of this PR and not purely pre-existing. The in-place mutation of the caller's object predates the PR, but before it the pollution was self-healing: Step 3 unconditionally overwrote client_metadata.scope with the fresh selection, and when nothing was advertised the scope became None and was simply omitted from the request — the stale value never reached the wire on this path. After this PR, the polluted value is durably captured as the "configured" fallback tier and actively sent whenever tier 4 fires. That is exactly the cross-flow stale-scope leak the PR description says the configured_scope snapshot exists to prevent — just across provider instances rather than across 401 flows of one instance. (There is also a concurrency variant: two providers sharing one metadata object each hold their own OAuthContext.lock, so provider A's Step 3 write can race provider B mid-flow reads.)\n\nWhy it's a nit, not blocking. The trigger needs a specific pattern no SDK docs or examples demonstrate — one shared mutable metadata instance, providers constructed at different times, and a second server that advertises no scopes. The docs build metadata inline per provider, and ClientCredentialsOAuthProvider/PrivateKeyJWTOAuthProvider construct their metadata internally and are immune. The failure mode is requesting wrong/extra scopes, which a server typically rejects or narrows.\n\nFix. Either copy at construction — client_metadata=client_metadata.model_copy() in __init__ so the provider owns the object Step 3 mutates — or stop mutating client_metadata.scope altogether and track the effective/selected scope in a separate OAuthContext field. The second option also removes the surprising side effect of the SDK rewriting a caller-supplied model.


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 on lines 113 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 invalid_scope where the flow previously succeeded scope-less.

Extended reasoning...

What's missing. The new tier-4 fallback in get_client_metadata_scopes (src/mcp/client/auth/utils.py:113-126) changes observable wire behavior: when neither the WWW-Authenticate challenge nor PRM/AS scopes_supported yields a scope, the caller-configured scope (ClientCredentialsOAuthProvider(scope=...), PrivateKeyJWTOAuthProvider(scope=...), OAuthClientMetadata(scope=...)) is now sent in the token and authorize requests instead of being silently dropped. The PR itself labels this "Behavior change, no API change", and AGENTS.md requires behavior changes to be documented in docs/migration.md — but the PR touches only docs/client/oauth-clients.md and the interaction-requirements divergence note.\n\nThis is a real v1→v2 behavior change, not just a pre-PR-v2 fix. Verified against both the published v1 wheel (mcp 1.28.1) and the v1.x branch: v1's get_client_metadata_scopes returns None when no source advertises scopes and has no configured-scope fallback, and v1's scope-selection step overwrites client_metadata.scope with that result. So a v1 client with a configured scope against a server publishing no scopes sent a scope-less token request — and after this PR it sends scope=<configured>. A v1 user migrating to v2 will observe a new parameter on the wire.\n\nWhy it matters. The migration guide's own SEP-2207 entry (docs/migration.md:2022-2048) spells out the exact failure mode this can trigger: strict authorization servers that reject un-allowlisted scopes may fail the flow with invalid_scope. Before this PR, a caller whose configured scope was never sent could have been succeeding against such a server precisely because the scope was dropped; after this PR the same configuration sends the scope and can newly fail. That's the class of surprise the migration guide exists to document.\n\nDirect precedent in the guide. The repo already documents this exact category of no-API-change scope-behavior changes: the SEP-2207 offline_access augmentation entry (~line 2022) and the SEP-2350 step-up union entry (~line 2069, "No API change; the wider scope is sent automatically"). There is even a directly related entry for these same providers — the scopes=scope= rename at ~line 1978 from #3166, the PR this one follows up on — which per AGENTS.md's "group related changes together" guidance is the natural place to attach a note.\n\nStep-by-step example. (1) v1 user constructs ClientCredentialsOAuthProvider(server_url=..., storage=..., client_id=\"m2m\", client_secret=\"s\", scopes=[\"ops\"]). (2) The AS publishes no scopes_supported and the 401 challenge carries no scope. (3) v1: get_client_metadata_scopes returns None, client_metadata.scope is overwritten to None, and the /token body omits scope — the AS issues a token with its default scopes and the flow succeeds. (4) User migrates to v2 post-PR (renaming scopes=scope=\"ops\" per the existing migration entry). (5) Now tier 4 selects \"ops\" and the /token body carries scope=ops. (6) If the AS has not allowlisted ops for this client, it responds 400 invalid_scope and the flow raises OAuthTokenError — a regression from the user's perspective with no code change on their side beyond the documented rename.\n\nHow to fix. Add a short entry to docs/migration.md, grouped with the scope= rename section (~line 1978) or the adjacent scope-behavior sections, stating: the configured scope is now sent as a last-resort fallback when the server advertises no scopes (previously it was silently dropped); advertised scopes still take precedence; strict authorization servers may newly return invalid_scope for a configured scope they don't recognize, and the remedy is to drop or correct the configured scope. Severity is nit: nothing fails at runtime from the missing doc, and the underlying change is a deliberate, well-tested bug fix — this is purely bringing the docs in line with the repo's own convention.


# 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