fix(proxy)!: bind admin-owned team keys to their team's MCP servers

A virtual key on a real team now resolves MCP servers and toolsets through its team even when its
owner is a proxy_admin or proxy_admin_viewer, the way team model access already binds it. The
unscoped admin view stays for the master key, admin keys with no team, the admin dashboard
session, and admitted user subjects. The toolset grant checks on /toolset/{name}/mcp and on the
Responses litellm_proxy MCP tool use the same rule.

BREAKING CHANGE: a team key owned by a proxy admin or admin viewer no longer reaches MCP servers or
toolsets its team does not grant. Grant them to the team or use a key with no team.
This commit is contained in:
mateo-berri 2026-10-20 19:12:21 -07:00
parent e17cd3e6fd
commit e9fef302d9
3 changed files with 31 additions and 17 deletions

View file

@ -300,6 +300,18 @@ def is_mcp_admitted_user_subject(user_api_key_auth: UserAPIKeyAuth | None) -> bo
_is_mcp_admitted_user_subject: Final = is_mcp_admitted_user_subject
def has_team_unbound_mcp_admin_view(user_api_key_auth: UserAPIKeyAuth | None) -> bool:
return (
user_api_key_auth is not None
and user_api_key_has_admin_view(user_api_key_auth)
and (
is_mcp_admitted_user_subject(user_api_key_auth)
or not user_api_key_auth.team_id
or user_api_key_auth.team_id == UI_TEAM_ID
)
)
def _gateway_dcr_challenge_target(
route: str,
mcp_servers: list[str] | None,
@ -4080,19 +4092,19 @@ class MCPRequestHandler:
"""Whether this principal's admin-view role grants the unscoped MCP resolution, whatever
credential carries it (admin key, dashboard session, or OAuth-admitted session subject).
Two bounds disqualify, one per ownership of the row. A CREDENTIAL's explicit
``object_permission.mcp_servers`` scope wins even for admins, including the empty list. An
admitted subject's object_permission is the user's own row, whose ``mcp_servers`` column is
[] by DB default, so for that shape the row binds through the entitlement ceiling instead
(any non-empty entitlement, or an unresolved one, disqualifies), exactly as
``operator_open_server_ids`` reads the same row. The one owner of this predicate: the
server-axis registry resolution in ``get_allowed_mcp_servers`` and the tools-axis open
channel in ``resolve_admitted_subject_tools`` both consult it, so the two axes cannot
disagree."""
A virtual key on a real team never qualifies: its team bounds its MCP access whoever owns it, as it
bounds the key's model access. Two more bounds disqualify, one per ownership of the row. A CREDENTIAL's
explicit ``object_permission.mcp_servers`` scope wins even for admins, including the empty list. An
admitted subject's object_permission is the user's own row, whose ``mcp_servers`` column is [] by DB
default, so for that shape the row binds through the entitlement ceiling instead (any non-empty
entitlement, or an unresolved one, disqualifies), exactly as ``operator_open_server_ids`` reads the same
row. The one owner of this predicate: the server-axis registry resolution in ``get_allowed_mcp_servers``
and the tools-axis open channel in ``resolve_admitted_subject_tools`` both consult it, so the two axes
cannot disagree."""
if (
user_api_key_auth is None
or user_api_key_auth.mcp_explicit_grants_only
or not user_api_key_has_admin_view(user_api_key_auth)
or not has_team_unbound_mcp_admin_view(user_api_key_auth)
):
return False
object_permission: Final = user_api_key_auth.object_permission

View file

@ -1760,10 +1760,10 @@ if MCP_AVAILABLE:
row capping it.
Raises HTTPException(403) unless the principal holds toolset_id through one
of its grant sources. Admins always pass.
of its grant sources. Admins pass unless the key is bound to a real team.
"""
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import has_team_unbound_mcp_admin_view
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
from litellm.proxy.management_endpoints.common_utils import user_api_key_has_admin_view
# A key scoped to no MCP servers opts out of every MCP path. Enforce it
# here too, since toolset scoping replaces mcp_servers and would otherwise
@ -1777,7 +1777,7 @@ if MCP_AVAILABLE:
)
acting: Final = await acting_user(user_api_key_auth)
is_admin: Final = user_api_key_has_admin_view(acting)
is_admin: Final = has_team_unbound_mcp_admin_view(acting)
if not is_admin and toolset_id not in await granted(acting):
raise HTTPException(
status_code=403,

View file

@ -282,14 +282,16 @@ class LiteLLM_Proxy_MCP_Handler:
toolset = await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, name)
if toolset is not None:
if user_api_key_auth is not None:
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
has_team_unbound_mcp_admin_view,
)
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
granted_toolset_ids,
)
from litellm.proxy.management_endpoints.common_utils import (
user_api_key_has_admin_view,
)
if not user_api_key_has_admin_view(user_api_key_auth) and toolset.toolset_id not in (
if not has_team_unbound_mcp_admin_view(
user_api_key_auth
) and toolset.toolset_id not in (
await (granted_toolsets or granted_toolset_ids)(user_api_key_auth)
):
verbose_logger.debug("Key does not have access to toolset '%s', skipping.", name)