diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 9729027f4ad..793e558c943 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -3312,27 +3312,18 @@ class MCPRequestHandler: axis twin of ``_apply_agent_caller_ceiling``, so the headers only ever narrow. Denies every tool on the server when the caller's team cannot be loaded, since a caller we cannot resolve must not read as unrestricted.""" - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - caller_auth: Final = agent_caller_auth(user_api_key_auth) if user_api_key_auth else None if caller_auth is None: return allowed_tools try: team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(caller_auth) - team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(team_obj_perm, server_id) + resolved_inventory: Final = await MCPRequestHandler._manager_inventory(server_id) + team_tools: Final = await MCPRequestHandler._row_level_tools(team_obj_perm, server_id, resolved_inventory) except Exception as e: # noqa: BLE001 # an unresolved caller team must deny, not widen verbose_logger.warning( "MCP agent caller team tool ceiling unresolvable, denying tools on %r: %s", server_id, e ) return () - team_direct_tools: Final = ( - global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id) - if team_obj_perm - else None - ) - team_tools: Final = MCPRequestHandler._union_tool_grants(team_direct_tools, team_toolset_tools) team_capped: Final = ( allowed_tools if team_tools is None @@ -3340,7 +3331,9 @@ class MCPRequestHandler: if allowed_tools is None else tuple(frozenset(allowed_tools) & frozenset(team_tools)) ) - return await MCPRequestHandler._apply_user_tool_ceiling(team_capped, server_id, caller_auth) + return await MCPRequestHandler._apply_user_tool_ceiling( + team_capped, server_id, caller_auth, inventory=resolved_inventory + ) @staticmethod async def _apply_end_user_tool_ceiling( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index c3e5d51ced0..685d4f4fe78 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -4384,6 +4384,48 @@ class TestAgentMCPPermissions: assert await MCPRequestHandler.get_allowed_tools_for_server("server-a", agent_key) == ["ask_wiki_question"] assert await MCPRequestHandler.get_allowed_tools_for_server("server-c", agent_key) == [] + async def test_agent_key_acting_for_a_user_is_capped_at_the_callers_convention_tools(self): + """A converted caller team granting server-a with no tool list still caps the agent to the + convention set: inventory non-deletes minus explicit denies.""" + agent_key = self._agent_key_acting_for(user_id="alice", team_id="callers") + + async def caller_team_permission( + user_api_key_auth: UserAPIKeyAuth | None = None, + ) -> LiteLLM_ObjectPermissionTable | None: + assert user_api_key_auth is not None + if user_api_key_auth.team_id != "callers": + return None + return LiteLLM_ObjectPermissionTable( + object_permission_id="perm-callers", + mcp_servers=["server-a"], + mcp_permission_version=1, + ) + + with ( + patch.object( # test-quality-ok: the level loaders read proxy_server globals with no injection seam + MCPRequestHandler, "_get_key_object_permission", return_value=None + ), + patch.object( # test-quality-ok: same seam, keyed by which team is being asked about + MCPRequestHandler, "_get_team_object_permission", AsyncMock(side_effect=caller_team_permission) + ), + patch.object( # test-quality-ok: same seam + MCPRequestHandler, "_get_user_object_permission", AsyncMock(return_value=None) + ), + patch.object( # test-quality-ok: agent object_permission lookup hits the DB, not under test here + MCPRequestHandler, "_get_agent_object_permission", AsyncMock(return_value=None) + ), + patch.object( # test-quality-ok: the discovered catalog is process state with no injection seam + MCPRequestHandler, + "_manager_inventory", + AsyncMock( + return_value={"read_wiki_structure": "reads a page", "delete_wiki_page": "deletes a page"} + ), + ), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("server-a", agent_key) == [ + "read_wiki_structure" + ] + async def test_agent_key_not_acting_for_anyone_ignores_the_caller_tool_ceiling(self): agent_key = UserAPIKeyAuth(api_key="agent-key", user_id="agent-owner", team_id="agent-team", agent_id="agent-1") diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index a88c9970801..2921819e904 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -484,7 +484,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser if (toolsets && toolsets.length > 0) { formValues.object_permission.mcp_toolsets = toolsets; } - delete formValues.allowed_mcp_servers_and_groups; } if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) { @@ -505,6 +504,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser } delete formValues.mcp_tool_overrides; } + delete formValues.allowed_mcp_servers_and_groups; } // Transform allowed_mcp_access_groups into object_permission