Repository navigation
Don't override client_metadata.scopes if they are already set #2317
Description
Activity
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.
- addedenhancementRequest for a new feature that's not currently supportedRequest for a new feature that's not currently supportedP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthneeds decisionIssue is actionable, needs maintainer decision on whether to implementIssue is actionable, needs maintainer decision on whether to implement
on Aug 14, 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 own
oauth.scopeconfig key) against Bonusly's MCP server. The operator explicitly
configured five scopes:user:read company:read recognition:read rewards:read recognition:writeBonusly's protected-resource metadata advertises 37 scopes in
scopes_supported, 16 of them:administer— includingbilling:administer,
finance:administer,user:administerandrecognition:administer. Step 3
overwrote 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:DCR request scopereturned"scope": "user:read recognition:read recognition:write"exactly those 3 no scopefieldempty what the SDK sent all 37 So 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:Grants Hermes Agent permission to: Read: User information, Company information, Recognitions, Rewards, … Write: User information, Recognitions, Awards, Incentives, … Manage: User information, Company information, Billing, Reports, Analytics, Finance, Recognitions, Rewards, …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:
- It is silent. The application set
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. - It defaults to maximum privilege. Any client on this SDK requests every
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 inscopes_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
proposedif self.context.client_metadata.scope is None:preserves the strategy
for 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.- It is silent. The application set
should the granted scope set returned by the authorization server be surfaced back to the client so a caller can detect when it received less authority than it asked for, before it calls a protected resource
I say YES.
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.
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.
Another reproduction, from a read-only client: SniffMCP (https://github.com/SomehowLiving/sniffmcp)
only ever callstools/list. Against a production MCP server whose PRM advertises
profile.read profile.write applications.read applications.write projects.read projects.write …,
the SDK (2.3) replaced our configured scope with all ofscopes_supported, so users were asked to
grant 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 on
OAuthClientProviderwould cover the "narrow what the server advertises" case too.
Initial Checks
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).
From https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/auth/oauth2.py#L553-L558:
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