From f5380dce6fc1ff9a054e21b00ed99e7c9dfd4a88 Mon Sep 17 00:00:00 2001 From: xubenqiang Date: Wed, 28 Oct 2026 22:35:26 +0800 Subject: [PATCH] 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`. --- src/mcp/server/auth/settings.py | 6 ++ src/mcp/server/lowlevel/server.py | 2 +- src/mcp/server/mcpserver/server.py | 2 +- tests/server/auth/test_protected_resource.py | 59 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index 5da39adcbd..4b330260c8 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -32,6 +32,12 @@ class AuthSettings(BaseModel): client_registration_options: ClientRegistrationOptions | None = None revocation_options: RevocationOptions | None = None required_scopes: list[str] | None = None + scopes_supported: list[str] | None = Field( + default=None, + description="Scopes advertised in the Protected Resource Metadata. Defaults to " + "`required_scopes`; set it to also advertise scopes that individual handlers check " + "without requiring them on every request.", + ) identity_assertion_enabled: bool = Field( default=False, description="Advertise and accept the SEP-990 Identity Assertion Authorization Grant " diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index cbcf103d01..36033fa252 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -837,7 +837,7 @@ def streamable_http_app( create_protected_resource_routes( resource_url=auth.resource_server_url, authorization_servers=[auth.issuer_url], - scopes_supported=auth.required_scopes, + scopes_supported=auth.scopes_supported or auth.required_scopes, ) ) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7efa29108d..4fbdb7c9aa 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1275,7 +1275,7 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover create_protected_resource_routes( resource_url=self.settings.auth.resource_server_url, authorization_servers=[self.settings.auth.issuer_url], - scopes_supported=self.settings.auth.required_scopes, + scopes_supported=self.settings.auth.scopes_supported or self.settings.auth.required_scopes, ) ) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 5b4f69d9d5..4160f9e973 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -11,6 +11,65 @@ from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes +class _RejectingTokenVerifier: + async def verify_token(self, token: str): + return None + + +@pytest.mark.anyio +async def test_scopes_supported_advertised_separately_from_required_scopes(): + """A server can advertise scopes it does not challenge for on every request (#3778).""" + from mcp.server.auth.settings import AuthSettings + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer( + "test", + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + validate_token_resource=True, + required_scopes=["returns:read"], + scopes_supported=["returns:read", "returns:write"], + ), + token_verifier=_RejectingTokenVerifier(), + ) + + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=mcp.streamable_http_app()), + base_url="http://127.0.0.1:8000", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp") + + assert response.status_code == 200 + assert response.json()["scopes_supported"] == ["returns:read", "returns:write"] + + +@pytest.mark.anyio +async def test_scopes_supported_defaults_to_required_scopes(): + from mcp.server.auth.settings import AuthSettings + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer( + "test", + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + validate_token_resource=True, + required_scopes=["returns:read"], + ), + token_verifier=_RejectingTokenVerifier(), + ) + + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=mcp.streamable_http_app()), + base_url="http://127.0.0.1:8000", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp") + + assert response.status_code == 200 + assert response.json()["scopes_supported"] == ["returns:read"] + + @pytest.fixture def test_app(): """Fixture to create protected resource routes for testing."""