diff --git a/litellm/proxy/mcp_server/discoverable_endpoints.py b/litellm/proxy/mcp_server/discoverable_endpoints.py index f36609aa8b3..36a312d7c1f 100644 --- a/litellm/proxy/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/mcp_server/discoverable_endpoints.py @@ -110,6 +110,7 @@ from litellm.proxy.mcp_server.oauth_identity_binding import ( from litellm.proxy.mcp_server.oauth_utils import ( CIMD_METADATA_PATH, TOKEN_NO_CACHE_HEADERS, + asks_for_wildcard, build_upstream_authorize_url, build_upstream_oauth2_token_request, configured_proxy_base_url, @@ -1066,7 +1067,10 @@ async def _managed_authorization_scope( if scope_user_id and prisma_client is not None else None ) - merged_scope: Final = union_scopes(" ".join(prior_grant.get("scopes") or ()) if prior_grant else None, scope) + kept_scope: Final = ( + None if prior_grant is None or asks_for_wildcard(scope) else " ".join(prior_grant.get("scopes") or ()) + ) + merged_scope: Final = union_scopes(kept_scope, scope) selected_scope: Final = ( get_client_metadata_scopes( merged_scope, diff --git a/litellm/proxy/mcp_server/oauth_utils.py b/litellm/proxy/mcp_server/oauth_utils.py index 3171c476ede..c4fbaa7b087 100644 --- a/litellm/proxy/mcp_server/oauth_utils.py +++ b/litellm/proxy/mcp_server/oauth_utils.py @@ -132,18 +132,27 @@ def build_upstream_authorize_url(authorization_url: str, params: Mapping[str, st return urlunparse(parsed._replace(query=urlencode(merged))) +MCP_WILDCARD_SCOPE: Final = "mcp_all" + + +def asks_for_wildcard(scope: str | None) -> bool: + return MCP_WILDCARD_SCOPE in (scope or "").split() + + def grant_covers_scopes( granted: tuple[str, ...], requested: tuple[str, ...], server_scopes: Callable[[], frozenset[str]] ) -> bool: - """Whether a stored grant holds every requested scope. A grant stored as one comma-joined token, the form - Slack and GitHub reply with, counts as its parts only when every part is a requested or server scope.""" - if all(scope in granted for scope in requested): + """Whether a stored grant holds every requested scope. A non-empty grant holds the wildcard scope, which a + provider grants as granular scopes in its place. A grant stored as one comma-joined token, the form Slack and + GitHub reply with, counts as its parts only when every part is a requested or server scope.""" + held: Final = frozenset((*granted, MCP_WILDCARD_SCOPE)) if granted else frozenset[str]() + if all(scope in held for scope in requested): return True if len(granted) != 1 or "," not in granted[0]: return False parts: Final = frozenset(granted[0].split(",")) known: Final = frozenset(requested) | server_scopes() - return parts <= known and all(scope in granted or scope in parts for scope in requested) + return parts <= known and all(scope in held or scope in parts for scope in requested) _PUBLISHED_SCOPES: Final = TypeAdapter[tuple[str, ...] | None](tuple[str, ...] | None) diff --git a/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py b/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py index 64413c4a74a..b42f558689c 100644 --- a/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py +++ b/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py @@ -14999,11 +14999,23 @@ async def test_saved_refresh_metadata_is_bound_to_the_callers_stored_grant( @pytest.mark.asyncio @pytest.mark.parametrize( - "prior_scopes, expected", [(["tools.read"], "tools.read tools.write"), (None, "tools.write"), ([], "tools.write")] + "prior_scopes, scope, expected", + [ + (["tools.read"], "tools.write", "tools.read tools.write"), + (None, "tools.write", "tools.write"), + ([], "tools.write", "tools.write"), + (["logs_read_data", "dashboards_read"], "mcp_all", "mcp_all"), + (["logs_read_data", "dashboards_read"], "openid mcp_all", "openid mcp_all"), + (["tools.read"], "mcp_allowed", "tools.read mcp_allowed"), + ], ) -async def test_managed_authorize_retains_the_callers_previous_grant( - jwt_oauth_identity, monkeypatch, prior_scopes, expected -): +async def test_managed_authorize_retains_the_previous_grant_except_for_a_wildcard_request( + jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"], + monkeypatch: pytest.MonkeyPatch, + prior_scopes: list[str] | None, + scope: str, + expected: str, +) -> None: from types import SimpleNamespace from urllib.parse import parse_qs, urlsplit @@ -15029,7 +15041,7 @@ async def test_managed_authorize_retains_the_callers_previous_grant( server, "scope-client", "http://127.0.0.1:9/cb", - scope="tools.write", + scope=scope, code_challenge="challenge", code_challenge_method="S256", ) @@ -15298,6 +15310,7 @@ async def test_connect_requires_the_requested_vendor_grant(monkeypatch, granted, _SLACK_SCOPES: Final = frozenset(("channels:read", "chat:write", "files:write")) +_DATADOG_EXPANSION: Final = ("logs_read_index_data", "dashboards_read", "mcp_read", "mcp_write") _COMMA_NAMED_SCOPES: Final = frozenset(("files:read", "files:read,write")) @@ -15316,6 +15329,10 @@ _COMMA_NAMED_SCOPES: Final = frozenset(("files:read", "files:read,write")) (("read", "write"), ("read", "write"), frozenset[str](), True), ((), ("read",), frozenset(("read",)), False), ((), (), frozenset[str](), True), + (_DATADOG_EXPANSION, ("mcp_all",), frozenset[str](), True), + (("channels:read,chat:write",), ("mcp_all", "channels:read"), _SLACK_SCOPES, True), + ((), ("mcp_all",), frozenset[str](), False), + (_DATADOG_EXPANSION, ("mcp_all", "admin"), frozenset[str](), False), ], ) def test_grant_covers_scopes_splits_a_lone_token_only_into_known_scopes(