Skip to content

ClientOAuthProvider.Scopes have priority again (#1236)#1238

Open
halllo wants to merge 1 commit intomodelcontextprotocol:mainfrom
halllo:#1236_ClientOAuthOptions_Scopes_Priority
Open

ClientOAuthProvider.Scopes have priority again (#1236)#1238
halllo wants to merge 1 commit intomodelcontextprotocol:mainfrom
halllo:#1236_ClientOAuthOptions_Scopes_Priority

Conversation

@halllo
Copy link
Contributor

@halllo halllo commented Feb 2, 2026

I adjusted the priority of scope determination. Scopes specified via ClientOAuthOptions.Scopes have priority over scopes from PRM.

Motivation and Context

When the client specifies Scopes via the ClientOAuthOptions, these scopes are used before the scopes of the PRM, just as the xml comment states

"When specified, these scopes will be used instead of the scopes advertised by the protected resource.".

For example this is needed if a client only supports a subset of PRM scopes or wants to add the offline_access scope.

How Has This Been Tested?

I added test method.

Breaking Changes

No. Unless they unexpectedly rely on ClientOAuthOptions.Scopes not having an effect in the presence of PRM scopes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

This addresses #1236

@halter73
Copy link
Contributor

halter73 commented Feb 3, 2026

I think we should update the doc comments to indicate that ClientOAuthOptions.Scopes is only a fallback. Or maybe we need to split between ScopesFallback and ScopesOverride.

The November version of the MCP spec added a "Scope Selection Strategy" we now implement.

Scope Selection Strategy

When implementing authorization flows, MCP clients SHOULD follow the principle of least privilege by requesting
only the scopes necessary for their intended operations. During the initial authorization handshake, MCP clients
SHOULD follow this priority order for scope selection:

  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.

This approach accommodates the general-purpose nature of MCP clients, which typically lack domain-specific knowledge to make informed decisions about individual scope selection. Requesting all available scopes allows the authorization server and end-user to determine appropriate permissions during the consent process.

This approach minimizes user friction while following the principle of least privilege.
The scopes_supported field is intended to represent the minimal set of scopes necessary
for basic functionality (see Scope Minimization),
with additional scopes requested incrementally through the step-up authorization flow steps
described in the Scope Challenge Handling section.

https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy

Do we know what the other SDK's do here?

@halter73
Copy link
Contributor

halter73 commented Feb 4, 2026

I asked Copilot what the TS and Python SDK do, and it appears the client-developer-provided scopes are only used as a fallback.

TypeScript SDK (modelcontextprotocol/typescript-sdk): scope selection precedence is explicit and matches the spec (+ uses client scope as last fallback)

In the TypeScript client OAuth implementation, the scope passed into the authorization-start call is selected with this precedence chain:

  1. explicit scope argument (this comes from WWW-Authenticate: ... scope="..." when a 401 happens, via middleware), else
  2. scopes_supported from Protected Resource Metadata (PRM), else
  3. provider.clientMetadata.scope (client-configured scope)
// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
    metadata,
    clientInformation,
    state,
    redirectUrl: provider.redirectUrl,
    scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
    resource
});

The TypeScript SDK also explicitly parses scope out of the WWW-Authenticate header:

export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } {
    const authenticateHeader = res.headers.get('WWW-Authenticate');
    if (!authenticateHeader) {
        return {};
    }

    const [type, scheme] = authenticateHeader.split(' ');
    if (type?.toLowerCase() !== 'bearer' || !scheme) {
        return {};
    }

    // ...
    const scope = extractFieldFromWwwAuth(res, 'scope') || undefined;
    const error = extractFieldFromWwwAuth(res, 'error') || undefined;

    return {
        resourceMetadataUrl,
        scope,
        error
    };
}

And their tests assert the key spec behaviors:

  • if scope is not provided, use PRM scopes_supported
  • explicit scope beats PRM
// Call auth without scope parameter
const result = await auth(mockProvider, {
    serverUrl: 'https://api.example.com/'
});

// Verify the authorization URL includes the scopes from PRM
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!;
const authUrl: URL = redirectCall[0];
expect(authUrl?.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');

(There is also a test named prefers explicit scope parameter over scopes_supported from PRM immediately after this snippet in the same file.)

So: TypeScript treats client-provided clientMetadata.scope as a last-resort fallback, not as an override that always wins.

Note: my lexical search output warns it’s limited to 10 results; if you want to browse more occurrences in GitHub UI, here’s the search:
https://github.com/modelcontextprotocol/typescript-sdk/search?q=repo%3Amodelcontextprotocol%2Ftypescript-sdk+path%3Apackages%2Fclient%2Fsrc%2Fclient%2Fauth.ts+%28scopes_supported+OR+WWW-Authenticate+OR+extractWWWAuthenticateParams%29&type=code


Python SDK (modelcontextprotocol/python-sdk): implements the MCP “Scope Selection Strategy” directly; client-configured scope gets overwritten

The Python SDK has a dedicated helper with an explicit docstring and comments stating the strategy and priority:

  1. WWW-Authenticate scope
  2. PRM scopes_supported
  3. (extra fallback) Authorization Server Metadata scopes_supported (RFC 8414)
  4. omit scope parameter
def get_client_metadata_scopes(
    www_authenticate_scope: str | None,
    protected_resource_metadata: ProtectedResourceMetadata | None,
    authorization_server_metadata: OAuthMetadata | None = None,
) -> str | None:
    """Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec."""
    # Per MCP spec, scope selection priority order:
    # 1. Use scope from WWW-Authenticate header (if provided)
    # 2. Use all scopes from PRM scopes_supported (if available)
    # 3. Omit scope parameter if neither is available

    if www_authenticate_scope is not None:
        # Priority 1: WWW-Authenticate header scope
        return www_authenticate_scope
    elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
        # Priority 2: PRM scopes_supported
        return " ".join(protected_resource_metadata.scopes_supported)
    elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
        return " ".join(authorization_server_metadata.scopes_supported)  # pragma: no cover
    else:
        # Priority 3: Omit scope parameter
        return None

Crucially: during auth flow, Python sets self.context.client_metadata.scope based on that selection strategy—meaning any user-provided client_metadata.scope becomes effectively a fallback that can be overwritten:

# Step 3: Apply scope selection strategy
self.context.client_metadata.scope = get_client_metadata_scopes(
    extract_scope_from_www_auth(response),
    self.context.protected_resource_metadata,
    self.context.oauth_metadata,
)

And on “step-up” (insufficient_scope) it again updates scope from the 401:

if error == "insufficient_scope":  # pragma: no branch
    # Step 2a: Update the required scopes
    self.context.client_metadata.scope = get_client_metadata_scopes(
        extract_scope_from_www_auth(response), self.context.protected_resource_metadata
    )

And when building the authorize URL, it includes scope only if client_metadata.scope is set:

if self.context.client_metadata.scope:  # pragma: no branch
    auth_params["scope"] = self.context.client_metadata.scope

So: Python is very clearly “spec-first”: WWW-Authenticate wins, else PRM scopes_supported; client-provided scopes are not an override. It even adds an extra fallback to authorization-server metadata scopes_supported.


Comparison / implication for C# docs

Both SDKs treat a client-configured scope as fallback, not “highest priority override”:

  • TypeScript: WWW-Authenticate scope / explicit scope param > PRM scopes_supported > client metadata scope
  • Python: WWW-Authenticate scope > PRM scopes_supported > (AS metadata scopes_supported) > omit; and it overwrites client_metadata.scope based on this.

@halllo Can you explain the scenario where you need to override the scopes explicitly requested by the MCP server? Is this a scenario that works with any clients that do not use the MCP C# SDK?

@halllo
Copy link
Contributor Author

halllo commented Feb 5, 2026

That is very interesting, because I find it counter-intuitive that the values I explicitly provide are not used.
My main scenario is the addition of the offline_access scope to get the refresh token. The offline_access scope is not a scope that’s in PRM, because it is not part of the resource. However it is needed to get refresh tokens for long running sessions.
Another scenario is that my client supports only a subset of the scopes of PRM (and therefore only a subset of tools), so when it tries to use the scopes from PRM, token acquisition fails because of unsupported scopes.

Alternatively additional integration points (e.g. custom scope selection) would also help me to have more control over what scopes are actually used.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants