feat(mcp): add all-proxy-mcps grant for teams

Teams can grant access to every MCP server on the proxy by storing the
all-proxy-mcps sentinel in object_permission.mcp_servers instead of
enumerating each server one by one. The sentinel expands against the live
registry at resolution time, so servers registered later are covered
without re-editing the team

The expansion lives in MCPServerManager.expand_permission_list, the single
chokepoint every key/team/org/end-user permission list flows through, so it
works uniformly wherever object_permission.mcp_servers is set, mirroring the
existing all-proxy-models pattern for models

The dashboard MCP selector gains an exclusive "All Proxy MCPs" option on the
team create and edit forms, and the permissions view renders the grant
This commit is contained in:
ryan-crabbe-berri 2026-06-25 12:26:11 -07:00
parent 17bfd415ae
commit 5d02c8f9b4
11 changed files with 277 additions and 19 deletions

View file

@ -4144,10 +4144,16 @@ class MCPServerManager:
emitted so admins can diagnose stale/typo permission entries — the
downstream access-check denies them when compared against the
concrete request server_id.
The ``all-proxy-mcps`` sentinel expands to every server in the live
registry, so an entity granted it stays in sync as servers are added
or removed without re-editing its permission list.
"""
if not identifiers:
return []
registry = self.get_registry()
if SpecialMCPServerNames.all_proxy_mcp_servers.value in identifiers:
return list(registry.keys())
expanded: Set[str] = set()
for identifier in identifiers:
if identifier in registry:

View file

@ -2985,6 +2985,7 @@ class SpecialModelNames(enum.Enum):
class SpecialMCPServerNames(enum.Enum):
no_mcp_servers = "no-mcp-servers"
all_proxy_mcp_servers = "all-proxy-mcps"
class SpecialProxyStrings(enum.Enum):

View file

@ -1,6 +1,7 @@
import json
import os
import sys
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, call as mock_call, patch
import pytest
@ -16,6 +17,8 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
MCPTransport,
SpecialHeaders,
SpecialMCPServerNames,
UserAPIKeyAuth,
@ -100,6 +103,66 @@ class TestMCPRequestHandler:
mock_key_servers.assert_called_once_with(mock_user_auth)
mock_team_servers.assert_called_once_with(mock_user_auth)
async def test_get_allowed_mcp_servers_for_team_all_proxy_mcps(self):
"""A team scoped to the all-proxy-mcps sentinel resolves to every
server in the live registry — the smoothest path requested in the
ticket, exercised through the real expansion logic."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
)
manager = MCPServerManager()
for server_id in ("srv-a", "srv-b", "srv-c"):
await manager.add_server(
LiteLLM_MCPServerTable(
server_id=server_id,
alias=server_id,
description="",
url=None,
transport=MCPTransport.stdio,
command="python",
args=["-m", "server"],
env={},
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
object_permission = MagicMock()
object_permission.mcp_servers = [
SpecialMCPServerNames.all_proxy_mcp_servers.value
]
object_permission.mcp_access_groups = []
object_permission.mcp_tool_permissions = None
team_obj = MagicMock()
team_obj.object_permission = object_permission
team_obj.access_group_ids = []
user_auth = UserAPIKeyAuth(api_key="k", user_id="u", team_id="t")
with patch(
"litellm.proxy.proxy_server.prisma_client", MagicMock()
), patch(
"litellm.proxy.auth.auth_checks.get_team_object",
AsyncMock(return_value=team_obj),
), patch(
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
AsyncMock(return_value=[]),
), patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
manager,
), patch.object(
MCPRequestHandler,
"_get_mcp_servers_from_access_groups",
AsyncMock(return_value=[]),
):
result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(
user_auth
)
assert sorted(result) == ["srv-a", "srv-b", "srv-c"]
@pytest.mark.parametrize(
"team_servers,key_servers,expected_servers,scenario",
[

View file

@ -38,6 +38,7 @@ from litellm.proxy._types import (
MCPEnvVar,
MCPEnvVarScope,
MCPTransport,
SpecialMCPServerNames,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer
@ -111,6 +112,54 @@ class TestMCPServerManager:
assert added_server.args == ["-m", "server"]
assert added_server.env == {"DEBUG": "1", "TEST": "1"}
async def test_expand_permission_list_all_proxy_mcps_sentinel(self):
"""The all-proxy-mcps sentinel expands to every server in the live
registry and re-expands against servers registered later, so a grant
stays current without the permission list being re-edited."""
manager = MCPServerManager()
async def _register(server_id: str):
await manager.add_server(
LiteLLM_MCPServerTable(
server_id=server_id,
alias=server_id,
description="",
url=None,
transport=MCPTransport.stdio,
command="python",
args=["-m", "server"],
env={},
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await _register("srv-a")
await _register("srv-b")
sentinel = SpecialMCPServerNames.all_proxy_mcp_servers.value
assert set(manager.expand_permission_list([sentinel])) == {"srv-a", "srv-b"}
# The sentinel dominates: mixing it with a concrete id still grants all.
assert set(manager.expand_permission_list([sentinel, "srv-a"])) == {
"srv-a",
"srv-b",
}
# A server registered after the grant is included with no re-edit.
await _register("srv-c")
assert set(manager.expand_permission_list([sentinel])) == {
"srv-a",
"srv-b",
"srv-c",
}
# Without the sentinel, only the named server resolves — proving the
# full-registry result above comes from the sentinel branch, not a
# blanket "return everything".
assert manager.expand_permission_list(["srv-a"]) == ["srv-a"]
async def test_create_mcp_client_stdio(self):
"""Test creating MCP client for stdio transport"""
manager = MCPServerManager()

View file

@ -1479,6 +1479,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
accessToken={accessToken || ""}
placeholder="Select MCP servers or access groups (optional)"
allowAllProxyMcps
/>
</Form.Item>

View file

@ -3,7 +3,7 @@ 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";
import { ALL_PROXY_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
useMCPServers: vi.fn(),
@ -98,3 +98,57 @@ describe("MCPServerSelector no-mcp-servers option", () => {
expect(optionByValue(NO_MCP_SERVERS_SENTINEL)?.disabled).toBe(false);
});
});
describe("MCPServerSelector all-proxy-mcps 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 All Proxy MCPs option by default", () => {
renderWithProviders(
<MCPServerSelector accessToken="tok" onChange={vi.fn()} value={{ servers: [], accessGroups: [] }} />,
);
expect(optionByValue(ALL_PROXY_MCPS_SENTINEL)).toBeUndefined();
});
it("emits an exclusive sentinel when All Proxy MCPs is selected", async () => {
const onChange = vi.fn();
renderWithProviders(
<MCPServerSelector
accessToken="tok"
allowAllProxyMcps
onChange={onChange}
value={{ servers: ["srv-1"], accessGroups: [] }}
/>,
);
expect(optionByValue(ALL_PROXY_MCPS_SENTINEL)).toBeDefined();
await userEvent.selectOptions(screen.getByTestId("mcp-select"), [ALL_PROXY_MCPS_SENTINEL]);
expect(onChange).toHaveBeenCalledWith({ servers: [ALL_PROXY_MCPS_SENTINEL], accessGroups: [], toolsets: [] });
});
it("disables real server options while the sentinel is selected", () => {
renderWithProviders(
<MCPServerSelector
accessToken="tok"
allowAllProxyMcps
onChange={vi.fn()}
value={{ servers: [ALL_PROXY_MCPS_SENTINEL], accessGroups: [] }}
/>,
);
expect(optionByValue("srv-1")?.disabled).toBe(true);
expect(optionByValue(ALL_PROXY_MCPS_SENTINEL)?.disabled).toBe(false);
});
});

View file

@ -3,7 +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";
import { ALL_PROXY_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
interface MCPServerSelectorProps {
onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
@ -18,10 +18,30 @@ interface MCPServerSelectorProps {
disabled?: boolean;
teamId?: string | null;
allowNoMcpServers?: boolean;
allowAllProxyMcps?: boolean;
}
const TOOLSET_PREFIX = "toolset:";
type MCPSelection = { servers: string[]; accessGroups: string[]; toolsets: string[] };
// "No MCP Servers" and "All Proxy MCPs" are mutually exclusive with everything
// else. The most restrictive ("block all") wins, mirroring the backend
// precedence where the no-mcp-servers sentinel overrides every grant.
const resolveExclusiveSelection = (
selected: string[],
allowNoMcpServers: boolean,
allowAllProxyMcps: boolean,
): MCPSelection | null => {
if (allowNoMcpServers && selected.includes(NO_MCP_SERVERS_SENTINEL)) {
return { servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [], toolsets: [] };
}
if (allowAllProxyMcps && selected.includes(ALL_PROXY_MCPS_SENTINEL)) {
return { servers: [ALL_PROXY_MCPS_SENTINEL], accessGroups: [], toolsets: [] };
}
return null;
};
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
onChange,
value,
@ -31,6 +51,7 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
disabled = false,
teamId,
allowNoMcpServers = false,
allowAllProxyMcps = false,
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
@ -81,12 +102,14 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
];
const hasNoMcpServersSelected = allowNoMcpServers && selectedValues.includes(NO_MCP_SERVERS_SENTINEL);
const hasAllProxyMcpsSelected = allowAllProxyMcps && selectedValues.includes(ALL_PROXY_MCPS_SENTINEL);
const hasExclusiveSelected = hasNoMcpServersSelected || hasAllProxyMcpsSelected;
// 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: [] });
const exclusive = resolveExclusiveSelection(selected, allowNoMcpServers, allowAllProxyMcps);
if (exclusive) {
onChange(exclusive);
return;
}
const toolsetsSelected = selected
@ -98,6 +121,39 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected });
};
const renderExclusiveOptions = () => [
...(allowAllProxyMcps
? [
<Select.Option
key={ALL_PROXY_MCPS_SENTINEL}
value={ALL_PROXY_MCPS_SENTINEL}
label="All Proxy MCPs"
disabled={hasNoMcpServersSelected}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ flex: 1 }}>All Proxy MCPs</span>
<span style={{ color: "#1890ff", fontSize: "12px", fontWeight: 500, opacity: 0.8 }}>Every server</span>
</div>
</Select.Option>,
]
: []),
...(allowNoMcpServers
? [
<Select.Option
key={NO_MCP_SERVERS_SENTINEL}
value={NO_MCP_SERVERS_SENTINEL}
label="No MCP Servers"
disabled={hasAllProxyMcpsSelected}
>
<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>,
]
: []),
];
return (
<div>
<Select
@ -112,21 +168,14 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
style={{ width: "100%" }}
disabled={disabled}
filterOption={(input, option) => {
if (option?.value === NO_MCP_SERVERS_SENTINEL) return true;
if (option?.value === NO_MCP_SERVERS_SENTINEL || option?.value === ALL_PROXY_MCPS_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>
)}
{renderExclusiveOptions()}
{options.map((opt) => (
<Select.Option key={opt.value} value={opt.value} label={opt.label} disabled={hasNoMcpServersSelected}>
<Select.Option key={opt.value} value={opt.value} label={opt.label} disabled={hasExclusiveSelected}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{

View file

@ -1,2 +1,5 @@
// Must match the backend SpecialMCPServerNames.no_mcp_servers enum value.
export const NO_MCP_SERVERS_SENTINEL = "no-mcp-servers";
// Must match the backend SpecialMCPServerNames.all_proxy_mcp_servers enum value.
export const ALL_PROXY_MCPS_SENTINEL = "all-proxy-mcps";

View file

@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MCPServerPermissions from "./MCPServerPermissions";
import * as networking from "../networking";
import { ALL_PROXY_MCPS_SENTINEL } from "../mcp_tools/constants";
vi.mock("../networking");
@ -248,6 +249,28 @@ describe("MCPServerPermissions", () => {
expect(screen.getByText("0")).toBeInTheDocument();
});
it("should display the all-proxy-mcps grant instead of listing the sentinel as a server", async () => {
/**
* Tests that the all-proxy-mcps sentinel renders the "every server" grant
* state rather than a literal server row, so an admin sees that the team
* has all proxy MCPs.
*/
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
render(
<MCPServerPermissions
mcpServers={[ALL_PROXY_MCPS_SENTINEL]}
mcpAccessGroups={[]}
mcpToolPermissions={{}}
accessToken={mockAccessToken}
/>,
);
expect(await screen.findByText(/All proxy MCP servers/)).toBeInTheDocument();
// The sentinel must not leak into the list as a fake server id.
expect(screen.queryByText(ALL_PROXY_MCPS_SENTINEL)).not.toBeInTheDocument();
});
it("should handle multiple servers with different tool permissions", async () => {
/**
* Tests that multiple servers can each have their own tool permissions

View file

@ -4,7 +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";
import { ALL_PROXY_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
interface MCPServerPermissionsProps {
mcpServers: string[];
@ -96,11 +96,12 @@ export function MCPServerPermissions({
};
const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL);
const grantsAllProxyMcps = mcpServers.includes(ALL_PROXY_MCPS_SENTINEL);
// Merge servers and access groups into one list
const mergedItems = [
...mcpServers
.filter((server) => server !== NO_MCP_SERVERS_SENTINEL)
.filter((server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCPS_SENTINEL)
.map((server) => ({ type: "server", value: server })),
...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })),
];
@ -111,8 +112,8 @@ 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={blocksAllMcpServers ? "red" : "blue"} size="xs">
{blocksAllMcpServers ? "Blocked" : totalCount}
<Badge color={blocksAllMcpServers ? "red" : grantsAllProxyMcps ? "green" : "blue"} size="xs">
{blocksAllMcpServers ? "Blocked" : grantsAllProxyMcps ? "All" : totalCount}
</Badge>
</div>
@ -123,6 +124,13 @@ export function MCPServerPermissions({
No MCP servers — this key is blocked from all MCP servers, including its team&apos;s servers
</Text>
</div>
) : grantsAllProxyMcps ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-green-50 border border-green-200">
<ServerIcon className="h-4 w-4 text-green-500" />
<Text className="text-green-700 text-sm">
All proxy MCP servers — access to every MCP server registered on the proxy, including ones added later
</Text>
</div>
) : totalCount > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-1">
{mergedItems.map((item, index) => {

View file

@ -1356,6 +1356,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
value={form.getFieldValue("mcp_servers_and_groups")}
accessToken={accessToken || ""}
placeholder="Select MCP servers or access groups (optional)"
allowAllProxyMcps
/>
</Form.Item>