mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
faa2f13a05
commit
ade37bc049
15 changed files with 333 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
// Must match the backend SpecialMCPServerNames.no_mcp_servers enum value.
|
||||
export const NO_MCP_SERVERS_SENTINEL = "no-mcp-servers";
|
||||
|
|
@ -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 {
|
||||
|
|
@ -1398,6 +1399,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>
|
||||
|
||||
|
|
@ -1417,7 +1419,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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<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'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;
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue