fix(mcp): treat the mcp_all scope as a wildcard any grant covers (#51578)

Datadog answers a request for mcp_all with its granular scopes, so the connect
check never found mcp_all and users looped back to Connect. Reconnecting also
re-asked every granular scope from the old grant, which overflowed the sign-in
cookie. Any non-empty grant now covers mcp_all, and a request for mcp_all does
not re-ask the old grant
This commit is contained in:
shrey 2026-11-13 07:52:46 -08:00 • committed by GitHub
parent 6eda17aadb
commit b67cdcc34e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 40 additions and 10 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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(