mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
6eda17aadb
commit
b67cdcc34e
3 changed files with 40 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue