From ade37bc049947485556bef1a2b246a716d874695 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 22 Jun 2026 17:08:28 -0700 Subject: [PATCH] feat(mcp): scope a key to zero MCP servers with no-mcp-servers sentinel (#31029) * feat(mcp): scope a key to zero MCP servers with no-mcp-servers sentinel A key under a team that has MCP servers had no way to opt out of them; an empty list has always meant "inherit the team". This adds a no-mcp-servers sentinel (mirroring no-default-models for models) so a key can declare an explicit zero that overrides team inheritance, additive grants, and allow_all_keys servers, surfaced as an exclusive "No MCP Servers" option in the key create/edit UI. * refactor(ui): centralize no-mcp-servers sentinel in a shared constant The sentinel string was defined under two different local names and inlined in two more files; a single exported constant removes the drift risk flagged in review. * fix(mcp): enforce no-mcp-servers sentinel on toolset-scoped routes Toolset scoping replaced a key's mcp_servers with the toolset's servers, dropping the no-mcp-servers sentinel, so a key opted out of all MCP could still execute a granted toolset's tools via /toolset/{name}/mcp. Deny toolset access when the key carries the sentinel, checked before the admin branch to match get_allowed_mcp_servers. (cherry picked from commit 19a29e05790431968115753478c9ab47ea899656) --- .../mcp_server/auth/user_api_key_auth_mcp.py | 17 +++ .../mcp_server/mcp_server_manager.py | 12 +++ .../proxy/_experimental/mcp_server/server.py | 15 ++- litellm/proxy/_types.py | 4 + .../object_permission_utils.py | 2 + .../auth/test_user_api_key_auth_mcp.py | 53 +++++++++- .../mcp_server/test_mcp_server_manager.py | 35 ++++++ .../mcp_server/test_mcp_toolset_scope.py | 31 ++++++ .../test_object_permission_utils.py | 18 ++++ .../MCPServerSelector.test.tsx | 100 ++++++++++++++++++ .../MCPServerSelector.tsx | 21 +++- .../src/components/mcp_tools/constants.ts | 2 + .../organisms/create_key_button.tsx | 6 +- .../permissions/MCPServerPermissions.tsx | 20 +++- .../components/templates/key_edit_view.tsx | 6 +- 15 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/constants.ts diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 70fc2c233e7..b390169589e 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -11,6 +11,7 @@ from litellm.proxy._types import ( LiteLLM_TeamTable, ProxyException, SpecialHeaders, + SpecialMCPServerNames, UserAPIKeyAuth, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -560,6 +561,15 @@ class MCPRequestHandler: user_api_key_auth ) ) + + # The key explicitly opted out of every MCP server. This overrides + # team inheritance and additive grants (mirrors no-default-models). + if ( + SpecialMCPServerNames.no_mcp_servers.value + in allowed_mcp_servers_for_key + ): + return [] + allowed_mcp_servers_for_team = ( await MCPRequestHandler._get_allowed_mcp_servers_for_team( user_api_key_auth @@ -909,6 +919,13 @@ class MCPRequestHandler: if key_object_permission is None: return [] + # Sentinel opt-out: surface it unexpanded so the caller can short-circuit + # to zero servers instead of inheriting the team. + if SpecialMCPServerNames.no_mcp_servers.value in ( + key_object_permission.mcp_servers or [] + ): + return [SpecialMCPServerNames.no_mcp_servers.value] + # Permission entries may be server_ids OR names/aliases — expand to ids. from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d0e9ad7b2a4..80edb1a1a6f 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -67,6 +67,7 @@ from litellm.proxy._types import ( MCPAuthType, MCPTransport, MCPTransportType, + SpecialMCPServerNames, UserAPIKeyAuth, ) from litellm.proxy.auth.ip_address_utils import IPAddressUtils @@ -1011,6 +1012,17 @@ class MCPServerManager: allow_all_server_ids = self.get_allow_all_keys_server_ids() try: + # The key explicitly opted out of every MCP server. Return zero before + # layering on allow_all_keys servers so the opt-out is absolute. + key_object_permission = ( + user_api_key_auth.object_permission if user_api_key_auth else None + ) + if key_object_permission is not None and ( + SpecialMCPServerNames.no_mcp_servers.value + in (key_object_permission.mcp_servers or []) + ): + return [] + # Check if object_permission.mcp_servers is explicitly set has_explicit_object_permission = False if user_api_key_auth and user_api_key_auth.object_permission: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index f31005be0cb..4060d11aebf 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -56,7 +56,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import SpecialMCPServerNames, UserAPIKeyAuth from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.proxy.litellm_pre_call_utils import ( LiteLLMProxyRequestSetup, @@ -2802,6 +2802,19 @@ if MCP_AVAILABLE: from litellm.proxy._types import LiteLLM_ObjectPermissionTable from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view + # A key scoped to no MCP servers opts out of every MCP path. Enforce it + # here too, since toolset scoping replaces mcp_servers and would otherwise + # drop the sentinel. Checked before the admin branch, mirroring + # get_allowed_mcp_servers. + original_op = user_api_key_auth.object_permission + if original_op is not None and SpecialMCPServerNames.no_mcp_servers.value in ( + original_op.mcp_servers or [] + ): + raise HTTPException( + status_code=403, + detail="API key is scoped to no MCP servers; toolset access is denied.", + ) + # Access control: non-admin keys must have this toolset in their grant list. # Use _user_has_admin_view so that PROXY_ADMIN_VIEW_ONLY is also treated as admin. is_admin = _user_has_admin_view(user_api_key_auth) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 09cf9656b93..44b4fd1a363 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3368,6 +3368,10 @@ class SpecialModelNames(enum.Enum): no_default_models = "no-default-models" +class SpecialMCPServerNames(enum.Enum): + no_mcp_servers = "no-mcp-servers" + + class SpecialProxyStrings(enum.Enum): default_user_id = "default_user_id" # global proxy admin diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index eb90d1b5ca7..089efbacdd1 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -11,6 +11,7 @@ from fastapi import HTTPException, status from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.proxy._types import SpecialMCPServerNames from litellm.proxy.utils import PrismaClient if TYPE_CHECKING: @@ -265,6 +266,7 @@ def _extract_requested_mcp_server_ids( mcp_servers = object_permission.get("mcp_servers") if isinstance(mcp_servers, list): server_ids.update(mcp_servers) + server_ids.discard(SpecialMCPServerNames.no_mcp_servers.value) mcp_tool_permissions = object_permission.get("mcp_tool_permissions") if isinstance(mcp_tool_permissions, dict): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 868d1ec7a09..b129d1a4259 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -18,7 +18,11 @@ from starlette.datastructures import Headers from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, ) -from litellm.proxy._types import SpecialHeaders, UserAPIKeyAuth +from litellm.proxy._types import ( + SpecialHeaders, + SpecialMCPServerNames, + UserAPIKeyAuth, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -170,6 +174,53 @@ class TestMCPRequestHandler: mock_key_servers.assert_called_once_with(user_api_key_auth) mock_team_servers.assert_called_once_with(user_api_key_auth) + @pytest.mark.parametrize("team_servers", [[], ["team_server1", "team_server2"]]) + async def test_no_mcp_servers_sentinel_returns_empty(self, team_servers): + """A key scoped to the no-mcp-servers sentinel resolves to zero servers, + overriding team inheritance and never leaking the sentinel marker.""" + user_api_key_auth = UserAPIKeyAuth( + api_key="test-key", user_id="test-user", team_id="test-team" + ) + key_object_permission = MagicMock() + key_object_permission.mcp_servers = [ + SpecialMCPServerNames.no_mcp_servers.value + ] + + with patch.object( + MCPRequestHandler, + "_get_key_object_permission", + return_value=key_object_permission, + ), patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_team", + new_callable=AsyncMock, + return_value=team_servers, + ): + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) + + assert result == [] + + async def test_get_allowed_mcp_servers_for_key_returns_sentinel_marker(self): + """_get_allowed_mcp_servers_for_key surfaces the sentinel unexpanded so the + caller can short-circuit, ignoring any other entries on the key.""" + user_api_key_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + key_object_permission = MagicMock() + key_object_permission.mcp_servers = [ + SpecialMCPServerNames.no_mcp_servers.value, + "some-other-server", + ] + + with patch.object( + MCPRequestHandler, + "_get_key_object_permission", + return_value=key_object_permission, + ): + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( + user_api_key_auth + ) + + assert result == [SpecialMCPServerNames.no_mcp_servers.value] + async def test_permission_inheritance_edge_cases(self): """Test edge cases in permission inheritance""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index d7078412a44..723a669cd4f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -2712,6 +2712,41 @@ class TestMCPServerManager: assert "test_server_1" in result assert "test_server_2" in result + @pytest.mark.asyncio + async def test_no_mcp_servers_sentinel_blocks_allow_all_keys(self): + """A key scoped to no-mcp-servers gets zero servers even when allow_all_keys + servers exist, and the inner resolver is never consulted.""" + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth + + manager = MCPServerManager() + object_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="perm_no_mcp", + mcp_servers=["no-mcp-servers"], + mcp_access_groups=[], + ) + user_api_key_auth = UserAPIKeyAuth( + api_key="sk-test", + user_id="user-123", + object_permission=object_permission, + object_permission_id="perm_no_mcp", + ) + + with patch.object( + manager, "get_allow_all_keys_server_ids", return_value=["global-server"] + ), patch.object( + MCPRequestHandler, + "get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=["leaked-server"], + ) as mock_inner: + result = await manager.get_allowed_mcp_servers(user_api_key_auth) + + assert result == [] + mock_inner.assert_not_called() + @pytest.mark.asyncio async def test_get_allowed_mcp_servers_anonymous_delegate_requires_oauth2(self): """Anonymous delegated auth listing should only include oauth2 servers.""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py index 2ffa997bdde..b41cdfb1576 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py @@ -93,6 +93,37 @@ class TestApplyToolsetScope: await _apply_toolset_scope(auth, "toolset-123") assert exc_info.value.status_code == 403 + @pytest.mark.asyncio + @pytest.mark.parametrize("user_role", [None, LitellmUserRoles.PROXY_ADMIN.value]) + async def test_no_mcp_servers_sentinel_denies_toolset_access(self, user_role): + """A key scoped to the no-mcp-servers sentinel cannot reach a toolset it + would otherwise be granted (even as admin); the opt-out covers the + toolset path, which replaces mcp_servers and would drop the sentinel.""" + from starlette.exceptions import HTTPException + + from litellm.proxy._experimental.mcp_server.server import _apply_toolset_scope + + op = LiteLLM_ObjectPermissionTable( + object_permission_id="test", + mcp_servers=["no-mcp-servers"], + mcp_toolsets=["toolset-123"], + ) + auth = UserAPIKeyAuth( + api_key="sk-test", object_permission=op, user_role=user_role + ) + + resolve = AsyncMock(return_value={"server-a": ["tool1"]}) + with patch( + "litellm.proxy._experimental.mcp_server.server." + "global_mcp_server_manager.resolve_toolset_tool_permissions", + new=resolve, + ): + with pytest.raises(HTTPException) as exc_info: + await _apply_toolset_scope(auth, "toolset-123") + + assert exc_info.value.status_code == 403 + resolve.assert_not_awaited() + class TestFetchMCPToolsetsAccess: """Tests for GET /v1/mcp/toolset access control.""" diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index b36383dfd97..ac8679bc41b 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -111,6 +111,24 @@ def test_extract_requested_mcp_server_ids_none(): assert _extract_requested_mcp_server_ids({}) == set() +def test_extract_requested_mcp_server_ids_excludes_no_mcp_servers_sentinel(): + obj_perm = {"mcp_servers": ["no-mcp-servers", "server-1"]} + assert _extract_requested_mcp_server_ids(obj_perm) == {"server-1"} + + +@pytest.mark.asyncio +async def test_validate_no_mcp_servers_sentinel_passes_and_preserved(): + """A key scoped to no-mcp-servers passes team validation without being rejected + as an unknown server, and the sentinel is preserved on the key.""" + team_obj = _make_team_obj(mcp_servers=["server-1"]) + obj_perm = {"mcp_servers": ["no-mcp-servers"]} + await validate_key_mcp_servers_against_team( + object_permission=obj_perm, + team_obj=team_obj, + ) + assert obj_perm["mcp_servers"] == ["no-mcp-servers"] + + # ---- Tests for _extract_requested_mcp_access_groups ---- 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 new file mode 100644 index 00000000000..1517b2dfa27 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx @@ -0,0 +1,100 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import MCPServerSelector from "./MCPServerSelector"; +import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; + +vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({ + useMCPServers: vi.fn(), +})); +vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups", () => ({ + useMCPAccessGroups: vi.fn(), +})); +vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({ + useMCPToolsets: vi.fn(), +})); + +vi.mock("antd", async (importOriginal) => { + const actual = await importOriginal(); + const Select = ({ value, onChange, children, mode }: any) => ( + + ); + Select.displayName = "MockSelect"; + Select.Option = ({ value, disabled, label }: any) => ( + + ); + Select.Option.displayName = "MockSelectOption"; + return { ...actual, Select }; +}); + +import { useMCPAccessGroups } from "@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; + +const mockUseMCPServers = vi.mocked(useMCPServers); +const mockUseMCPAccessGroups = vi.mocked(useMCPAccessGroups); +const mockUseMCPToolsets = vi.mocked(useMCPToolsets); + +describe("MCPServerSelector no-mcp-servers option", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseMCPServers.mockReturnValue({ + data: [{ server_id: "srv-1", server_name: "Server One" }], + isLoading: false, + } as any); + mockUseMCPAccessGroups.mockReturnValue({ data: [], isLoading: false } as any); + mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any); + }); + + const optionByValue = (value: string) => + Array.from(screen.getByTestId("mcp-select").querySelectorAll("option")).find( + (o) => (o as HTMLOptionElement).value === value, + ) as HTMLOptionElement | undefined; + + it("hides the No MCP Servers option by default", () => { + renderWithProviders( + , + ); + expect(optionByValue(NO_MCP_SERVERS_SENTINEL)).toBeUndefined(); + }); + + it("emits an exclusive sentinel when No MCP Servers is selected", async () => { + const onChange = vi.fn(); + renderWithProviders( + , + ); + expect(optionByValue(NO_MCP_SERVERS_SENTINEL)).toBeDefined(); + + await userEvent.selectOptions(screen.getByTestId("mcp-select"), [NO_MCP_SERVERS_SENTINEL]); + + expect(onChange).toHaveBeenCalledWith({ servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [], toolsets: [] }); + }); + + it("disables real server options while the sentinel is selected", () => { + renderWithProviders( + , + ); + expect(optionByValue("srv-1")?.disabled).toBe(true); + expect(optionByValue(NO_MCP_SERVERS_SENTINEL)?.disabled).toBe(false); + }); +}); 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 fc4b20517cf..bbda761938e 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -3,6 +3,7 @@ import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers" import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import { Select } from "antd"; import React from "react"; +import { NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants"; interface MCPServerSelectorProps { onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void; @@ -16,6 +17,7 @@ interface MCPServerSelectorProps { placeholder?: string; disabled?: boolean; teamId?: string | null; + allowNoMcpServers?: boolean; } const TOOLSET_PREFIX = "toolset:"; @@ -28,6 +30,7 @@ const MCPServerSelector: React.FC = ({ placeholder = "Select MCP servers", disabled = false, teamId, + allowNoMcpServers = false, }) => { const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId); const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups(); @@ -77,8 +80,15 @@ const MCPServerSelector: React.FC = ({ ...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`), ]; + const hasNoMcpServersSelected = allowNoMcpServers && selectedValues.includes(NO_MCP_SERVERS_SENTINEL); + // Handle selection const handleChange = (selected: string[]) => { + // "No MCP Servers" is exclusive: picking it clears everything else. + if (allowNoMcpServers && selected.includes(NO_MCP_SERVERS_SENTINEL)) { + 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)); @@ -102,12 +112,21 @@ const MCPServerSelector: React.FC = ({ style={{ width: "100%" }} disabled={disabled} filterOption={(input, option) => { + if (option?.value === NO_MCP_SERVERS_SENTINEL) return true; const searchText = options.find((opt) => opt.value === option?.value)?.searchText || ""; return searchText.toLowerCase().includes(input.toLowerCase()); }} > + {allowNoMcpServers && ( + +
+ No MCP Servers + Block all +
+
+ )} {options.map((opt) => ( - +
= ({ team, teams, data, addKey, autoOp accessToken={accessToken} teamId={selectedCreateKeyTeam?.team_id ?? null} placeholder="Select MCP servers or access groups (optional)" + allowNoMcpServers /> @@ -1417,7 +1419,9 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
s !== NO_MCP_SERVERS_SENTINEL)} toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}} onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })} /> diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index 649e8efdf4a..faa906bc6e1 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -4,6 +4,7 @@ import { ServerIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/ import { Tooltip } from "antd"; import { fetchMCPServers, fetchMCPToolsets } from "../networking"; import { MCPServer, MCPToolset } from "../mcp_tools/types"; +import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; interface MCPServerPermissionsProps { mcpServers: string[]; @@ -96,9 +97,13 @@ export function MCPServerPermissions({ return serverId; }; + const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL); + // Merge servers and access groups into one list const mergedItems = [ - ...mcpServers.map((server) => ({ type: "server", value: server })), + ...mcpServers + .filter((server) => server !== NO_MCP_SERVERS_SENTINEL) + .map((server) => ({ type: "server", value: server })), ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })), ]; const totalCount = mergedItems.length + mcpToolsets.length; @@ -108,12 +113,19 @@ export function MCPServerPermissions({
MCP Servers - - {totalCount} + + {blocksAllMcpServers ? "Blocked" : totalCount}
- {totalCount > 0 ? ( + {blocksAllMcpServers ? ( +
+ + + No MCP servers — this key is blocked from all MCP servers, including its team's servers + +
+ ) : totalCount > 0 ? (
{mergedItems.map((item, index) => { const toolsForServer = item.type === "server" ? mcpToolPermissions[item.value] : undefined; 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 d5e410029a7..4823a1bc755 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -19,6 +19,7 @@ import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor"; 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 NotificationsManager from "../molecules/notifications_manager"; import { getPromptsList, modelAvailableCall, tagListCall } from "../networking"; @@ -618,6 +619,7 @@ export function KeyEditView({ value={form.getFieldValue("mcp_servers_and_groups")} accessToken={accessToken || ""} placeholder="Select MCP servers or access groups (optional)" + allowNoMcpServers /> @@ -637,7 +639,9 @@ export function KeyEditView({
s !== NO_MCP_SERVERS_SENTINEL, + )} toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}} onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })} />