@@ -127,7 +171,9 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
default: vi.fn(({ initialModelAliases, onAliasUpdate }) => (
{JSON.stringify(initialModelAliases)}
-
onAliasUpdate({ "gpt-4o": "gpt-4" })}>Set Alias
+
onAliasUpdate({ "gpt-4o": "gpt-4" })}>
+ Set Alias
+
onAliasUpdate({})}>
Clear Aliases
@@ -138,14 +184,22 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: vi.fn().mockReturnValue({
data: [
- { access_group_id: "ag-1", access_group_name: "Group 1" },
- { access_group_id: "ag-2", access_group_name: "Group 2" },
+ { access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: [] },
+ { access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
],
isLoading: false,
isError: false,
}),
}));
+vi.mock("@/components/common_components/AccessGroupSelector", () => ({
+ default: ({ value, onChange }: { value?: string[]; onChange?: (next: string[]) => void }) => (
+
onChange?.((value ?? []).slice(1))}>
+ remove first unified access group
+
+ ),
+}));
+
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: vi.fn().mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
@@ -170,12 +224,18 @@ import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
+import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
+import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
+import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseKeys = vi.mocked(useKeys);
const mockUseTeam = vi.mocked(useTeam);
const mockUseOrganization = vi.mocked(useOrganization);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
+const mockUseMCPServers = vi.mocked(useMCPServers);
+const mockUseMCPToolsets = vi.mocked(useMCPToolsets);
+const mockUseAccessGroups = vi.mocked(useAccessGroups);
const createMockTeamData = (overrides = {}) => ({
team_id: "123",
@@ -234,6 +294,16 @@ const seedDefaultMocks = () => {
data: { models: [] },
isLoading: false,
} as any);
+ mockUseMCPServers.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
+ mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
+ mockUseAccessGroups.mockReturnValue({
+ data: [
+ { access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: [] },
+ { access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
+ ],
+ isLoading: false,
+ isError: false,
+ } as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
@@ -2193,3 +2263,442 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
});
+
+describe("TeamInfo MCP permission retention", () => {
+ beforeEach(seedDefaultMocks);
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const server = (serverId: string, serverName: string, alias?: string): MCPServer =>
+ ({
+ server_id: serverId,
+ server_name: serverName,
+ alias,
+ mcp_access_groups: [],
+ created_at: "",
+ created_by: "",
+ updated_at: "",
+ updated_by: "",
+ }) as MCPServer;
+
+ const effective = (serverId: string, kind: EffectiveMcpServer["source"]["kind"]): EffectiveMcpServer =>
+ ({
+ server: server(serverId, serverId),
+ permissionKey: serverId,
+ supersededKeys: [],
+ ambiguousKeys: [],
+ keyedTools: undefined,
+ toolsetTools: undefined,
+ allowedTools: undefined,
+ source: kind === "accessGroup" ? { kind, name: "ops_readonly" } : { kind },
+ }) as EffectiveMcpServer;
+
+ const UNIFIED_SERVER = server("unified-server", "wiki");
+ const UNIFIED_GROUPS = [
+ { access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: ["unified-server"] },
+ { access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
+ ];
+
+ const unifiedTeam = (toolPermissions: Record
, serverIds: string[] = []) => {
+ const teamData = {
+ models: ["gpt-4"],
+ access_group_ids: ["ag-1", "ag-2"],
+ access_group_mcp_server_ids: serverIds,
+ object_permission: {
+ mcp_servers: [],
+ mcp_access_groups: [],
+ mcp_toolsets: [],
+ mcp_tool_permissions: toolPermissions,
+ },
+ };
+ return createMockTeamData(teamData);
+ };
+
+ const renderMcpEditor = async (
+ user: ReturnType,
+ {
+ initialTeam = unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
+ freshTeam = initialTeam,
+ accessGroups = [],
+ }: {
+ initialTeam?: ReturnType;
+ freshTeam?: ReturnType;
+ accessGroups?: typeof UNIFIED_GROUPS;
+ } = {},
+ ) => {
+ mockUseMCPServers.mockReturnValue({ data: [UNIFIED_SERVER], isLoading: false, isError: false } as any);
+ mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
+ mockUseAccessGroups.mockReturnValue({ data: accessGroups, isLoading: false, isError: false } as any);
+ vi.mocked(networking.teamInfoCall).mockResolvedValueOnce(initialTeam).mockResolvedValue(freshTeam);
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await screen.findByLabelText("Team Name");
+ };
+
+ const saveMcpEditor = async (user: ReturnType) => {
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ const calls = vi.mocked(networking.teamUpdateCall).mock.calls;
+ const [, payload] = calls[calls.length - 1];
+ return payload.object_permission.mcp_tool_permissions;
+ };
+
+ const refuseMcpSave = async (user: ReturnType, reason: RegExp) => {
+ const errorToast = vi.spyOn(toast, "fromError").mockImplementation(() => {});
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+ await waitFor(() => expect(errorToast).toHaveBeenCalledWith(expect.stringMatching(reason)));
+ expect(networking.teamUpdateCall).not.toHaveBeenCalled();
+ errorToast.mockRestore();
+ };
+
+ const resolveGrants = (overrides: Partial) => {
+ const input: McpGrantInput = {
+ effectiveServers: [effective("server-1", "direct")],
+ selectedAccessGroupIds: ["ag-1"],
+ accessGroups: [],
+ standingServerIds: new Set(),
+ loadTeamGroups: vi.fn(),
+ ...overrides,
+ };
+ return grantedMcpServerIds(input);
+ };
+
+ it("retains permissions for directly and indirectly granted servers", async () => {
+ const resolution = await resolveGrants({
+ effectiveServers: [effective("direct", "direct"), effective("inherited", "toolPermission")],
+ accessGroups: [{ access_group_id: "ag-1", access_mcp_server_ids: ["inherited"] }],
+ });
+ expect(resolution.kind).toBe("resolved");
+ if (resolution.kind !== "resolved") return;
+
+ expect(
+ retainedMcpToolPermissions(
+ { direct: ["create_issue"], inherited: ["list_issues"], removed: ["delete_repo"] },
+ resolution.serverIds,
+ [server("direct", "deploy_tracker"), server("inherited", "issue_tracker"), server("removed", "old_tracker")],
+ ),
+ ).toEqual({
+ direct: ["create_issue"],
+ inherited: ["list_issues"],
+ });
+ });
+
+ it("matches name and alias permission keys to granted server ids", () => {
+ const catalog = [server("server-1", "issue_tracker", "issues")];
+
+ expect(
+ retainedMcpToolPermissions(
+ { issue_tracker: ["list_issues"], issues: ["create_issue"] },
+ new Set(["server-1"]),
+ catalog,
+ ),
+ ).toEqual({
+ issue_tracker: ["list_issues"],
+ issues: ["create_issue"],
+ });
+ });
+
+ it("does not reload the team when the access group list covers the selection", async () => {
+ const loadTeamGroups = vi.fn();
+ await expect(
+ resolveGrants({
+ accessGroups: [{ access_group_id: "ag-1", access_mcp_server_ids: ["group-server"] }],
+ loadTeamGroups,
+ }),
+ ).resolves.toEqual({
+ kind: "resolved",
+ serverIds: new Set(["server-1", "group-server"]),
+ });
+ expect(loadTeamGroups).not.toHaveBeenCalled();
+ });
+
+ it("falls back to the team's loaded access group servers when the list is unavailable and the selection is unchanged", async () => {
+ expect(
+ await resolveGrants({
+ loadTeamGroups: vi.fn().mockResolvedValue({ ids: ["ag-1"], serverIds: ["group-server"] }),
+ }),
+ ).toEqual({
+ kind: "resolved",
+ serverIds: new Set(["server-1", "group-server"]),
+ });
+ });
+
+ it("adds standing tool-permission grants to the reloaded team grants", async () => {
+ expect(
+ await resolveGrants({
+ effectiveServers: [effective("server-1", "direct"), effective("standing", "toolPermission")],
+ standingServerIds: new Set(["standing"]),
+ loadTeamGroups: vi.fn().mockResolvedValue({ ids: ["ag-1"], serverIds: ["group-server"] }),
+ }),
+ ).toEqual({
+ kind: "resolved",
+ serverIds: new Set(["server-1", "group-server", "standing"]),
+ });
+ });
+
+ it("is unresolvable when the list is unavailable and the selection changed", async () => {
+ expect(
+ await resolveGrants({
+ loadTeamGroups: vi.fn().mockResolvedValue({ ids: ["ag-1", "ag-2"], serverIds: ["group-server"] }),
+ }),
+ ).toEqual({
+ kind: "unresolvable",
+ reason: expect.stringMatching(/access groups could not be loaded/),
+ });
+ });
+
+ it("is unresolvable when the team reload fails", async () => {
+ expect(await resolveGrants({ loadTeamGroups: vi.fn().mockRejectedValue(new Error("boom")) })).toEqual({
+ kind: "unresolvable",
+ reason: expect.stringMatching(/access groups could not be reloaded/),
+ });
+ });
+
+ it("refuses an unresolved selected access group", async () => {
+ expect(
+ await resolveGrants({
+ selectedAccessGroupIds: ["missing"],
+ loadTeamGroups: vi.fn().mockResolvedValue({ ids: [], serverIds: [] }),
+ }),
+ ).toEqual({
+ kind: "unresolvable",
+ reason: expect.stringMatching(/access groups could not be loaded/),
+ });
+ });
+
+ it("retains an unknown permission key so an inventory refresh cannot erase it", () => {
+ expect(retainedMcpToolPermissions({ "not-yet-loaded": ["read"] }, new Set(), [])).toEqual({
+ "not-yet-loaded": ["read"],
+ });
+ });
+
+ it("splits an ambiguous permission across the matching granted server", () => {
+ const catalog = [server("server-1", "shared"), server("server-2", "shared")];
+
+ expect(
+ retainedMcpToolPermissions({ shared: ["read"], "server-1": ["write"] }, new Set(["server-1"]), catalog),
+ ).toEqual({
+ "server-1": ["write", "read"],
+ });
+ });
+
+ it("keeps an ambiguous permission when all matching servers remain granted", () => {
+ const catalog = [server("server-1", "shared"), server("server-2", "shared")];
+
+ expect(retainedMcpToolPermissions({ shared: ["read"] }, new Set(["server-1", "server-2"]), catalog)).toEqual({
+ shared: ["read"],
+ });
+ });
+
+ it("keeps the group server allowlist when the access group list is unavailable and the selection is unchanged", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user);
+
+ await user.clear(screen.getByLabelText("Team Name"));
+ await user.type(screen.getByLabelText("Team Name"), "Renamed Team");
+
+ expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
+ });
+
+ it("refuses a save when the access group list is unavailable and the selection changed", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user);
+
+ await user.click(screen.getByRole("button", { name: "remove first unified access group" }));
+ await refuseMcpSave(user, /access groups could not be loaded/);
+ });
+
+ it("keeps the group server allowlist when a selected access group is missing from the list", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user, {
+ accessGroups: [UNIFIED_GROUPS[1]],
+ initialTeam: unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
+ });
+
+ expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
+ });
+
+ it("uses current group grants when a server was revoked after the page loaded", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user, {
+ initialTeam: unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
+ freshTeam: unifiedTeam({ wiki: ["read_page"] }),
+ });
+
+ expect(await saveMcpEditor(user)).toEqual({});
+ });
+
+ it("uses current group grants when a server was granted after the page loaded", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user, {
+ initialTeam: unifiedTeam({ wiki: ["read_page"] }),
+ freshTeam: unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
+ });
+
+ expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
+ });
+
+ it("keeps a standing allowlist that no group grant covers at load or at save", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user, {
+ initialTeam: unifiedTeam({ wiki: ["read_page"] }),
+ freshTeam: unifiedTeam({ wiki: ["read_page"] }),
+ });
+
+ expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
+ });
+
+ it("refuses a save when the access group list is unavailable and the team reload fails", async () => {
+ const user = userEvent.setup({ delay: null });
+ await renderMcpEditor(user);
+ vi.mocked(networking.teamInfoCall).mockRejectedValueOnce(new Error("boom"));
+
+ await refuseMcpSave(user, /access groups could not be reloaded/);
+ });
+
+ it("identifies standing tool-permission grants not covered by loaded access groups", () => {
+ expect(
+ standingToolPermissionServerIds(
+ [effective("a", "toolPermission"), effective("b", "toolPermission"), effective("c", "direct")],
+ ["ag-1"],
+ [{ access_group_id: "ag-1", access_mcp_server_ids: ["b"] }],
+ [],
+ ),
+ ).toEqual(new Set(["a"]));
+ });
+
+ it("does not treat a server granted by the team's loaded access groups as standing", () => {
+ expect(
+ standingToolPermissionServerIds(
+ [effective("a", "toolPermission"), effective("b", "toolPermission")],
+ ["ag-1"],
+ [],
+ ["b"],
+ ),
+ ).toEqual(new Set(["a"]));
+ });
+
+ it("includes standing tool-permission grants in the resolved server ids", async () => {
+ const standingOnly = { effectiveServers: [effective("x", "toolPermission")], selectedAccessGroupIds: [] };
+ expect(await resolveGrants({ ...standingOnly, standingServerIds: new Set(["x"]) })).toEqual({
+ kind: "resolved",
+ serverIds: new Set(["x"]),
+ });
+ expect(await resolveGrants(standingOnly)).toEqual({
+ kind: "resolved",
+ serverIds: new Set(),
+ });
+ });
+
+ it("retains an indirectly granted server on an unrelated team save", async () => {
+ const user = userEvent.setup({ delay: null });
+ const catalog = [server("direct-server", "deploy_tracker"), server("perm-only-server", "issue_tracker")];
+ mockUseMCPServers.mockReturnValue({ data: catalog, isLoading: false, isError: false } as any);
+ mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
+ mockUseAccessGroups.mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ } as any);
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ models: ["gpt-4"],
+ access_group_ids: [],
+ object_permission: {
+ mcp_servers: ["direct-server"],
+ mcp_access_groups: [],
+ mcp_toolsets: [],
+ mcp_tool_permissions: {
+ "direct-server": ["create_issue"],
+ "perm-only-server": ["list_issues"],
+ },
+ },
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+
+ renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await user.clear(screen.getByLabelText("Team Name"));
+ await user.type(screen.getByLabelText("Team Name"), "Renamed Team");
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
+ const [, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
+ expect(payload.object_permission.mcp_tool_permissions).toEqual({
+ "direct-server": ["create_issue"],
+ "perm-only-server": ["list_issues"],
+ });
+ });
+
+ it("refuses a save with MCP permissions while the server inventory is unavailable", async () => {
+ const user = userEvent.setup({ delay: null });
+ mockUseMCPServers.mockReturnValue({ data: [], isLoading: false, isError: true } as any);
+ vi.mocked(networking.teamInfoCall).mockResolvedValue(
+ createMockTeamData({
+ models: ["gpt-4"],
+ object_permission: {
+ mcp_servers: ["direct-server"],
+ mcp_access_groups: [],
+ mcp_toolsets: [],
+ mcp_tool_permissions: { "direct-server": ["create_issue"] },
+ },
+ }),
+ );
+ vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
+ const errorToast = vi.spyOn(toast, "fromError").mockImplementation(() => {});
+
+ renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ await user.click(await screen.findByRole("button", { name: /edit settings/i }));
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() =>
+ expect(errorToast).toHaveBeenCalledWith(expect.stringMatching(/server list could not be loaded/)),
+ );
+ expect(networking.teamUpdateCall).not.toHaveBeenCalled();
+ errorToast.mockRestore();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 23ecd19fdd7..efd69a79fc0 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -74,6 +74,15 @@ import GuardrailSettingsView from "../GuardrailSettingsView";
import LoggingSettingsView from "../logging_settings_view";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
+import {
+ mcpServersForIdentifier,
+ resolveEffectiveMcpServers,
+ type EffectiveMcpServer,
+} from "../mcp_server_management/effectiveMcpServers";
+import type { MCPServer } from "../mcp_tools/types";
+import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
+import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
+import { useAccessGroups, type AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { ModelSelect } from "../ModelSelect/ModelSelect";
import { estimateChecks, estimateTooltips } from "../templates/estimatedOutputTokens";
import ObjectPermissionsView from "../object_permissions_view";
@@ -118,6 +127,110 @@ const TEAM_MODEL_BADGE_TONES: Record = {
const teamModelBadgeHref = (badge: TeamModelBadge): string | undefined =>
badge.kind === "direct" || badge.kind === "access-group" ? modelGroupHref(badge.label) : undefined;
+export type McpGrantResolution =
+ | { readonly kind: "resolved"; readonly serverIds: ReadonlySet }
+ | { readonly kind: "unresolvable"; readonly reason: string };
+
+export type TeamAccessGroupGrants = {
+ readonly ids: readonly string[];
+ readonly serverIds: readonly string[];
+};
+
+const sameIdSelection = (a: readonly string[], b: readonly string[]): boolean => {
+ const aSet = new Set(a);
+ const bSet = new Set(b);
+ return aSet.size === bSet.size && [...aSet].every((id) => bSet.has(id));
+};
+
+export const standingToolPermissionServerIds = (
+ loadedEffectiveServers: readonly EffectiveMcpServer[],
+ loadedAccessGroupIds: readonly string[],
+ accessGroups: readonly Pick[],
+ loadedTeamGroupServerIds: readonly string[],
+): ReadonlySet => {
+ const loadedUnifiedServerIds = new Set([
+ ...accessGroups
+ .filter((group) => loadedAccessGroupIds.includes(group.access_group_id))
+ .flatMap((group) => group.access_mcp_server_ids),
+ ...loadedTeamGroupServerIds,
+ ]);
+ return new Set(
+ loadedEffectiveServers
+ .filter(({ source, server }) => source.kind === "toolPermission" && !loadedUnifiedServerIds.has(server.server_id))
+ .map(({ server }) => server.server_id),
+ );
+};
+
+export type McpGrantInput = {
+ readonly effectiveServers: readonly EffectiveMcpServer[];
+ readonly selectedAccessGroupIds: readonly string[];
+ readonly accessGroups: readonly Pick[];
+ readonly standingServerIds: ReadonlySet;
+ readonly loadTeamGroups: () => Promise;
+};
+
+export const grantedMcpServerIds = async ({
+ effectiveServers,
+ selectedAccessGroupIds,
+ accessGroups,
+ standingServerIds,
+ loadTeamGroups,
+}: McpGrantInput): Promise => {
+ const selectedGroups = accessGroups.filter((group) => selectedAccessGroupIds.includes(group.access_group_id));
+ const direct = effectiveServers
+ .filter(({ source }) => source.kind !== "toolPermission")
+ .map(({ server }) => server.server_id);
+ if (selectedAccessGroupIds.every((id) => selectedGroups.some((group) => group.access_group_id === id))) {
+ return {
+ kind: "resolved",
+ serverIds: new Set([
+ ...direct,
+ ...selectedGroups.flatMap((group) => group.access_mcp_server_ids),
+ ...standingServerIds,
+ ]),
+ };
+ }
+ const loadedTeamGroups = await loadTeamGroups().catch(() => null);
+ if (loadedTeamGroups === null) {
+ return { kind: "unresolvable", reason: "the team's access groups could not be reloaded" };
+ }
+ if (sameIdSelection(selectedAccessGroupIds, loadedTeamGroups.ids)) {
+ return {
+ kind: "resolved",
+ serverIds: new Set([...direct, ...loadedTeamGroups.serverIds, ...standingServerIds]),
+ };
+ }
+ return { kind: "unresolvable", reason: "the team's access groups could not be loaded" };
+};
+
+export const retainedMcpToolPermissions = (
+ toolPermissions: Record,
+ grantedServerIds: ReadonlySet,
+ knownServers: readonly MCPServer[],
+): Record => {
+ const entries = Object.entries(toolPermissions).flatMap(([key, tools]) => {
+ const named = mcpServersForIdentifier(knownServers, key);
+ const granted = named.filter((server) => grantedServerIds.has(server.server_id));
+ if (named.length === 0 || granted.length === named.length) {
+ return [[key, tools] as const];
+ }
+ if (granted.length === 0) {
+ return [];
+ }
+ return granted.map(({ server_id }) => [server_id, [...(toolPermissions[server_id] ?? []), ...tools]] as const);
+ });
+ return entries.reduce>(
+ (retained, [key, tools]) => ({
+ ...retained,
+ [key]: [...new Set([...(retained[key] ?? []), ...tools])],
+ }),
+ {},
+ );
+};
+
+export const mcpUnresolvableSaveError = (reason: string): string =>
+ `Cannot save MCP tool permissions because ${reason}. Retry once the page has finished loading`;
+
export interface TeamMembership {
user_id: string;
team_id: string;
@@ -442,6 +555,9 @@ const TeamInfoView: React.FC = ({
const routerSettingsRef = React.useRef(null);
const [organization, setOrganization] = useState(null);
const { userRole, userId } = useAuthorized();
+ const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
+ const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
+ const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
const canEditTeamEstimates = isProxyAdminRole(userRole);
const teamEstimateTooltip = estimateTooltips(canEditTeamEstimates, "team");
const { data: userOrganizations = [] } = useOrganizations();
@@ -462,6 +578,15 @@ const TeamInfoView: React.FC = ({
const killSwitchOn = form.watch("disable_global_guardrails");
const watchedMcpSelection = form.watch("mcp_servers_and_groups");
const watchedToolPermissions = form.watch("mcp_tool_permissions");
+ const mcpLookupFailure =
+ (
+ [
+ [mcpServersFailed, "the MCP server list could not be loaded"],
+ [mcpToolsetsFailed, "the MCP toolset list could not be loaded"],
+ [accessGroupsFailed, "the access group list could not be loaded"],
+ [mcpServersLoading || mcpToolsetsLoading || accessGroupsLoading, "the MCP server inventory is still loading"],
+ ] as const
+ ).find(([failed]) => failed)?.[1] ?? null;
const availableRateLimitModels = useMemo(() => {
const selected = watchedModels ?? teamData?.team_info?.models ?? [];
if (selected.includes("all-proxy-models") || selected.includes("all-team-models")) {
@@ -837,10 +962,56 @@ const TeamInfoView: React.FC = ({
accessGroups: [],
toolsets: [],
};
- const serverIds = new Set(servers || []);
- const mcpToolPermissions = Object.fromEntries(
- Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)),
+ const submittedToolPermissions: Record = values.mcp_tool_permissions || {};
+ const effectiveMcpInput = {
+ allServers: allMcpServers,
+ selectedServers: servers || [],
+ selectedAccessGroups: accessGroups || [],
+ selectedToolsets: toolsets || [],
+ toolsets: allMcpToolsets,
+ toolPermissions: submittedToolPermissions,
+ };
+ const loadedObjectPermission = info.object_permission ?? {};
+ const loadedMcpInput = {
+ allServers: allMcpServers,
+ selectedServers: loadedObjectPermission.mcp_servers ?? [],
+ selectedAccessGroups: loadedObjectPermission.mcp_access_groups ?? [],
+ selectedToolsets: loadedObjectPermission.mcp_toolsets ?? [],
+ toolsets: allMcpToolsets,
+ toolPermissions: loadedObjectPermission.mcp_tool_permissions ?? {},
+ };
+ const loadedEffectiveMcpServers = resolveEffectiveMcpServers(loadedMcpInput);
+ const standingServerIds = standingToolPermissionServerIds(
+ loadedEffectiveMcpServers,
+ info.access_group_ids ?? [],
+ allAccessGroups,
+ info.access_group_mcp_server_ids ?? [],
);
+ const mcpGrantInput: McpGrantInput = {
+ effectiveServers: resolveEffectiveMcpServers(effectiveMcpInput),
+ selectedAccessGroupIds: values.access_group_ids || [],
+ accessGroups: allAccessGroups,
+ standingServerIds,
+ loadTeamGroups: async () => {
+ const teamInfo = await teamInfoCall(accessToken, teamId);
+ return {
+ ids: teamInfo.team_info.access_group_ids ?? [],
+ serverIds: teamInfo.team_info.access_group_mcp_server_ids ?? [],
+ };
+ },
+ };
+ const mcpResolution: McpGrantResolution =
+ mcpLookupFailure !== null
+ ? { kind: "unresolvable", reason: mcpLookupFailure }
+ : await grantedMcpServerIds(mcpGrantInput);
+ if (mcpResolution.kind === "unresolvable" && Object.keys(submittedToolPermissions).length > 0) {
+ toast.fromError(mcpUnresolvableSaveError(mcpResolution.reason));
+ return;
+ }
+ const mcpToolPermissions =
+ mcpResolution.kind === "resolved"
+ ? retainedMcpToolPermissions(submittedToolPermissions, mcpResolution.serverIds, allMcpServers)
+ : submittedToolPermissions;
updateData.object_permission = {};
if (servers) {
@@ -1629,6 +1800,8 @@ const TeamInfoView: React.FC = ({
form.setValue("mcp_tool_permissions", toolPerms)}
/>
diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
index 9edfba4b981..0f955fee895 100644
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
@@ -54,7 +54,6 @@ import {
import { excludeProxyWideSentinel, hasAllModelsSentinel } from "../key_team_helpers/fetch_available_models_team_key";
import { KeyResponse } from "../key_team_helpers/key_list";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
-import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import { toast } from "@/lib/toast";
import { getPromptsList, modelAvailableCall, tagListCall } from "../networking";
@@ -138,7 +137,9 @@ export function KeyEditView({
const selectedModels = (form.watch("models") as string[] | undefined) ?? [];
const allowedRoutes = parseAllowedRoutes(allowedRoutesValue);
const isModelsDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes");
- const mcpServersAndGroups = form.watch("mcp_servers_and_groups");
+ const mcpSelection = form.watch("mcp_servers_and_groups") as
+ | { servers?: string[]; accessGroups?: string[]; toolsets?: string[] }
+ | undefined;
const mcpToolPermissions = form.watch("mcp_tool_permissions");
useEffect(() => {
@@ -751,9 +752,9 @@ export function KeyEditView({
s !== NO_MCP_SERVERS_SENTINEL,
- )}
+ selectedServers={mcpSelection?.servers || []}
+ selectedAccessGroups={mcpSelection?.accessGroups || []}
+ selectedToolsets={mcpSelection?.toolsets || []}
toolPermissions={(mcpToolPermissions as Record | undefined) || {}}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
/>