diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx index 74b3c3b6a08..62f74e49e4b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx @@ -66,7 +66,7 @@ describe("CustomersTable controls", () => { expect(screen.getByRole("combobox", { name: "Budget" })).toHaveTextContent(/^All\b/); await chooseSelectOption(user, screen.getByRole("combobox", { name: "Status" }), "Active"); await chooseSelectOption(user, screen.getByRole("combobox", { name: "Budget" }), "Has budget"); - await chooseSelectOption(user, screen.getByRole("combobox", { name: "MCP access" }), "Has MCP access"); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "MCP access" }), "Limited MCP access"); expect(screen.getByRole("combobox", { name: "Budget" })).toHaveTextContent(/^Has budget\b/); await user.click(screen.getByRole("button", { name: "Apply Filters" })); @@ -78,7 +78,7 @@ describe("CustomersTable controls", () => { const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; expect(params.get("filter_blocked")).toBe("active"); expect(params.get("filter_max_budget")).toBe("yes"); - expect(params.get("filter_mcp_access")).toBe("yes"); + expect(params.get("filter_mcp_access")).toBe("limited"); await user.click(screen.getByRole("button", { name: /^Filters/ })); await user.click(screen.getByRole("button", { name: "Reset" })); await user.click(screen.getByRole("button", { name: "Apply Filters" })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx index 9ba8ae32d0b..91edd53fadc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx @@ -22,7 +22,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -import { getCustomerResourceCounts } from "./customerResources"; +import { customerMcpScope, getCustomerResourceCounts } from "./customerResources"; export type CustomerTableActions = { canModify: boolean; @@ -102,8 +102,9 @@ export function CustomerBlockAction({ const countLabel = (count: number, noun: string): string => (count ? `${count} ${noun}${count === 1 ? "" : "s"}` : ""); export function describeMcpGrant(permission: EndUser["object_permission"]): string { - const servers = permission?.mcp_servers ?? []; - if (servers.includes(NO_MCP_SERVERS_SENTINEL)) return "No MCP access"; + const scope = customerMcpScope(permission); + if (scope === "denied") return "No MCP access"; + if (scope === "unrestricted") return "No restriction"; const counts = getCustomerResourceCounts({ object_permission: permission }); const toolPermissionKeys = Object.keys(permission?.mcp_tool_permissions ?? {}).filter( (server) => server !== ALL_PROXY_MCP_SERVERS_SENTINEL && server !== NO_MCP_SERVERS_SENTINEL, @@ -114,10 +115,7 @@ export function describeMcpGrant(permission: EndUser["object_permission"]): stri countLabel(counts.toolsets, "toolset"), countLabel(toolPermissionKeys.length, "tool limit"), ].filter(Boolean); - const hasUnsupportedAll = - servers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL) || - Object.hasOwn(permission?.mcp_tool_permissions ?? {}, ALL_PROXY_MCP_SERVERS_SENTINEL); - return parts.join(", ") || (hasUnsupportedAll ? "No MCP access" : "No restriction"); + return parts.join(", "); } const RESOURCE_ITEMS = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts index 590f3d2bdda..727d5d352d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts @@ -20,6 +20,11 @@ const permission: NonNullable = { vector_stores: [], }; +const MCP_SCOPES = ["limited", "denied", "unrestricted"] as const; + +const scopesMatching = (row: EndUser): string[] => + MCP_SCOPES.filter((scope) => customerMatchesFilter(row, "mcp_access", scope)); + describe("customer filters", () => { it("searches ID and alias case-insensitively and trims the search", () => { expect(customerMatchesSearch(customer, " CUSTOMER-1 ")).toBe(true); @@ -52,34 +57,34 @@ describe("customer filters", () => { { mcp_tool_permissions: { "server-1": ["read"] } }, ])("detects configured MCP grants %j", (grant) => { const row = { ...customer, object_permission: { ...permission, ...grant } }; - expect(customerMatchesFilter(row, "mcp_access", "yes")).toBe(true); - expect(customerMatchesFilter(row, "mcp_access", "no")).toBe(false); + expect(scopesMatching(row)).toEqual(["limited"]); }); - it("does not mistake absent, empty, or explicitly denied MCP grants for access", () => { - const denied = { ...permission, mcp_servers: [NO_MCP_SERVERS_SENTINEL], mcp_access_groups: ["group-1"] }; - for (const object_permission of [ - undefined, - null, - permission, - denied, - { ...permission, mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL] }, - ]) { - expect(customerMatchesFilter({ ...customer, object_permission }, "mcp_access", "yes")).toBe(false); - expect(customerMatchesFilter({ ...customer, object_permission }, "mcp_access", "no")).toBe(true); - } + it.each([ + { mcp_servers: [NO_MCP_SERVERS_SENTINEL], mcp_access_groups: ["group-1"] }, + { mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL] }, + ])("matches denied MCP grants only as No MCP access %j", (grant) => { + expect(scopesMatching({ ...customer, object_permission: { ...permission, ...grant } })).toEqual(["denied"]); }); + it.each([undefined, null, permission])( + "matches customers without a grant only as No MCP restriction (%j)", + (object_permission) => { + expect(scopesMatching({ ...customer, object_permission })).toEqual(["unrestricted"]); + }, + ); + it("ignores invalid URL filter values and unrelated columns", () => { expect(customerMatchesFilter(customer, "blocked", "unexpected")).toBe(true); expect(customerMatchesFilter(customer, "max_budget", false)).toBe(true); expect(customerMatchesFilter(customer, "mcp_access", "unexpected")).toBe(true); + expect(customerMatchesFilter(customer, "mcp_access", "yes")).toBe(true); expect(customerMatchesFilter(customer, "unrelated", "yes")).toBe(true); }); it("formats filter chips with the labels shown in the drawer", () => { expect(formatCustomerFilterValue("blocked", "active")).toBe("Active"); expect(formatCustomerFilterValue("max_budget", "yes")).toBe("Has budget"); - expect(formatCustomerFilterValue("mcp_access", "no")).toBe("No MCP access"); + expect(formatCustomerFilterValue("mcp_access", "unrestricted")).toBe("No MCP restriction"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts index bce9adf696d..c078eeed42e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts @@ -2,7 +2,7 @@ import type { FilterFn } from "@tanstack/react-table"; import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; -import { customerHasMcpAccess } from "./customerResources"; +import { customerMcpScope } from "./customerResources"; export const CUSTOMER_FILTER_FIELDS = [ { @@ -25,8 +25,9 @@ export const CUSTOMER_FILTER_FIELDS = [ id: "mcp_access", label: "MCP access", options: [ - { value: "yes", label: "Has MCP access" }, - { value: "no", label: "No MCP access" }, + { value: "limited", label: "Limited MCP access" }, + { value: "denied", label: "No MCP access" }, + { value: "unrestricted", label: "No MCP restriction" }, ], }, ] as const; @@ -40,9 +41,12 @@ export const customerMatchesFilter = (customer: EndUser, columnId: string, value if (value === "active") return !customer.blocked; return true; } + if (columnId === "mcp_access") { + if (value !== "limited" && value !== "denied" && value !== "unrestricted") return true; + return customerMcpScope(customer.object_permission) === value; + } if (value !== "yes" && value !== "no") return true; if (columnId === "max_budget") return (customer.litellm_budget_table?.max_budget != null) === (value === "yes"); - if (columnId === "mcp_access") return customerHasMcpAccess(customer) === (value === "yes"); return true; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts index 48c5b7a5b2b..25046fcc0ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants"; -import { customerHasMcpAccess, getCustomerResourceCounts, type CustomerResourceCounts } from "./customerResources"; +import { customerMcpScope, getCustomerResourceCounts, type CustomerResourceCounts } from "./customerResources"; const emptyPermission: NonNullable = { object_permission_id: "permission-id", @@ -55,7 +55,7 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured).servers).toBe(2); - expect(customerHasMcpAccess(configured)).toBe(true); + expect(customerMcpScope(configured.object_permission)).toBe("limited"); }); it("excludes the unsupported all-MCP sentinel from customer server counts", () => { @@ -64,7 +64,7 @@ describe("getCustomerResourceCounts", () => { object_permission: { ...emptyPermission, mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL, "server-a"] }, }; expect(getCustomerResourceCounts(configured).servers).toBe(1); - expect(customerHasMcpAccess(configured)).toBe(true); + expect(customerMcpScope(configured.object_permission)).toBe("limited"); }); it("does not claim MCP access from unsupported all-MCP sentinel-only grants", () => { @@ -77,7 +77,7 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured).servers).toBe(0); - expect(customerHasMcpAccess(configured)).toBe(false); + expect(customerMcpScope(configured.object_permission)).toBe("denied"); }); it("zeroes every MCP grant when no-MCP overrides other grants", () => { @@ -92,7 +92,7 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured)).toEqual(noMcpCounts); - expect(customerHasMcpAccess(configured)).toBe(false); + expect(customerMcpScope(configured.object_permission)).toBe("denied"); }); it("excludes permission-table models and resources not enforced for customers", () => { @@ -110,19 +110,19 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured)).toEqual({ ...noMcpCounts, models: 1 }); - expect(customerHasMcpAccess(configured)).toBe(false); + expect(customerMcpScope(configured.object_permission)).toBe("unrestricted"); }); }); -describe("customerHasMcpAccess", () => { +describe("customerMcpScope", () => { it.each([ undefined, null, emptyPermission, { ...emptyPermission, mcp_servers: null, mcp_access_groups: null, mcp_toolsets: null }, - ])("treats an empty customer-level grant as not configured (%j)", (object_permission) => { + ])("treats an empty customer-level grant as unrestricted (%j)", (object_permission) => { const unconfigured = { ...customer, object_permission }; - expect(customerHasMcpAccess(unconfigured)).toBe(false); + expect(customerMcpScope(object_permission)).toBe("unrestricted"); expect(getCustomerResourceCounts(unconfigured)).toEqual(noMcpCounts); }); @@ -132,6 +132,6 @@ describe("customerHasMcpAccess", () => { { mcp_toolsets: ["toolset-id"] }, { mcp_tool_permissions: { "server-id": ["read"] } }, ])("recognizes a grant from each MCP source (%j)", (grant) => { - expect(customerHasMcpAccess({ ...customer, object_permission: { ...emptyPermission, ...grant } })).toBe(true); + expect(customerMcpScope({ ...emptyPermission, ...grant })).toBe("limited"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts index cc15e5583af..49006149b15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts @@ -30,7 +30,15 @@ export function getCustomerResourceCounts( }; } -export function customerHasMcpAccess(customer: EndUser): boolean { - const counts = getCustomerResourceCounts(customer); - return counts.servers > 0 || counts.accessGroups > 0 || counts.toolsets > 0; +export type CustomerMcpScope = "limited" | "denied" | "unrestricted"; + +export function customerMcpScope(permission: EndUser["object_permission"]): CustomerMcpScope { + const servers = permission?.mcp_servers ?? []; + if (servers.includes(NO_MCP_SERVERS_SENTINEL)) return "denied"; + const counts = getCustomerResourceCounts({ object_permission: permission }); + if (counts.servers > 0 || counts.accessGroups > 0 || counts.toolsets > 0) return "limited"; + const onlyUnsupportedAll = + servers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL) || + Object.hasOwn(permission?.mcp_tool_permissions ?? {}, ALL_PROXY_MCP_SERVERS_SENTINEL); + return onlyUnsupportedAll ? "denied" : "unrestricted"; } diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx index 55dae66a3c9..71f0c248952 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx @@ -258,3 +258,42 @@ describe("MCPServerSelector scoping", () => { expect(optionByLabel("Wiki A")).toBeDefined(); }); }); + +describe("MCPServerSelector name collisions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseMCPServers.mockReturnValue({ + data: [ + { server_id: "shared", server_name: "Shared" }, + { server_id: "srv-x", server_name: "Other" }, + ], + isLoading: false, + } as any); + mockUseMCPAccessGroups.mockReturnValue({ data: ["shared", "alias-a"], isLoading: false } as any); + mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any); + }); + + it.each(["shared", "alias-a"])( + "keeps the stored server %s a server when it matches an access group name", + async (server) => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders( + , + ); + + await openSelector(user); + await user.click(optionByLabel("Other")!); + + expect(onChange).toHaveBeenCalledWith({ servers: [server, "srv-x"], accessGroups: [], toolsets: [] }); + }, + ); + + it("shows a stored server alias by its name", () => { + renderWithProviders( + , + ); + + expect(screen.getByLabelText("alias-a")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx index 89405aaa9c1..073f995f3f5 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -26,6 +26,14 @@ interface MCPServerSelectorProps { } const TOOLSET_PREFIX = "toolset:"; +const ACCESS_GROUP_PREFIX = "access-group:"; +const SERVER_PREFIX = "server:"; +const SENTINELS: readonly string[] = [ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL]; + +const serverValue = (server: string): string => (SENTINELS.includes(server) ? server : `${SERVER_PREFIX}${server}`); + +const valuesWithPrefix = (values: readonly string[], prefix: string): string[] => + values.filter((value) => value.startsWith(prefix)).map((value) => value.slice(prefix.length)); export const serversWithinAccessGroups = ( servers: readonly MCPServer[], @@ -60,19 +68,18 @@ const MCPServerSelector: React.FC = ({ const loading = serversLoading || groupsLoading || toolsetsLoading; - const accessGroupSet = new Set(accessGroups); const visibleServers = serversWithinAccessGroups(mcpServers, value?.accessGroups ?? [], value?.servers ?? []); // Combine options: access groups + servers + toolsets const options = [ ...accessGroups.map((group) => ({ label: group, - value: group, + value: `${ACCESS_GROUP_PREFIX}${group}`, description: "Access Group", })), ...visibleServers.map((server) => ({ label: `${server.server_name || server.server_id} (${server.server_id})`, - value: server.server_id, + value: serverValue(server.server_id), description: "MCP Server", })), ...toolsets.map((toolset) => ({ @@ -82,13 +89,24 @@ const MCPServerSelector: React.FC = ({ })), ]; - // Flatten value for Select — prefix toolset IDs const selectedValues = [ - ...(value?.servers || []), - ...(value?.accessGroups || []), + ...(value?.servers || []).map(serverValue), + ...(value?.accessGroups || []).map((group) => `${ACCESS_GROUP_PREFIX}${group}`), ...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`), ]; + const listedValues = new Set(options.map((option) => option.value)); + const unlistedSelections = [ + ...(value?.servers || []) + .filter((server) => !SENTINELS.includes(server)) + .map((server) => ({ label: server, value: serverValue(server), description: "MCP Server" })), + ...(value?.accessGroups || []).map((group) => ({ + label: group, + value: `${ACCESS_GROUP_PREFIX}${group}`, + description: "Access Group", + })), + ].filter((option) => !listedValues.has(option.value)); + const hasNoMcpServersSelected = allowNoMcpServers && selectedValues.includes(NO_MCP_SERVERS_SENTINEL); const hasAllProxyMcpServersSelected = selectedValues.includes(ALL_PROXY_MCP_SERVERS_SENTINEL); @@ -103,13 +121,11 @@ const MCPServerSelector: React.FC = ({ onChange({ servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [], toolsets: [] }); return; } - const toolsetsSelected = selected - .filter((v) => v.startsWith(TOOLSET_PREFIX)) - .map((v) => v.slice(TOOLSET_PREFIX.length)); - const rest = selected.filter((v) => !v.startsWith(TOOLSET_PREFIX)); - const servers = rest.filter((v) => !accessGroupSet.has(v)); - const accessGroupsSelected = rest.filter((v) => accessGroupSet.has(v)); - onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected }); + onChange({ + servers: [...selected.filter((v) => SENTINELS.includes(v)), ...valuesWithPrefix(selected, SERVER_PREFIX)], + accessGroups: valuesWithPrefix(selected, ACCESS_GROUP_PREFIX), + toolsets: valuesWithPrefix(selected, TOOLSET_PREFIX), + }); }; const selectOptions: MultiSelectOption[] = [ @@ -119,7 +135,7 @@ const MCPServerSelector: React.FC = ({ ...(allowNoMcpServers ? [{ label: "No MCP Servers", value: NO_MCP_SERVERS_SENTINEL, description: "Block all" }] : []), - ...options.map((opt) => ({ + ...[...options, ...unlistedSelections].map((opt) => ({ ...opt, disabled: hasNoMcpServersSelected || hasAllProxyMcpServersSelected, })), diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx index 7ef1c273b53..3a1628ae81b 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx @@ -157,6 +157,24 @@ describe("MCPServerPermissions", () => { expect(screen.queryByText(/tool/)).not.toBeInTheDocument(); }); + it("labels an empty tool allowlist as allowing no tools", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 }, + ]); + + render( + , + ); + + expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument(); + expect(screen.getByText("No tools allowed")).toBeInTheDocument(); + }); + it("should display access groups correctly", async () => { /** * Tests that access groups are displayed with the correct styling diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index 9bf66af0944..24e77108afe 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -165,6 +165,7 @@ export function MCPServerPermissions({ {mergedItems.map((item, index) => { const toolsForServer = item.type === "server" ? getToolPermissionsFor(item.value) : undefined; const hasToolRestrictions = toolsForServer && toolsForServer.length > 0; + const allowsNoTools = toolsForServer?.length === 0; const isExpanded = expandedServers.has(item.value); return ( @@ -197,6 +198,7 @@ export function MCPServerPermissions({ )} + {allowsNoTools && No tools allowed} {hasToolRestrictions && (
{toolsForServer.length}