Don't override client_metadata.scopes if they are already set #2317
Description
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
The scope selection strategy inside async_auth_flow unconditionally requests all available scopes. This overwrites the scope list that may have been explicitly set by the client. Being able to explicitly set the requested scopes is an important use case, either to reduce the permissions granted or because the server only permits certain scopes (despite advertising others).
# 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,
)
This could be conditional on if self.context.client_metadata.scope is None.
I see that this behavior was previously suggested in #1324 (comment) and rejected, on the basis that "Requesting all available scopes allows the authorization server and end-user to determine appropriate permissions during the consent process". However, I think this is worth revisiting. The specific motivating example here is the official SalesForce MCP server: if the client requests scopes that are not authorized for the given client application, the server rejects the request entirely.
Example Code
Python & MCP Python SDK
python 3.12.12
sdk 1.26.0
Activity
Ram9199 commentedon Jun 13, 2026
I am collecting real-world incidents around MCP authorization and least-privilege behavior.
Did this scope-overwrite behavior cause a real failed deployment, over-broad consent request, rejected enterprise connector, or changed auth practice? What changed afterward - explicit scope pinning, separate OAuth apps, manual review, custom auth code, audit logging, or no change?
Not asking about any product. I am trying to separate current operational pain from pre-incident hardening.
haizaar commentedon Sep 2, 2026
Hit this in production with a security consequence, which I think speaks directly
to the rationale used to reject it in #1324.
Setup: Hermes Agent (which sets
client_metadata.scopefrom its ownoauth.scopeconfig key) against Bonusly's MCP server. The operator explicitlyconfigured five scopes:
Bonusly's protected-resource metadata advertises 37 scopes in
scopes_supported, 16 of them:administer— includingbilling:administer,finance:administer,user:administerandrecognition:administer. Step 3overwrote the configured five, the client registered for all 37, and the
resulting token carries all 37.
Verified the server itself is not the cause. Registering directly against their
/oauth/register:scopereturned"scope": "user:read recognition:read recognition:write"scopefieldSo the AS honours a narrow request precisely. It granted everything because the
SDK asked for everything.
Why this rebuts the original objection
The reasoning for rejecting the conditional was that "requesting all available
scopes allows the authorization server and end-user to determine appropriate
permissions during the consent process."
In practice the consent screen can only offer what the client requested, so
requesting everything removes the choice rather than enabling it. Ours rendered
as:
The end-user's options are "grant administrative access to billing and finance"
or "deny". There is no per-scope toggle — few authorization servers offer one —
so the client's request is the ceiling, and the consent screen is where the
user discovers that their configuration was discarded.
Two further points:
scopeand got no error, no warning,and no indication the value was dropped. From the operator's side the
configuration simply had no effect, which took us a while to attribute.
scope every server advertises. That is fine where a server advertises only
what it needs, and becomes a real exposure the moment a vendor lists admin
scopes in
scopes_supported— which is exactly what a "supported scopes"field is for.
The spec's selection strategy seems right as a default for a client with no
preference. The problem is applying it to a client that expressed one. The
proposed
if self.context.client_metadata.scope is None:preserves the strategyfor everyone who has not set a scope, and stops silently discarding an explicit
security decision for everyone who has.
Happy to send that PR if it would help.
Environment:
mcp==2.0.0, Python 3.12, remote HTTP transport with DCR.haizaar commentedon Sep 2, 2026
I say YES.
adtyavrdhn commentedon Sep 4, 2026
Additional reproducible security impact from pydantic/pydantic-ai-harness#791:
Using FastMCP 3.4.5 and MCP Python SDK 1.26.0 against Google's official Gmail MCP endpoint, a read-only
search_threadsconnection received a 401 without ascopeparameter. Its protected-resource metadata advertisedhttps://mail.google.com/,gmail.modify, andgmail.readonly. The SDK replaced the configured scope ceiling with all three values. The generated Google authorization URL then requested exactly those three scopes.This reproduces without credentials by intercepting the redirect URL. Supplying FastMCP
OAuth(scopes=...)does not help because the SDK overwrites that value during protected-resource discovery.We have withheld automatic local OAuth from the Harness integration and require caller-managed bearer tokens or clients until an explicit scope list remains a ceiling through discovery and step-up.
robertreppel commentedon Sep 13, 2026
Third independent reproduction, with a variant not described above: the overwrite can also silently narrow the requested scope below both the configured value and the server's own advertised
scopes_supported— the opposite failure direction from the Bonusly case, same root cause.Setup:
taylorwilsdon/google_workspace_mcp(self-hosted, FastMCPGoogleProvider) as the MCP server, Hermes Agent v0.21.0 as the client, withmcp_servers.<name>.oauth.scopeconfigured to includedocuments,spreadsheets,drive.readonly,drive.file(plus baselineuserinfo.email openid).The server's
.well-known/oauth-authorization-servercorrectly lists all of those inscopes_supported— not a server misconfiguration. But it also (correctly, per spec) sends aWWW-Authenticatechallenge with only a minimal required scope (userinfo.email openid) on the first unauthenticated request, since that's all it needs to accept a bearer token at the protocol layer — actual Drive/Docs/Sheets access is meant to be requested by the client on top of that.Per
get_client_metadata_scopes's rule 1, that challenge value wins outright over both the configured scope and the broaderscopes_supportedadvertisement. So in this deployment shape, the client silently ends up with less privilege than either the operator configured or the server made available — confirmed via a debug patch on_build_client_metadatashowing the correctly-configured scope reachesOAuthClientMetadatabefore registration, while the actual DCR response and subsequent/authorizerequest both carry only the WWW-Authenticate-derived minimum.Adding this as a third independent corroboration (alongside the Bonusly and Gmail MCP reports above) that this is a real, cross-vendor problem, and that the "let the AS/consent screen decide" rationale from #1324's original rejection doesn't hold here either — no consent screen was ever involved for the narrowed-out scopes, since the request never reached the AS with more than the challenge-declared minimum.
SomehowLiving commentedon Oct 11, 2026
Another reproduction, from a read-only client: SniffMCP (https://github.com/SomehowLiving/sniffmcp)
only ever calls
tools/list. Against a production MCP server whose PRM advertisesprofile.read profile.write applications.read applications.write projects.read projects.write …,the SDK (2.3) replaced our configured scope with all of
scopes_supported, so users were asked togrant write access to a scanner that never writes.
Our workaround shows why a supported hook would help: we subclass
OAuthClientMetadataand override__setattr__soscopeis filtered every time the SDK assigns it (initial selection and step-up).It works, but it depends on SDK internals.
+1 to respecting an explicitly configured scope. A
scope_filter/ allowlist onOAuthClientProviderwould cover the "narrow what the server advertises" case too.