Skip to content

Commit f5380dc

Browse files
committed
Separate advertised scopes_supported from required_scopes
`AuthSettings.required_scopes` is used both to refuse tokens and as the Protected Resource Metadata's `scopes_supported`, so a server cannot advertise a scope that only some handlers check (a write scope, for example) without challenging for it on every request. Add an optional `AuthSettings.scopes_supported` used for the metadata, defaulting to `required_scopes` to keep current servers unchanged. The bearer middleware still challenges with `required_scopes`.
1 parent d7845c0 commit f5380dc

4 files changed

Lines changed: 67 additions & 2 deletions

File tree

‎src/mcp/server/auth/settings.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ class AuthSettings(BaseModel):
3232
client_registration_options: ClientRegistrationOptions | None = None
3333
revocation_options: RevocationOptions | None = None
3434
required_scopes: list[str] | None = None
35+
scopes_supported: list[str] | None = Field(
36+
default=None,
37+
description="Scopes advertised in the Protected Resource Metadata. Defaults to "
38+
"`required_scopes`; set it to also advertise scopes that individual handlers check "
39+
"without requiring them on every request.",
40+
)
3541
identity_assertion_enabled: bool = Field(
3642
default=False,
3743
description="Advertise and accept the SEP-990 Identity Assertion Authorization Grant "

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -837,7 +837,7 @@ def streamable_http_app(
837837
create_protected_resource_routes(
838838
resource_url=auth.resource_server_url,
839839
authorization_servers=[auth.issuer_url],
840-
scopes_supported=auth.required_scopes,
840+
scopes_supported=auth.scopes_supported or auth.required_scopes,
841841
)
842842
)
843843

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1275,7 +1275,7 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover
12751275
create_protected_resource_routes(
12761276
resource_url=self.settings.auth.resource_server_url,
12771277
authorization_servers=[self.settings.auth.issuer_url],
1278-
scopes_supported=self.settings.auth.required_scopes,
1278+
scopes_supported=self.settings.auth.scopes_supported or self.settings.auth.required_scopes,
12791279
)
12801280
)
12811281

‎tests/server/auth/test_protected_resource.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,65 @@
1111
from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
1212

1313

14+
class _RejectingTokenVerifier:
15+
async def verify_token(self, token: str):
16+
return None
17+
18+
19+
@pytest.mark.anyio
20+
async def test_scopes_supported_advertised_separately_from_required_scopes():
21+
"""A server can advertise scopes it does not challenge for on every request (#3778)."""
22+
from mcp.server.auth.settings import AuthSettings
23+
from mcp.server.mcpserver import MCPServer
24+
25+
mcp = MCPServer(
26+
"test",
27+
auth=AuthSettings(
28+
issuer_url=AnyHttpUrl("https://auth.example.com"),
29+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
30+
validate_token_resource=True,
31+
required_scopes=["returns:read"],
32+
scopes_supported=["returns:read", "returns:write"],
33+
),
34+
token_verifier=_RejectingTokenVerifier(),
35+
)
36+
37+
async with httpx2.AsyncClient(
38+
transport=httpx2.ASGITransport(app=mcp.streamable_http_app()),
39+
base_url="http://127.0.0.1:8000",
40+
) as client:
41+
response = await client.get("/.well-known/oauth-protected-resource/mcp")
42+
43+
assert response.status_code == 200
44+
assert response.json()["scopes_supported"] == ["returns:read", "returns:write"]
45+
46+
47+
@pytest.mark.anyio
48+
async def test_scopes_supported_defaults_to_required_scopes():
49+
from mcp.server.auth.settings import AuthSettings
50+
from mcp.server.mcpserver import MCPServer
51+
52+
mcp = MCPServer(
53+
"test",
54+
auth=AuthSettings(
55+
issuer_url=AnyHttpUrl("https://auth.example.com"),
56+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
57+
validate_token_resource=True,
58+
required_scopes=["returns:read"],
59+
),
60+
token_verifier=_RejectingTokenVerifier(),
61+
)
62+
63+
async with httpx2.AsyncClient(
64+
transport=httpx2.ASGITransport(app=mcp.streamable_http_app()),
65+
base_url="http://127.0.0.1:8000",
66+
) as client:
67+
response = await client.get("/.well-known/oauth-protected-resource/mcp")
68+
69+
assert response.status_code == 200
70+
assert response.json()["scopes_supported"] == ["returns:read"]
71+
72+
1473
@pytest.fixture
1574
def test_app():
1675
"""Fixture to create protected resource routes for testing."""

0 commit comments

Comments
 (0)