Access group "{group}" has 0 servers
+
+ No MCP server lists this group, so it grants nothing. A server defined in config.yaml joins a group
+ through its access_groups key; mcp_access_groups is ignored there
+
Unable to load toolsets
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts index b2ac5337245..487c6f9f55e 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts +++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { MCPServer, MCPToolset } from "../mcp_tools/types"; import { applyToolPermissionWrite, + emptyMcpAccessGroups, mcpAllowedToolsFor, mcpServersForIdentifier, mcpToolPermissionKeyFor, @@ -101,6 +102,29 @@ describe("mcpToolPermissionKeyFor", () => { }); }); +describe("emptyMcpAccessGroups", () => { + const grouped = server({ server_id: "srv-group", server_name: "Grouped", mcp_access_groups: ["prod"] }); + const objectGrouped = { + ...grouped, + server_id: "srv-obj", + mcp_access_groups: [{ name: "legacy" }], + } as unknown as MCPServer; + + it("names only the selected groups no loaded server belongs to", () => { + expect(emptyMcpAccessGroups([grouped, objectGrouped], [], ["prod", "legacy", "ops_readonly"])).toEqual([ + "ops_readonly", + ]); + }); + + it("names every selected group when no server is loaded and the registry is empty", () => { + expect(emptyMcpAccessGroups([], [], ["prod"])).toEqual(["prod"]); + }); + + it("trusts the group registry when the caller's catalog hides the member servers", () => { + expect(emptyMcpAccessGroups([], ["prod"], ["prod", "ops_readonly"])).toEqual(["ops_readonly"]); + }); +}); + describe("resolveEffectiveMcpServers", () => { const direct = server({ server_id: "srv-direct", server_name: "Direct" }); const grouped = server({ server_id: "srv-group", server_name: "Grouped", mcp_access_groups: ["prod"] }); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts index 3320dc135c9..b3ba24f3c59 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts +++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts @@ -54,6 +54,19 @@ const accessGroupNamesOf = (server: MCPServer): readonly string[] => return [typeof parsed.data === "string" ? parsed.data : parsed.data.name]; }); +// The server catalog can be trimmed to the caller's grants, so the unfiltered group registry +// (GET /v1/mcp/access_groups) has to agree before a group is called empty. +export const emptyMcpAccessGroups = ( + allServers: readonly MCPServer[], + populatedAccessGroups: readonly string[], + selectedAccessGroups: readonly string[], +): readonly string[] => + selectedAccessGroups.filter( + (group) => + !populatedAccessGroups.includes(group) && + !allServers.some((server) => accessGroupNamesOf(server).includes(group)), + ); + // Which servers an identifier names, with the same precedence the backend's expand_permission_list // applies: a string that is a registry server id names exactly that server, and only a string that // is not falls back to server_name/alias, which can name several. Matching all three fields at once