Skip to content

OAuth scope selection falls back to the authorization server's scopes_supported, which the spec does not allow #3782

Closed as not planned

Description

@jin-bo

Initial Checks

Description

When the 401 carries no scope and the Protected Resource Metadata (PRM) has no scopes_supported, get_client_metadata_scopes (src/mcp/client/auth/utils.py) requests every scope the authorization server lists:

# MCP spec scope selection priority:
#   1. WWW-Authenticate header scope
#   2. PRM scopes_supported
#   3. AS scopes_supported (SDK fallback)
#   4. Omit scope parameter
...
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
    selected_scope = " ".join(authorization_server_metadata.scopes_supported)

The spec has two steps, and the second one ends in omitting scope (2026-07-28, Authorization → Scope Selection Strategy):

  1. Use scope parameter from the initial WWW-Authenticate header in the 401 response, if provided
  2. If scope is not available, use all scopes defined in scopes_supported from the Protected Resource Metadata document, omitting the scope parameter if scopes_supported is undefined.

The same section says clients SHOULD request "only the scopes necessary for their intended operations."

An authorization server's scopes_supported describes the whole authorization server, not this resource. Often that's a shared IdP serving many APIs (openid, profile, email, org-admin and write scopes for unrelated services). Tier 3 therefore turns "the resource didn't say" into "ask for everything the IdP has". The results:

  • an over-privileged token for an MCP server that needed none of it;
  • a broad consent screen for the user;
  • a login failure when the authorization server refuses to issue some of the listed scopes to a public or dynamically registered client (invalid_scope), where omitting scope would have succeeded with the authorization server's default.

Example Code

The selection function on its own (works on 1.26.0, 1.30.0 and 2.3.0):

from mcp.client.auth.utils import get_client_metadata_scopes
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata

prm = ProtectedResourceMetadata(
    resource="https://mcp.example.com/mcp",
    authorization_servers=["https://auth.example.com"],
)  # no scopes_supported
asm = OAuthMetadata(
    issuer="https://auth.example.com",
    authorization_endpoint="https://auth.example.com/authorize",
    token_endpoint="https://auth.example.com/token",
    scopes_supported=["openid", "profile", "email", "admin:org", "repo:write"],
)
print(get_client_metadata_scopes(None, prm, asm))
# 'openid profile email admin:org repo:write'   — expected: None (omit scope)

In the real flow, OAuthClientProvider.async_auth_flow assigns this result to client_metadata.scope on every 401. So a client can't avoid it by configuring a narrower scope: that is #2317, which has several production reports.

Expected behaviour

Follow the spec: (1) the challenge scope, (2) PRM scopes_supported, otherwise omit scope. If a fallback beyond the spec is wanted, make it something a client turns on, not the default.

Relation to existing issues

Python & MCP Python SDK

  • MCP Python SDK: reproduced on 1.26.0, 1.30.0, 2.3.0. The same code is on main at d7845c0.
  • Python 3.12

Activity

  1. jin-bo commented on Oct 28, 2026

    @jin-bo
    Author

    Duplicate of #3781, posted twice by mistake.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions