Skip to content

Commit d95f3a6

Browse files
committed
fix: remove scope registration check from authorize handler
The check in validate_scope rejected any requested scope not in the client's registered metadata. This broke the MCP spec's step-up authorization flow: when a server returns 403 insufficient_scope with a WWW-Authenticate challenge containing expanded scopes, the client (see client/auth/oauth2.py) re-authorizes with those scopes and the server would reject them. RFC 7591 Section 2 defines the scope field as scopes the client "can use", with no language restricting requests to that set. Scope policy enforcement belongs in OAuthAuthorizationServerProvider.authorize(), which can already raise AuthorizeError(error="invalid_scope", ...). The TypeScript SDK removed this check in #983 for the same reason. InvalidScopeError is removed as it was only raised from this path. Reported-by: nik1097 Github-Issue: #2216
1 parent 75a80b6 commit d95f3a6

File tree

5 files changed

+37
-56
lines changed

5 files changed

+37
-56
lines changed

docs/migration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,23 @@ server = Server("my-server")
797797
server.experimental.enable_tasks(on_get_task=custom_get_task)
798798
```
799799

800+
### Server auth: `InvalidScopeError` removed, `validate_scope` no longer enforces
801+
802+
`OAuthClientMetadata.validate_scope()` no longer rejects scopes outside the client's registered set — it now only parses the scope string. The previous check blocked the MCP spec's step-up authorization flow, where a client must be able to request scopes beyond its initial registration in response to a `WWW-Authenticate: insufficient_scope` challenge. See [TypeScript SDK #983](https://github.com/modelcontextprotocol/typescript-sdk/pull/983) for the equivalent change.
803+
804+
`InvalidScopeError` (from `mcp.shared.auth`) has been removed — the SDK no longer raises it.
805+
806+
If your server needs to reject scopes, enforce policy inside `OAuthAuthorizationServerProvider.authorize()`:
807+
808+
```python
809+
from mcp.server.auth.provider import AuthorizeError
810+
811+
async def authorize(self, client, params):
812+
if params.scopes and "admin" in params.scopes and not client_is_trusted(client):
813+
raise AuthorizeError(error="invalid_scope", error_description="admin scope requires approval")
814+
...
815+
```
816+
800817
## Deprecations
801818

802819
<!-- Add deprecations below -->

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

Lines changed: 2 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

@@ -185,15 +185,7 @@ async def error_response(
185185
error_description=validation_error.message,
186186
)
187187

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-
)
188+
scopes = client.validate_scope(auth_request.scope)
197189

198190
# Setup authorization parameters
199191
auth_params = AuthorizationParams(

src/mcp/shared/auth.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@ def normalize_token_type(cls, v: str | None) -> str | None:
2222
return v # pragma: no cover
2323

2424

25-
class InvalidScopeError(Exception):
26-
def __init__(self, message: str):
27-
self.message = message
28-
29-
3025
class InvalidRedirectUriError(Exception):
3126
def __init__(self, message: str):
3227
self.message = message
@@ -68,14 +63,15 @@ class OAuthClientMetadata(BaseModel):
6863
software_version: str | None = None
6964

7065
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
66+
"""Parse the requested scope string into a list.
67+
68+
Scope policy enforcement is the provider's responsibility: raise
69+
``AuthorizeError(error="invalid_scope", ...)`` from
70+
``OAuthAuthorizationServerProvider.authorize()`` to reject.
71+
"""
7172
if requested_scope is None:
7273
return None
73-
requested_scopes = requested_scope.split(" ")
74-
allowed_scopes = [] if self.scope is None else self.scope.split(" ")
75-
for scope in requested_scopes:
76-
if scope not in allowed_scopes: # pragma: no branch
77-
raise InvalidScopeError(f"Client was not registered with scope {scope}")
78-
return requested_scopes # pragma: no cover
74+
return requested_scope.split(" ")
7975

8076
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
8177
if redirect_uri is not None:

tests/server/mcpserver/auth/test_auth_integration.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,37 +1607,3 @@ async def test_authorize_missing_pkce_challenge(
16071607
# State should be preserved
16081608
assert "state" in query_params
16091609
assert query_params["state"][0] == "test_state"
1610-
1611-
@pytest.mark.anyio
1612-
async def test_authorize_invalid_scope(
1613-
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
1614-
):
1615-
"""Test authorization endpoint with invalid scope.
1616-
1617-
Invalid scope should redirect with invalid_scope error.
1618-
"""
1619-
1620-
response = await test_client.get(
1621-
"/authorize",
1622-
params={
1623-
"response_type": "code",
1624-
"client_id": registered_client["client_id"],
1625-
"redirect_uri": "https://client.example.com/callback",
1626-
"code_challenge": pkce_challenge["code_challenge"],
1627-
"code_challenge_method": "S256",
1628-
"scope": "invalid_scope_that_does_not_exist",
1629-
"state": "test_state",
1630-
},
1631-
)
1632-
1633-
# Should redirect with error parameters
1634-
assert response.status_code == 302
1635-
redirect_url = response.headers["location"]
1636-
parsed_url = urlparse(redirect_url)
1637-
query_params = parse_qs(parsed_url.query)
1638-
1639-
assert "error" in query_params
1640-
assert query_params["error"][0] == "invalid_scope"
1641-
# State should be preserved
1642-
assert "state" in query_params
1643-
assert query_params["state"][0] == "test_state"

tests/shared/test_auth.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for OAuth 2.0 shared code."""
22

3-
from mcp.shared.auth import OAuthMetadata
3+
from mcp.shared.auth import OAuthClientMetadata, OAuthMetadata
44

55

66
def test_oauth():
@@ -58,3 +58,13 @@ def test_oauth_with_jarm():
5858
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
5959
}
6060
)
61+
62+
63+
def test_validate_scope_none_returns_none():
64+
client = OAuthClientMetadata.model_validate({"redirect_uris": ["https://example.com/cb"]})
65+
assert client.validate_scope(None) is None
66+
67+
68+
def test_validate_scope_splits_requested():
69+
client = OAuthClientMetadata.model_validate({"redirect_uris": ["https://example.com/cb"]})
70+
assert client.validate_scope("read write admin") == ["read", "write", "admin"]

0 commit comments

Comments
 (0)