Skip to content

Commit 7d398a0

Browse files
committed
Delegate authorize-time scope policy to the provider
The SDK-hosted authorization server rejected any /authorize request whose scope was not in the client's registered `scope` metadata, and treated a client registered without a scope as allowed to request nothing at all. That broke the spec's step-up flow, in which a client answers a 403 insufficient_scope challenge by re-authorizing for the union of its previous and challenged scopes without re-registering, and it rejected every scoped request from scope-less registrations (#2216) as well as an empty `scope=` parameter (#977). The authorize handler now enforces only the server-wide scope set, `ClientRegistrationOptions.valid_scopes` (the list already advertised as `scopes_supported` and enforced at registration). A requested scope outside it redirects back with `invalid_scope`; when it is unset, every requested scope reaches `provider.authorize()`, which can reject a client's request by raising `AuthorizeError(error="invalid_scope")`. An empty scope parameter is treated as omitted. `OAuthClientInformationFull.validate_scope()` and `InvalidScopeError` are removed; both existed only to implement the deleted check. Github-Issue: #2216
1 parent fe47969 commit 7d398a0

8 files changed

Lines changed: 241 additions & 35 deletions

File tree

docs/migration.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2594,7 +2594,7 @@ class OAuthClientMetadata(OAuthClientMetadataBase): ... # request: stri
25942594
class OAuthClientInformationFull(OAuthClientMetadataBase): ... # server record: tolerant
25952595
```
25962596

2597-
On `OAuthClientInformationFull`, `application_type` and `token_endpoint_auth_method` are now `str | None`, `grant_types` is `list[str]`, and `redirect_uris` is optional (`list[AnyUrl] | None`, no minimum length). `client_id` is now required (`str`): [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) makes it mandatory in the response, and a record of a registered client without one was never meaningful. Code that only reads these fields is unaffected. Code that relied on `isinstance(client_info, OAuthClientMetadata)`, or passed an `OAuthClientInformationFull` where an `OAuthClientMetadata` is expected, must reference the record type directly. `validate_scope()` and `validate_redirect_uri()` moved with the record: they are methods of `OAuthClientInformationFull` (the type authorization-server code holds) and are no longer available on `OAuthClientMetadata`.
2597+
On `OAuthClientInformationFull`, `application_type` and `token_endpoint_auth_method` are now `str | None`, `grant_types` is `list[str]`, and `redirect_uris` is optional (`list[AnyUrl] | None`, no minimum length). `client_id` is now required (`str`): [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) makes it mandatory in the response, and a record of a registered client without one was never meaningful. Code that only reads these fields is unaffected. Code that relied on `isinstance(client_info, OAuthClientMetadata)`, or passed an `OAuthClientInformationFull` where an `OAuthClientMetadata` is expected, must reference the record type directly. `validate_redirect_uri()` moved with the record: it is a method of `OAuthClientInformationFull` (the type authorization-server code holds) and is no longer available on `OAuthClientMetadata`. `validate_scope()` is removed altogether; see [The SDK-hosted authorization server no longer enforces a client's registered `scope`](#the-sdk-hosted-authorization-server-no-longer-enforces-a-clients-registered-scope).
25982598

25992599
A registration response the server sends is no longer rejected on these fields: a member serialized as a placeholder - an explicit `null`, or `""` - reads as an omitted key, so its default applies. Whether a substituted value is usable is judged where it matters, not at parse. When Dynamic Client Registration completes with credentials the authorization-code flow cannot use - a `token_endpoint_auth_method` other than `none`, `client_secret_post`, or `client_secret_basic` (including `private_key_jwt`, whose assertion that flow has no key to sign), or a secret-based method for which the server issued no `client_secret` - the client raises `OAuthRegistrationError` naming the problem, before the record is stored or authorization begins. Separately, a stored or pre-registered record carrying a method the SDK does not know at all raises `OAuthTokenError` when it reaches the token exchange; `private_key_jwt` on such a record does not raise there, so `PrivateKeyJWTOAuthProvider`, which signs its assertion only in the client-credentials exchange, still recovers from a rejected refresh by exchanging afresh.
26002600

@@ -2640,6 +2640,38 @@ LEGACY_CLIENT = OAuthClientInformationFull(
26402640
)
26412641
```
26422642

2643+
### The SDK-hosted authorization server no longer enforces a client's registered `scope`
2644+
2645+
In v1 (and early v2), the authorize endpoint of the SDK-hosted authorization server (`create_auth_routes`) rejected any requested scope that was not in the client's registered `scope` metadata, and treated a client registered without a `scope` as allowed to request nothing at all:
2646+
2647+
```text
2648+
# v1: client registered without a scope, then GET /authorize?...&scope=mcp
2649+
302 → ?error=invalid_scope&error_description=Client+was+not+registered+with+scope+mcp
2650+
```
2651+
2652+
That check is gone. A client's registered `scope` is [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591#section-2) client metadata - self-asserted, "scope values that the client can use when requesting access tokens" - not an allowlist the authorization server must enforce, and enforcing it broke the spec's [step-up authorization flow](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#step-up-authorization-flow), in which a client answers a `403 insufficient_scope` challenge by re-authorizing for the union of its previous and challenged scopes without re-registering. The endpoint now enforces only the server-wide scope set: `ClientRegistrationOptions.valid_scopes` (the same list advertised as `scopes_supported` and already enforced at dynamic client registration). A requested scope outside it is redirected back with `error=invalid_scope`; when `valid_scopes` is unset, every requested scope is passed to the provider. An empty `scope=` parameter is now treated as an omitted one instead of being rejected.
2653+
2654+
Per-client scope policy belongs in your provider, whose `authorize()` already receives the parsed request and can reject it with the same wire result - here an operator-maintained ceiling per client, rather than the client's self-asserted metadata:
2655+
2656+
```python
2657+
from mcp.server.auth.provider import AuthorizationParams, AuthorizeError
2658+
from mcp.shared.auth import OAuthClientInformationFull
2659+
2660+
# Scope ceilings you configure per client; anything not listed gets the base set.
2661+
CLIENT_SCOPE_CEILINGS = {"backend-service": {"mcp", "write", "admin"}}
2662+
2663+
2664+
class MyProvider:
2665+
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
2666+
requested = set(params.scopes or [])
2667+
ceiling = CLIENT_SCOPE_CEILINGS.get(client.client_id, {"mcp"})
2668+
if not requested <= ceiling:
2669+
raise AuthorizeError(error="invalid_scope", error_description="scope not permitted for this client")
2670+
...
2671+
```
2672+
2673+
`OAuthClientInformationFull.validate_scope()` and `mcp.shared.auth.InvalidScopeError` are removed; both existed only to implement the deleted check. Parse a scope string with `str.split()`, and move any `except InvalidScopeError` handling into the provider.
2674+
26432675
## Stricter protocol validation and wire behavior
26442676

26452677
### Server handler results are validated against the protocol schema

src/mcp/server/auth/handlers/authorize.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
OAuthAuthorizationServerProvider,
1818
construct_redirect_uri,
1919
)
20-
from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError
20+
from mcp.shared.auth import InvalidRedirectUriError
2121

2222
logger = logging.getLogger(__name__)
2323

@@ -63,9 +63,32 @@ class AnyUrlModel(RootModel[AnyUrl]):
6363
root: AnyUrl
6464

6565

66+
def scope_tokens(scope: str | None) -> list[str] | None:
67+
"""Split an RFC 6749 §3.3 scope parameter into its space-delimited tokens.
68+
69+
An absent or empty parameter is no scope request at all, so both return None and the
70+
provider applies its default (RFC 6749 §3.3).
71+
"""
72+
tokens = scope.split() if scope else []
73+
return tokens or None
74+
75+
6676
@dataclass
6777
class AuthorizationHandler:
78+
"""The authorization endpoint (RFC 6749 §4.1.1) of the SDK-hosted authorization server.
79+
80+
`valid_scopes` is the scope set this authorization server supports - the same list advertised
81+
as `scopes_supported` and enforced at dynamic client registration. A request naming any other
82+
scope is rejected with `invalid_scope`, whichever client sends it. `None` declares no
83+
server-wide scope set, so every requested scope reaches the provider. A registered client's
84+
own `scope` is not consulted: RFC 7591 §2 makes it self-asserted metadata, and enforcing it
85+
would reject the step-up flow, in which a client re-authorizes for scopes beyond its
86+
registration. Per-client scope policy belongs in `OAuthAuthorizationServerProvider.authorize()`,
87+
which rejects a request by raising `AuthorizeError` with `error="invalid_scope"`.
88+
"""
89+
6890
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
91+
valid_scopes: list[str] | None = None
6992

7093
async def handle(self, request: Request) -> Response:
7194
# implements authorization requests for grant_type=code;
@@ -185,15 +208,16 @@ async def error_response(
185208
error_description=validation_error.message,
186209
)
187210

188-
# Validate scope - for scope errors, we can redirect
189-
try:
190-
scopes = client.validate_scope(auth_request.scope)
191-
except InvalidScopeError as validation_error:
192-
# For scope errors, redirect with error parameters
193-
return await error_response(
194-
error="invalid_scope",
195-
error_description=validation_error.message,
196-
)
211+
# Enforce the server-wide scope set; scope policy for the client is the provider's,
212+
# in authorize(). An unsupported scope redirects back with invalid_scope.
213+
scopes = scope_tokens(auth_request.scope)
214+
if scopes is not None and self.valid_scopes is not None:
215+
unsupported = sorted(set(scopes) - set(self.valid_scopes))
216+
if unsupported:
217+
return await error_response(
218+
error="invalid_scope",
219+
error_description=f"Requested scopes are not valid: {', '.join(unsupported)}",
220+
)
197221

198222
# Setup authorization parameters
199223
auth_params = AuthorizationParams(

src/mcp/server/auth/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def create_auth_routes(
102102
AUTHORIZATION_PATH,
103103
# do not allow CORS for authorization endpoint;
104104
# clients should just redirect to this
105-
endpoint=AuthorizationHandler(provider).handle,
105+
endpoint=AuthorizationHandler(provider, valid_scopes=client_registration_options.valid_scopes).handle,
106106
methods=["GET", "POST"],
107107
),
108108
Route(

src/mcp/shared/auth.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,6 @@ class AuthorizationCodeResult(BaseModel):
5454
iss: str | None = None
5555

5656

57-
class InvalidScopeError(Exception):
58-
def __init__(self, message: str):
59-
self.message = message
60-
61-
6257
class InvalidRedirectUriError(Exception):
6358
def __init__(self, message: str):
6459
self.message = message
@@ -174,16 +169,6 @@ def _placeholder_members_read_as_omitted(cls, data: object) -> object:
174169
return {key: value for key, value in members.items() if value is not None and value != ""}
175170
return data
176171

177-
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
178-
if requested_scope is None:
179-
return None
180-
requested_scopes = requested_scope.split(" ")
181-
allowed_scopes = [] if self.scope is None else self.scope.split(" ")
182-
for scope in requested_scopes:
183-
if scope not in allowed_scopes:
184-
raise InvalidScopeError(f"Client was not registered with scope {scope}")
185-
return requested_scopes
186-
187172
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
188173
if redirect_uri is not None:
189174
# Validate redirect_uri against client's registered redirect URIs

tests/interaction/_requirements.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2942,6 +2942,17 @@ def __post_init__(self) -> None:
29422942
transports=("streamable-http",),
29432943
note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
29442944
),
2945+
"hosting:auth:as:authorize-scope": Requirement(
2946+
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
2947+
behavior=(
2948+
"The bundled authorize endpoint accepts a requested scope beyond the client's registered "
2949+
"`scope` when it is within the server's `valid_scopes`, so a client can step up without "
2950+
"re-registering, and rejects a scope outside `valid_scopes` with an `invalid_scope` "
2951+
"error redirect. Per-client scope policy is delegated to the provider's `authorize()`."
2952+
),
2953+
transports=("streamable-http",),
2954+
note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
2955+
),
29452956
"hosting:auth:as:verifier-mismatch": Requirement(
29462957
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection",
29472958
behavior=(

tests/interaction/auth/test_as_handlers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,47 @@ async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_reque
129129
assert "code_challenge" in params["error_description"][0]
130130

131131

132+
@requirement("hosting:auth:as:authorize-scope")
133+
async def test_authorize_accepts_scope_beyond_registration_within_valid_scopes_and_rejects_the_rest() -> None:
134+
"""A client registered with `mcp` may authorize for `mcp write` (`write` is within the server's
135+
`valid_scopes`, so step-up needs no re-registration), while a scope the server does not support
136+
is redirected back with `error=invalid_scope`. The client's registered `scope` is not an allowlist."""
137+
provider = InMemoryAuthorizationServerProvider()
138+
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
139+
async with mounted_app(
140+
Server("guarded"),
141+
auth=settings,
142+
token_verifier=ProviderTokenVerifier(provider),
143+
auth_server_provider=provider,
144+
) as (http, _):
145+
client_info = await _register_client(http)
146+
assert client_info.client_id is not None
147+
assert client_info.scope == "mcp"
148+
149+
_, challenge = _pkce_pair()
150+
base_params = {
151+
"response_type": "code",
152+
"client_id": client_info.client_id,
153+
"redirect_uri": REDIRECT_URI,
154+
"code_challenge": challenge,
155+
"code_challenge_method": "S256",
156+
"state": "s",
157+
}
158+
widened = await http.get("/authorize", params=base_params | {"scope": "mcp write"}, follow_redirects=False)
159+
unknown = await http.get("/authorize", params=base_params | {"scope": "mcp admin"}, follow_redirects=False)
160+
161+
assert widened.status_code == 302
162+
granted = parse_qs(urlsplit(widened.headers["location"]).query)
163+
assert "code" in granted
164+
assert provider.codes[granted["code"][0]].scopes == ["mcp", "write"]
165+
166+
assert unknown.status_code == 302
167+
rejected = parse_qs(urlsplit(unknown.headers["location"]).query)
168+
assert rejected["error"] == ["invalid_scope"]
169+
assert rejected["error_description"] == snapshot(["Requested scopes are not valid: admin"])
170+
assert rejected["state"] == ["s"]
171+
172+
132173
@requirement("hosting:auth:as:verifier-mismatch")
133174
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
134175
as_app: tuple[httpx2.AsyncClient, InMemoryAuthorizationServerProvider],

tests/interaction/auth/test_lifecycle.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,14 @@ async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challe
143143
which reaches the auth flow's step-up handler; the first authenticated POST is the post-401
144144
retry, after which the generator ends without inspecting the response). The challenge names a
145145
wider scope; step-up reuses cached metadata and the existing client registration,
146-
re-authorizes with the new scope, and the connect completes. The client is pre-registered
147-
with both scopes so the server's authorize handler accepts the wider second request. One
148-
re-authorize, one retry; the spec's SHOULD-retry-limit ("a few") is not enforced.
146+
re-authorizes with the new scope, and the connect completes. The client is registered with
147+
only `mcp`, so the server's authorize endpoint accepting the wider `mcp write` (both within
148+
its `valid_scopes`) is what lets step-up proceed without re-registering. One re-authorize,
149+
one retry; the spec's SHOULD-retry-limit ("a few") is not enforced.
149150
"""
150151
recorded, on_request = record_requests()
151152
provider = InMemoryAuthorizationServerProvider()
152-
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
153+
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp"))
153154
server = Server("guarded", on_list_tools=list_tools)
154155
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
155156
challenge = 'Bearer error="insufficient_scope", scope="mcp write"'
@@ -185,10 +186,11 @@ async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenge
185186
186187
The first authorization requests `mcp`; the 403 challenges a disjoint `write` (not naming
187188
`mcp`). Per SEP-2350 the client must re-authorize with `mcp write`, not drop `mcp`. The client
188-
is pre-registered with both scopes so the server's authorize handler accepts the wider request.
189+
is registered with only `mcp`; the server accepts the wider request because `write` is within
190+
its `valid_scopes`, not because the client registered it.
189191
"""
190192
provider = InMemoryAuthorizationServerProvider()
191-
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
193+
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp"))
192194
server = Server("guarded", on_list_tools=list_tools)
193195
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
194196
challenge = 'Bearer error="insufficient_scope", scope="write"'

0 commit comments

Comments
 (0)