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 19a29e0579)
This commit is contained in:
ryan-crabbe-berri 2026-06-22 17:08:28 -07:00 committed by Yuneng Jiang
parent 33df5891a9
commit 9b10543427
No known key found for this signature in database
15 changed files with 344 additions and 9 deletions

View file

@ -12,6 +12,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
@ -638,6 +639,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
@ -1054,6 +1064,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.
direct_mcp_servers = global_mcp_server_manager.expand_permission_list(
key_object_permission.mcp_servers or []

View file

@ -80,6 +80,7 @@ from litellm.proxy._types import (
MCPEnvVar,
MCPTransport,
MCPTransportType,
SpecialMCPServerNames,
UserAPIKeyAuth,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
@ -1300,6 +1301,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:

View file

@ -62,7 +62,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,
@ -3300,6 +3300,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)

View file

@ -3458,6 +3458,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

View file

@ -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:
@ -285,6 +286,9 @@ def _rewrite_object_permission_mcp_servers(
normalized_servers: List[str] = []
for identifier in mcp_servers:
if identifier == SpecialMCPServerNames.no_mcp_servers.value:
normalized_servers.append(SpecialMCPServerNames.no_mcp_servers.value)
continue
normalized_servers.extend(sorted(identifier_to_server_ids.get(identifier, [])))
object_permission["mcp_servers"] = _dedupe_preserving_order(normalized_servers)
@ -424,6 +428,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):

View file

@ -15,7 +15,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,
)
@pytest.mark.asyncio
@ -166,6 +170,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"""

View file

@ -2884,6 +2884,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."""

View file

@ -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."""

View file

@ -14,6 +14,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
_extract_requested_mcp_access_groups,
_extract_requested_mcp_server_ids,
_resolve_team_allowed_mcp_servers,
_rewrite_object_permission_mcp_servers,
_set_object_permission,
validate_key_mcp_servers_against_team,
validate_key_search_tools_against_team,
@ -111,6 +112,31 @@ 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"}
def test_rewrite_object_permission_mcp_servers_preserves_sentinel():
obj_perm = {"mcp_servers": ["no-mcp-servers", "alias-1"]}
_rewrite_object_permission_mcp_servers(obj_perm, {"alias-1": {"server-1"}})
assert obj_perm["mcp_servers"] == ["no-mcp-servers", "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 untouched, keeping the
sentinel so it is not mistaken for an unknown server and rejected."""
team_obj = _make_team_obj(mcp_servers=["server-1"])
obj_perm = {"mcp_servers": ["no-mcp-servers"]}
result = await validate_key_mcp_servers_against_team(
object_permission=obj_perm,
team_obj=team_obj,
)
assert result == obj_perm
assert obj_perm["mcp_servers"] == ["no-mcp-servers"]
# ---- Tests for _extract_requested_mcp_access_groups ----

View file

@ -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<typeof import("antd")>();
const Select = ({ value, onChange, children, mode }: any) => (
<select
data-testid="mcp-select"
multiple={mode === "multiple"}
value={value}
onChange={(e) => onChange(Array.from(e.target.selectedOptions, (o) => (o as HTMLOptionElement).value))}
>
{children}
</select>
);
Select.displayName = "MockSelect";
Select.Option = ({ value, disabled, label }: any) => (
<option value={value} disabled={disabled}>
{label}
</option>
);
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(
<MCPServerSelector accessToken="tok" onChange={vi.fn()} value={{ servers: [], accessGroups: [] }} />,
);
expect(optionByValue(NO_MCP_SERVERS_SENTINEL)).toBeUndefined();
});
it("emits an exclusive sentinel when No MCP Servers is selected", async () => {
const onChange = vi.fn();
renderWithProviders(
<MCPServerSelector
accessToken="tok"
allowNoMcpServers
onChange={onChange}
value={{ servers: ["srv-1"], accessGroups: [] }}
/>,
);
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(
<MCPServerSelector
accessToken="tok"
allowNoMcpServers
onChange={vi.fn()}
value={{ servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [] }}
/>,
);
expect(optionByValue("srv-1")?.disabled).toBe(true);
expect(optionByValue(NO_MCP_SERVERS_SENTINEL)?.disabled).toBe(false);
});
});

View file

@ -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<MCPServerSelectorProps> = ({
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<MCPServerSelectorProps> = ({
...(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<MCPServerSelectorProps> = ({
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 && (
<Select.Option key={NO_MCP_SERVERS_SENTINEL} value={NO_MCP_SERVERS_SENTINEL} label="No MCP Servers">
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ flex: 1 }}>No MCP Servers</span>
<span style={{ color: "#8c8c8c", fontSize: "12px", fontWeight: 500, opacity: 0.8 }}>Block all</span>
</div>
</Select.Option>
)}
{options.map((opt) => (
<Select.Option key={opt.value} value={opt.value} label={opt.label}>
<Select.Option key={opt.value} value={opt.value} label={opt.label} disabled={hasNoMcpServersSelected}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{

View file

@ -0,0 +1,2 @@
// Must match the backend SpecialMCPServerNames.no_mcp_servers enum value.
export const NO_MCP_SERVERS_SENTINEL = "no-mcp-servers";

View file

@ -32,6 +32,7 @@ import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/Budg
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { Team } 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 {
@ -1400,6 +1401,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
accessToken={accessToken}
teamId={selectedCreateKeyTeam?.team_id ?? null}
placeholder="Select MCP servers or access groups (optional)"
allowNoMcpServers
/>
</Form.Item>
@ -1419,7 +1421,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
<div className="mt-6">
<MCPToolPermissions
accessToken={accessToken}
selectedServers={form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []}
selectedServers={(
form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []
).filter((s: string) => s !== NO_MCP_SERVERS_SENTINEL)}
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
/>

View file

@ -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[];
@ -94,9 +95,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;
@ -106,12 +111,19 @@ export function MCPServerPermissions({
<div className="flex items-center gap-2">
<ServerIcon className="h-4 w-4 text-blue-600" />
<Text className="font-semibold text-gray-900">MCP Servers</Text>
<Badge color="blue" size="xs">
{totalCount}
<Badge color={blocksAllMcpServers ? "red" : "blue"} size="xs">
{blocksAllMcpServers ? "Blocked" : totalCount}
</Badge>
</div>
{totalCount > 0 ? (
{blocksAllMcpServers ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 border border-red-200">
<ServerIcon className="h-4 w-4 text-red-400" />
<Text className="text-red-700 text-sm">
No MCP servers this key is blocked from all MCP servers, including its team&apos;s servers
</Text>
</div>
) : totalCount > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-1">
{mergedItems.map((item, index) => {
const toolsForServer = item.type === "server" ? mcpToolPermissions[item.value] : undefined;

View file

@ -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
/>
</Form.Item>
@ -637,7 +639,9 @@ export function KeyEditView({
<div className="mb-6">
<MCPToolPermissions
accessToken={accessToken || ""}
selectedServers={form.getFieldValue("mcp_servers_and_groups")?.servers || []}
selectedServers={(form.getFieldValue("mcp_servers_and_groups")?.servers || []).filter(
(s: string) => s !== NO_MCP_SERVERS_SENTINEL,
)}
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
/>