Merge pull request #39215 from BerriAI/litellm_lit_6592_team_overview_inherited_mcp_agents

fix(ui): show MCP servers and agents inherited from access groups on team overview
This commit is contained in:
ryan-crabbe-berri 2026-09-03 12:53:27 -07:00 committed by GitHub
commit f173a6baf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 331 additions and 21 deletions

View file

@ -4239,6 +4239,8 @@ class TeamAccessGroupModelGrant(LiteLLMPydanticObjectBase):
access_group_id: str
access_group_name: str
models: tuple[str, ...]
mcp_server_ids: tuple[str, ...] = ()
agent_ids: tuple[str, ...] = ()
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):

View file

@ -4318,6 +4318,8 @@ async def _resolve_team_access_group_resources(
access_group_id=group.access_group_id,
access_group_name=group.access_group_name,
models=tuple(group.access_model_names or ()),
mcp_server_ids=tuple(group.access_mcp_server_ids or ()),
agent_ids=tuple(group.access_agent_ids or ()),
)
for group in resolved_groups
),

View file

@ -9897,11 +9897,11 @@ class TestResolveTeamAccessGroupResources:
assert resolved.access_group_mcp_server_ids == ["mcp-1"]
assert resolved.access_group_agent_ids == ["agent-1"]
assert [
(d.access_group_id, d.access_group_name, d.models)
(d.access_group_id, d.access_group_name, d.models, d.mcp_server_ids, d.agent_ids)
for d in (resolved.access_group_details or [])
] == [
("ag-1", "shared-models", ("gpt-4", "claude-3")),
("ag-2", "extra-models", ("claude-3", "gemini")),
("ag-1", "shared-models", ("gpt-4", "claude-3"), ("mcp-1",), ()),
("ag-2", "extra-models", ("claude-3", "gemini"), (), ("agent-1",)),
]
@pytest.mark.asyncio

View file

@ -3,9 +3,12 @@ import VectorStorePermissions from "./permissions/VectorStorePermissions";
import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
import type { ObjectPermission } from "./object_permission_types";
import type { InheritedGrant } from "./permissions/inheritedGrants";
interface ObjectPermissionsViewProps {
objectPermission?: ObjectPermission | null;
inheritedMcpServers?: InheritedGrant[];
inheritedAgents?: InheritedGrant[];
variant?: "card" | "inline";
className?: string;
accessToken?: string | null;
@ -13,6 +16,8 @@ interface ObjectPermissionsViewProps {
export function ObjectPermissionsView({
objectPermission,
inheritedMcpServers = [],
inheritedAgents = [],
variant = "card",
className = "",
accessToken,
@ -34,9 +39,15 @@ export function ObjectPermissionsView({
mcpAccessGroups={mcpAccessGroups}
mcpToolPermissions={mcpToolPermissions}
mcpToolsets={mcpToolsets}
inheritedMcpServers={inheritedMcpServers}
accessToken={accessToken}
/>
<AgentPermissions
agents={agents}
agentAccessGroups={agentAccessGroups}
inheritedAgents={inheritedAgents}
accessToken={accessToken}
/>
<AgentPermissions agents={agents} agentAccessGroups={agentAccessGroups} accessToken={accessToken} />
<div className="min-w-0 rounded-md border border-border p-4">
<p className="text-sm font-medium text-foreground">Search tools</p>
{searchTools.length === 0 ? (

View file

@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AgentPermissions from "./AgentPermissions";
import * as networking from "../networking";
vi.mock("../networking");
describe("AgentPermissions", () => {
const accessToken = "test-token";
const agentId = "90337622-756e-4f25-98f0-01fc8174aa24";
beforeEach(() => {
vi.clearAllMocks();
});
it("lists agents inherited from access groups, counts them, and names the groups on hover", async () => {
const user = userEvent.setup();
vi.mocked(networking.getAgentsList).mockResolvedValue({
agents: [{ agent_id: agentId, agent_name: "support_agent" }],
});
render(
<AgentPermissions
agents={[]}
inheritedAgents={[{ id: agentId, accessGroupNames: ["platform-tools", "support"] }]}
accessToken={accessToken}
/>,
);
const row = await screen.findByText(/support_agent/);
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument();
expect(networking.getAgentsList).toHaveBeenCalledWith(accessToken);
await user.hover(row);
expect(
await screen.findByText(`Granted via access groups platform-tools, support. Full ID: ${agentId}`),
).toBeInTheDocument();
});
it("does not double-list an agent that is both granted directly and inherited", async () => {
const user = userEvent.setup();
vi.mocked(networking.getAgentsList).mockResolvedValue({
agents: [{ agent_id: agentId, agent_name: "support_agent" }],
});
render(
<AgentPermissions
agents={[agentId]}
inheritedAgents={[{ id: agentId, accessGroupNames: ["support"] }]}
accessToken={accessToken}
/>,
);
const row = await screen.findByText(/support_agent/);
expect(screen.getAllByText(/support_agent/)).toHaveLength(1);
expect(screen.getByText("1")).toBeInTheDocument();
await user.hover(row);
expect(await screen.findByText(`Full ID: ${agentId}`)).toBeInTheDocument();
});
it("shows the empty state when nothing is granted directly or inherited", () => {
render(<AgentPermissions agents={[]} inheritedAgents={[]} accessToken={accessToken} />);
expect(screen.getByText("No agents or access groups configured")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
expect(networking.getAgentsList).not.toHaveBeenCalled();
});
});

View file

@ -3,6 +3,7 @@ import { UserGroupIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getAgentsList } from "../networking";
import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants";
interface Agent {
agent_id: string;
@ -14,16 +15,24 @@ interface Agent {
interface AgentPermissionsProps {
agents: string[];
agentAccessGroups?: string[];
inheritedAgents?: InheritedGrant[];
accessToken?: string | null;
}
export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }: AgentPermissionsProps) {
export function AgentPermissions({
agents,
agentAccessGroups = [],
inheritedAgents = [],
accessToken,
}: AgentPermissionsProps) {
const [agentDetails, setAgentDetails] = useState<Agent[]>([]);
const inheritedOnlyAgents = inheritedAgents.filter((grant) => !agents.includes(grant.id));
const agentIdCount = agents.length + inheritedOnlyAgents.length;
// Fetch agent details when component mounts
useEffect(() => {
const fetchAgentDetails = async () => {
if (accessToken && agents.length > 0) {
if (accessToken && agentIdCount > 0) {
try {
const response = await getAgentsList(accessToken);
if (response && response.agents && Array.isArray(response.agents)) {
@ -35,7 +44,7 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
}
};
fetchAgentDetails();
}, [accessToken, agents.length]);
}, [accessToken, agentIdCount]);
// Function to get display name for agent
const getAgentDisplayName = (agentId: string) => {
@ -47,10 +56,10 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
return agentId;
};
// Merge agents and access groups into one list
const mergedItems = [
...agents.map((agent) => ({ type: "agent", value: agent })),
...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group })),
...agents.map((agent) => ({ type: "agent", value: agent, tooltip: `Full ID: ${agent}` })),
...inheritedOnlyAgents.map((grant) => ({ type: "agent", value: grant.id, tooltip: inheritedGrantTooltip(grant) })),
...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })),
];
const totalCount = mergedItems.length;
@ -77,7 +86,7 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
{getAgentDisplayName(item.value)}
</span>
</TooltipTrigger>
<TooltipContent>{`Full ID: ${item.value}`}</TooltipContent>
<TooltipContent>{item.tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (

View file

@ -406,4 +406,55 @@ describe("MCPServerPermissions", () => {
);
await waitFor(() => expect(screen.getByText("Blocked")).toHaveAttribute("data-variant", "destructive"));
});
it("lists servers inherited from access groups, counts them, and names the group on hover", async () => {
const user = userEvent.setup();
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 },
]);
render(
<MCPServerPermissions
mcpServers={[]}
mcpAccessGroups={[]}
mcpToolPermissions={{}}
inheritedMcpServers={[{ id: mockServerId1, accessGroupNames: ["platform-tools"] }]}
accessToken={mockAccessToken}
/>,
);
const row = await screen.findByText(/DW_MCP/);
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument();
expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken);
await user.hover(row);
expect(
await screen.findByText(`Granted via access group platform-tools. Full ID: ${mockServerId1}`),
).toBeInTheDocument();
});
it("does not double-list a server that is both granted directly and inherited", async () => {
const user = userEvent.setup();
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: mockServerId2, server_name: mockServerName2, alias: mockServerName2 },
]);
render(
<MCPServerPermissions
mcpServers={[mockServerId2]}
mcpAccessGroups={[]}
mcpToolPermissions={{}}
inheritedMcpServers={[{ id: mockServerId2, accessGroupNames: ["platform-tools"] }]}
accessToken={mockAccessToken}
/>,
);
const row = await screen.findByText(/Test Server/);
expect(screen.getAllByText(/Test Server/)).toHaveLength(1);
expect(screen.getByText("1")).toBeInTheDocument();
await user.hover(row);
expect(await screen.findByText(`Full ID: ${mockServerId2}`)).toBeInTheDocument();
});
});

View file

@ -5,12 +5,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { fetchMCPServers, fetchMCPToolsets } from "../networking";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants";
interface MCPServerPermissionsProps {
mcpServers: string[];
mcpAccessGroups?: string[];
mcpToolPermissions?: Record<string, string[]>;
mcpToolsets?: string[];
inheritedMcpServers?: InheritedGrant[];
accessToken?: string | null;
}
@ -19,6 +21,7 @@ export function MCPServerPermissions({
mcpAccessGroups = [],
mcpToolPermissions = {},
mcpToolsets = [],
inheritedMcpServers = [],
accessToken,
}: MCPServerPermissionsProps) {
const [mcpServerDetails, setMCPServerDetails] = useState<MCPServer[]>([]);
@ -50,10 +53,16 @@ export function MCPServerPermissions({
});
};
const directServerIds = mcpServers.filter(
(server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL,
);
const inheritedOnlyServers = inheritedMcpServers.filter((grant) => !mcpServers.includes(grant.id));
const serverIdCount = directServerIds.length + inheritedOnlyServers.length;
// Fetch MCP server details when component mounts
useEffect(() => {
const fetchMCPServerDetails = async () => {
if (accessToken && mcpServers.length > 0) {
if (accessToken && serverIdCount > 0) {
try {
const response = await fetchMCPServers(accessToken);
if (response && Array.isArray(response)) {
@ -67,7 +76,7 @@ export function MCPServerPermissions({
}
};
fetchMCPServerDetails();
}, [accessToken, mcpServers.length]);
}, [accessToken, serverIdCount]);
// Fetch toolset details
useEffect(() => {
@ -98,12 +107,14 @@ export function MCPServerPermissions({
const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL);
const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL);
// Merge servers and access groups into one list
const mergedItems = [
...mcpServers
.filter((server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL)
.map((server) => ({ type: "server", value: server })),
...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })),
...directServerIds.map((server) => ({ type: "server", value: server, tooltip: `Full ID: ${server}` })),
...inheritedOnlyServers.map((grant) => ({
type: "server",
value: grant.id,
tooltip: inheritedGrantTooltip(grant),
})),
...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })),
];
const totalCount = mergedItems.length + mcpToolsets.length;
@ -153,7 +164,7 @@ export function MCPServerPermissions({
{getMCPServerDisplayName(item.value)}
</span>
</TooltipTrigger>
<TooltipContent>{`Full ID: ${item.value}`}</TooltipContent>
<TooltipContent>{item.tooltip}</TooltipContent>
</Tooltip>
) : (
<div className="inline-flex items-center gap-2 min-w-0">

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { computeInheritedGrants, inheritedGrantTooltip } from "./inheritedGrants";
import { TeamAccessGroupModelGrant } from "../team/teamModelAccess";
const GRANTS: TeamAccessGroupModelGrant[] = [
{ access_group_id: "ag-1", access_group_name: "platform-tools", models: [], mcp_server_ids: ["mcp-1", "mcp-2"] },
{
access_group_id: "ag-2",
access_group_name: "support",
models: [],
mcp_server_ids: ["mcp-2"],
agent_ids: ["agent-1"],
},
];
describe("computeInheritedGrants", () => {
it("attributes each id to every group that grants it, in group order", () => {
expect(computeInheritedGrants(["mcp-1", "mcp-2"], GRANTS, (g) => g.mcp_server_ids)).toEqual([
{ id: "mcp-1", accessGroupNames: ["platform-tools"] },
{ id: "mcp-2", accessGroupNames: ["platform-tools", "support"] },
]);
});
it("keeps ids the flat list carries but no group detail explains, with no group names", () => {
expect(computeInheritedGrants(["agent-1", "agent-legacy"], GRANTS, (g) => g.agent_ids)).toEqual([
{ id: "agent-1", accessGroupNames: ["support"] },
{ id: "agent-legacy", accessGroupNames: [] },
]);
});
it("falls back to the group details when the flat list is missing, without duplicates", () => {
expect(computeInheritedGrants(undefined, GRANTS, (g) => g.mcp_server_ids).map((g) => g.id)).toEqual([
"mcp-1",
"mcp-2",
]);
});
it("returns nothing when neither source has ids", () => {
expect(computeInheritedGrants(undefined, undefined, (g) => g.agent_ids)).toEqual([]);
});
});
describe("inheritedGrantTooltip", () => {
it("names a single group", () => {
expect(inheritedGrantTooltip({ id: "mcp-1", accessGroupNames: ["platform-tools"] })).toBe(
"Granted via access group platform-tools. Full ID: mcp-1",
);
});
it("lists several groups", () => {
expect(inheritedGrantTooltip({ id: "mcp-2", accessGroupNames: ["platform-tools", "support"] })).toBe(
"Granted via access groups platform-tools, support. Full ID: mcp-2",
);
});
it("stays generic when the proxy did not say which group granted it", () => {
expect(inheritedGrantTooltip({ id: "agent-legacy", accessGroupNames: [] })).toBe(
"Granted via an access group. Full ID: agent-legacy",
);
});
});

View file

@ -0,0 +1,26 @@
import { describeGroups, TeamAccessGroupModelGrant } from "../team/teamModelAccess";
export interface InheritedGrant {
id: string;
accessGroupNames: string[];
}
export function computeInheritedGrants(
ids: string[] | undefined,
grants: TeamAccessGroupModelGrant[] | undefined,
idsOf: (grant: TeamAccessGroupModelGrant) => string[] | undefined,
): InheritedGrant[] {
const known = grants ?? [];
const allIds = [...new Set([...(ids ?? []), ...known.flatMap((grant) => idsOf(grant) ?? [])])];
return allIds.map((id) => ({
id,
accessGroupNames: known
.filter((grant) => (idsOf(grant) ?? []).includes(id))
.map((grant) => grant.access_group_name),
}));
}
export const inheritedGrantTooltip = (grant: InheritedGrant): string => {
const source = grant.accessGroupNames.length > 0 ? describeGroups(grant.accessGroupNames) : "an access group";
return `Granted via ${source}. Full ID: ${grant.id}`;
};

View file

@ -38,6 +38,9 @@ vi.mock("@/components/networking", () => ({
organizationInfoCall: vi.fn(),
getRouterSettingsCall: vi.fn().mockResolvedValue({ fields: [] }),
getPassThroughEndpointsCall: vi.fn(),
fetchMCPServers: vi.fn().mockResolvedValue([]),
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
}));
const can = vi.fn();
@ -302,6 +305,47 @@ describe("TeamInfoView", () => {
);
});
it("shows MCP servers and agents inherited from access groups in the Object Permissions card, naming the group on hover", async () => {
const user = userEvent.setup();
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: "mcp-github-1234", server_name: "github", alias: "github" },
]);
vi.mocked(networking.getAgentsList).mockResolvedValue({
agents: [{ agent_id: "agent-support-5678", agent_name: "support_agent" }],
});
const platformToolsGroup = {
access_group_id: "ag-1",
access_group_name: "platform-tools",
models: [],
mcp_server_ids: ["mcp-github-1234"],
agent_ids: ["agent-support-5678"],
};
const inheritedGrants = {
object_permission: null,
access_group_ids: ["ag-1"],
access_group_mcp_server_ids: ["mcp-github-1234"],
access_group_agent_ids: ["agent-support-5678"],
access_group_details: [platformToolsGroup],
};
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData(inheritedGrants));
renderWithProviders(<TeamInfoView {...defaultProps} />);
const serverRow = await screen.findByText(/github \(mcp\.\.\.1234\)/);
const agentRow = await screen.findByText(/support_agent \(age\.\.\.5678\)/);
expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument();
expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument();
await user.hover(serverRow);
expect(
await screen.findByText("Granted via access group platform-tools. Full ID: mcp-github-1234"),
).toBeInTheDocument();
await user.hover(agentRow);
expect(
await screen.findByText("Granted via access group platform-tools. Full ID: agent-support-5678"),
).toBeInTheDocument();
});
it("keeps the all-proxy-models badge non-clickable", async () => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["all-proxy-models"] }));

View file

@ -58,6 +58,7 @@ import {
TeamModelBadge,
TeamModelBadgeKind,
} from "./teamModelAccess";
import { computeInheritedGrants } from "../permissions/inheritedGrants";
import MetadataKeyValueFields, {
metadataObjectToPairs,
metadataPairsSchema,
@ -936,6 +937,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const { team_info: info } = teamData;
const inheritedMcpServers = computeInheritedGrants(
info.access_group_mcp_server_ids,
info.access_group_details,
(grant) => grant.mcp_server_ids,
);
const inheritedAgents = computeInheritedGrants(
info.access_group_agent_ids,
info.access_group_details,
(grant) => grant.agent_ids,
);
const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true;
const allGuardrails: GuardrailListItem[] = guardrailsData?.guardrails ?? [];
@ -1033,7 +1045,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
</Card>
<ObjectPermissionsView objectPermission={info.object_permission} variant="card" accessToken={accessToken} />
<ObjectPermissionsView
objectPermission={info.object_permission}
inheritedMcpServers={inheritedMcpServers}
inheritedAgents={inheritedAgents}
variant="card"
accessToken={accessToken}
/>
<Card className="block p-6">
<GuardrailSettingsView
@ -1883,6 +1901,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<ObjectPermissionsView
objectPermission={info.object_permission}
inheritedMcpServers={inheritedMcpServers}
inheritedAgents={inheritedAgents}
variant="inline"
className="pt-4 border-t border-border"
accessToken={accessToken}

View file

@ -5,6 +5,8 @@ export interface TeamAccessGroupModelGrant {
access_group_id: string;
access_group_name: string;
models: string[];
mcp_server_ids?: string[];
agent_ids?: string[];
}
export type TeamModelBadgeKind = "all-proxy" | "no-default" | "direct" | "access-group";
@ -19,7 +21,7 @@ export function normalizeTeamModelSelection(models: string[] | undefined): strin
return models && models.length > 0 ? models : [NO_DEFAULT_MODELS];
}
const describeGroups = (names: string[]): string =>
export const describeGroups = (names: string[]): string =>
names.length > 1 ? `access groups ${names.join(", ")}` : `access group ${names[0]}`;
export function computeTeamModelBadges(