mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fix(ui): show MCP servers and agents inherited from access groups on the team overview
The team Overview and Settings tabs fed only object_permission into the Object Permissions card, so a team whose access group grants MCP servers or agents read "MCP Servers 0" and "Agents 0" while the Models card next to it already listed the inherited models. /team/info has returned access_group_mcp_server_ids and access_group_agent_ids for a while, nothing in the dashboard read them. ObjectPermissionsView now accepts the inherited ids and MCPServerPermissions / AgentPermissions merge them into their lists with an Inherited tag, deduped against direct grants, so an admin can tell a group grant from a direct one. Claude-Session: https://claude.ai/code/session_01QvQzYztinxj8ZuD5YxbVdL
This commit is contained in:
parent
97dbd8efcb
commit
c493fc855c
7 changed files with 194 additions and 17 deletions
|
|
@ -6,6 +6,8 @@ import type { ObjectPermission } from "./object_permission_types";
|
|||
|
||||
interface ObjectPermissionsViewProps {
|
||||
objectPermission?: ObjectPermission | null;
|
||||
inheritedMcpServerIds?: string[];
|
||||
inheritedAgentIds?: string[];
|
||||
variant?: "card" | "inline";
|
||||
className?: string;
|
||||
accessToken?: string | null;
|
||||
|
|
@ -13,6 +15,8 @@ interface ObjectPermissionsViewProps {
|
|||
|
||||
export function ObjectPermissionsView({
|
||||
objectPermission,
|
||||
inheritedMcpServerIds = [],
|
||||
inheritedAgentIds = [],
|
||||
variant = "card",
|
||||
className = "",
|
||||
accessToken,
|
||||
|
|
@ -34,9 +38,15 @@ export function ObjectPermissionsView({
|
|||
mcpAccessGroups={mcpAccessGroups}
|
||||
mcpToolPermissions={mcpToolPermissions}
|
||||
mcpToolsets={mcpToolsets}
|
||||
inheritedMcpServers={inheritedMcpServerIds}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<AgentPermissions
|
||||
agents={agents}
|
||||
agentAccessGroups={agentAccessGroups}
|
||||
inheritedAgents={inheritedAgentIds}
|
||||
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 ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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 with an Inherited tag and counts them", async () => {
|
||||
vi.mocked(networking.getAgentsList).mockResolvedValue({
|
||||
agents: [{ agent_id: agentId, agent_name: "support_agent" }],
|
||||
});
|
||||
|
||||
render(<AgentPermissions agents={[]} inheritedAgents={[agentId]} accessToken={accessToken} />);
|
||||
|
||||
expect(await screen.findByText(/support_agent/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Inherited")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument();
|
||||
expect(networking.getAgentsList).toHaveBeenCalledWith(accessToken);
|
||||
});
|
||||
|
||||
it("does not double-list an agent that is both granted directly and inherited", async () => {
|
||||
vi.mocked(networking.getAgentsList).mockResolvedValue({
|
||||
agents: [{ agent_id: agentId, agent_name: "support_agent" }],
|
||||
});
|
||||
|
||||
render(<AgentPermissions agents={[agentId]} inheritedAgents={[agentId]} accessToken={accessToken} />);
|
||||
|
||||
expect(await screen.findByText(/support_agent/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/support_agent/)).toHaveLength(1);
|
||||
expect(screen.queryByText("Inherited")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1")).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();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,16 +14,26 @@ interface Agent {
|
|||
interface AgentPermissionsProps {
|
||||
agents: string[];
|
||||
agentAccessGroups?: string[];
|
||||
inheritedAgents?: string[];
|
||||
accessToken?: string | null;
|
||||
}
|
||||
|
||||
export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }: AgentPermissionsProps) {
|
||||
const INHERITED_AGENT_TOOLTIP = "Granted through one of the team's access groups";
|
||||
|
||||
export function AgentPermissions({
|
||||
agents,
|
||||
agentAccessGroups = [],
|
||||
inheritedAgents = [],
|
||||
accessToken,
|
||||
}: AgentPermissionsProps) {
|
||||
const [agentDetails, setAgentDetails] = useState<Agent[]>([]);
|
||||
const inheritedOnlyAgents = inheritedAgents.filter((agent) => !agents.includes(agent));
|
||||
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 +45,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 +57,11 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
|
|||
return agentId;
|
||||
};
|
||||
|
||||
// Merge agents and access groups into one list
|
||||
// Merge agents, inherited 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, inherited: false })),
|
||||
...inheritedOnlyAgents.map((agent) => ({ type: "agent", value: agent, inherited: true })),
|
||||
...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group, inherited: false })),
|
||||
];
|
||||
const totalCount = mergedItems.length;
|
||||
|
||||
|
|
@ -76,8 +87,17 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
|
|||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{getAgentDisplayName(item.value)}
|
||||
</span>
|
||||
{item.inherited && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-purple-600 bg-purple-50 border border-purple-200 rounded-sm uppercase tracking-wide shrink-0 dark:text-purple-300 dark:bg-purple-950 dark:border-purple-800">
|
||||
Inherited
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`Full ID: ${item.value}`}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{item.inherited
|
||||
? `${INHERITED_AGENT_TOOLTIP}. Full ID: ${item.value}`
|
||||
: `Full ID: ${item.value}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -406,4 +406,47 @@ describe("MCPServerPermissions", () => {
|
|||
);
|
||||
await waitFor(() => expect(screen.getByText("Blocked")).toHaveAttribute("data-variant", "destructive"));
|
||||
});
|
||||
|
||||
it("lists servers inherited from access groups with an Inherited tag and counts them", async () => {
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 },
|
||||
]);
|
||||
|
||||
render(
|
||||
<MCPServerPermissions
|
||||
mcpServers={[]}
|
||||
mcpAccessGroups={[]}
|
||||
mcpToolPermissions={{}}
|
||||
inheritedMcpServers={[mockServerId1]}
|
||||
accessToken={mockAccessToken}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Inherited")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument();
|
||||
expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken);
|
||||
});
|
||||
|
||||
it("does not double-list a server that is both granted directly and inherited", async () => {
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
{ server_id: mockServerId2, server_name: mockServerName2, alias: mockServerName2 },
|
||||
]);
|
||||
|
||||
render(
|
||||
<MCPServerPermissions
|
||||
mcpServers={[mockServerId2]}
|
||||
mcpAccessGroups={[]}
|
||||
mcpToolPermissions={{}}
|
||||
inheritedMcpServers={[mockServerId2]}
|
||||
accessToken={mockAccessToken}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/Test Server/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Test Server/)).toHaveLength(1);
|
||||
expect(screen.queryByText("Inherited")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,14 +11,18 @@ interface MCPServerPermissionsProps {
|
|||
mcpAccessGroups?: string[];
|
||||
mcpToolPermissions?: Record<string, string[]>;
|
||||
mcpToolsets?: string[];
|
||||
inheritedMcpServers?: string[];
|
||||
accessToken?: string | null;
|
||||
}
|
||||
|
||||
const INHERITED_MCP_SERVER_TOOLTIP = "Granted through one of the team's access groups";
|
||||
|
||||
export function MCPServerPermissions({
|
||||
mcpServers,
|
||||
mcpAccessGroups = [],
|
||||
mcpToolPermissions = {},
|
||||
mcpToolsets = [],
|
||||
inheritedMcpServers = [],
|
||||
accessToken,
|
||||
}: MCPServerPermissionsProps) {
|
||||
const [mcpServerDetails, setMCPServerDetails] = useState<MCPServer[]>([]);
|
||||
|
|
@ -50,10 +54,16 @@ export function MCPServerPermissions({
|
|||
});
|
||||
};
|
||||
|
||||
const directServerIds = mcpServers.filter(
|
||||
(server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL,
|
||||
);
|
||||
const inheritedOnlyServerIds = inheritedMcpServers.filter((server) => !mcpServers.includes(server));
|
||||
const serverIdCount = directServerIds.length + inheritedOnlyServerIds.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 +77,7 @@ export function MCPServerPermissions({
|
|||
}
|
||||
};
|
||||
fetchMCPServerDetails();
|
||||
}, [accessToken, mcpServers.length]);
|
||||
}, [accessToken, serverIdCount]);
|
||||
|
||||
// Fetch toolset details
|
||||
useEffect(() => {
|
||||
|
|
@ -98,12 +108,11 @@ 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
|
||||
// Merge servers, inherited 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, inherited: false })),
|
||||
...inheritedOnlyServerIds.map((server) => ({ type: "server", value: server, inherited: true })),
|
||||
...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group, inherited: false })),
|
||||
];
|
||||
const totalCount = mergedItems.length + mcpToolsets.length;
|
||||
|
||||
|
|
@ -152,8 +161,17 @@ export function MCPServerPermissions({
|
|||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{getMCPServerDisplayName(item.value)}
|
||||
</span>
|
||||
{item.inherited && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-info bg-info/10 border border-info/20 rounded-sm uppercase tracking-wide shrink-0">
|
||||
Inherited
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`Full ID: ${item.value}`}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{item.inherited
|
||||
? `${INHERITED_MCP_SERVER_TOOLTIP}. Full ID: ${item.value}`
|
||||
: `Full ID: ${item.value}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 min-w-0">
|
||||
|
|
|
|||
|
|
@ -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,31 @@ describe("TeamInfoView", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("shows MCP servers and agents inherited from access groups in the Object Permissions card", async () => {
|
||||
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" }],
|
||||
});
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
object_permission: null,
|
||||
access_group_ids: ["ag-1"],
|
||||
access_group_mcp_server_ids: ["mcp-github-1234"],
|
||||
access_group_agent_ids: ["agent-support-5678"],
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText(/github \(mcp\.\.\.1234\)/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/support_agent \(age\.\.\.5678\)/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Inherited")).toHaveLength(2);
|
||||
expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the all-proxy-models badge non-clickable", async () => {
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["all-proxy-models"] }));
|
||||
|
||||
|
|
|
|||
|
|
@ -1033,7 +1033,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView objectPermission={info.object_permission} variant="card" accessToken={accessToken} />
|
||||
<ObjectPermissionsView
|
||||
objectPermission={info.object_permission}
|
||||
inheritedMcpServerIds={info.access_group_mcp_server_ids}
|
||||
inheritedAgentIds={info.access_group_agent_ids}
|
||||
variant="card"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
||||
<Card className="block p-6">
|
||||
<GuardrailSettingsView
|
||||
|
|
@ -1883,6 +1889,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={info.object_permission}
|
||||
inheritedMcpServerIds={info.access_group_mcp_server_ids}
|
||||
inheritedAgentIds={info.access_group_agent_ids}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-border"
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue