feat(mcp): allow ["*"] wildcard in mcp_tool_permissions to grant all current and future tools (#43108)

* feat(mcp): allow ["*"] wildcard in mcp_tool_permissions to grant all current and future tools

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): run prettier on MCPToolPermissions files

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): keep ["*"] wildcard through toolset union and move constant to litellm.constants

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(mcp): format user_api_key_auth_mcp with ruff format

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): restore wildcard ceiling and deny-all regression tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): treat an empty team tool list as deny-all regardless of key grants

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* revert(mcp): keep legacy [] merge semantics, the truthiness check predates this PR

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): drop banner comment that repeats the wildcard test docstring

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: joshua <joshua@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-24 21:34:57 -07:00 • committed by GitHub
parent efbb3ac87e
commit c976c16a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 312 additions and 21 deletions

View file

@ -167,6 +167,9 @@ MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE: Final = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_M
MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL: Final = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL", "3600"))
MCP_SSO_ASSERTION_CACHE_TTL_SECONDS: Final = int(os.getenv("MCP_SSO_ASSERTION_CACHE_TTL_SECONDS", "60"))
# mcp_tool_permissions entry that grants every current and future tool on a server
MCP_ALL_TOOLS_WILDCARD: Final = "*"
# Default npm cache directory for STDIO MCP servers.
# npm/npx needs a writable cache dir; in containers the default (~/.npm)
# may not exist or be read-only. /tmp is always writable.

View file

@ -13,6 +13,7 @@ from typing_extensions import assert_never
import litellm
from litellm._logging import verbose_logger
from litellm.constants import MCP_ALL_TOOLS_WILDCARD
from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_passthrough_resource_metadata_url,
get_passthrough_www_authenticate,
@ -2136,7 +2137,11 @@ class MCPRequestHandler:
via_toolsets: Sequence[str] | None,
) -> Sequence[str] | None:
"""Union of one level's direct tool grants and its toolset-granted tools on one server,
``None`` when neither source restricts (allow-all from this level)."""
``None`` when neither source restricts (allow-all from this level). A direct grant
containing ``MCP_ALL_TOOLS_WILDCARD`` makes the level unrestricted, so it returns
``None`` whatever the toolsets name."""
if direct is not None and MCP_ALL_TOOLS_WILDCARD in direct:
return None
if direct is None and via_toolsets is None:
return None
return tuple({*(direct or ()), *(via_toolsets or ())})
@ -2251,11 +2256,7 @@ class MCPRequestHandler:
else None
)
key_tools: Final = (
list(set(key_direct_tools or []) | set(key_toolset_tools or []))
if key_direct_tools is not None or key_toolset_tools is not None
else None
)
key_tools: Final = _as_list(MCPRequestHandler._union_tool_grants(key_direct_tools, key_toolset_tools))
team_direct_tools: Final = (
global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id)
if team_obj_perm

View file

@ -27,7 +27,8 @@ from collections.abc import (
from contextlib import asynccontextmanager
from dataclasses import dataclass, replace
from functools import lru_cache
from itertools import chain
from itertools import chain, groupby
from operator import itemgetter
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeAlias, TypedDict, TypeVar, cast
from urllib.parse import ParseResult, urlparse
@ -6812,9 +6813,11 @@ class MCPServerManager:
"""
Rewrite an ``mcp_tool_permissions`` dict keyed by id/name/alias so
every key is a concrete server_id where possible. Tool lists from
keys that point at the same server are unioned, matching the
"duplicate names grant access to all matches" semantics of
``expand_permission_list``.
keys that point at the same server are unioned and deduplicated
first-seen, matching the "duplicate names grant access to all
matches" semantics of ``expand_permission_list``; the
``MCP_ALL_TOOLS_WILDCARD`` entry is preserved as an ordinary list
entry for the caller to interpret.
Required so name-based keys don't silently drop their tool
restrictions when the lookup uses the resolved server_id. Unresolved
@ -6823,11 +6826,15 @@ class MCPServerManager:
"""
if not tool_permissions:
return {}
result: Final[dict[str, list[str]]] = {}
for key, tools in tool_permissions.items():
for server_id in self.expand_permission_list([key]):
result.setdefault(server_id, []).extend(tools or [])
return result
expanded: Final = tuple(
(server_id, tuple(tools or ()))
for key, tools in tool_permissions.items()
for server_id in self.expand_permission_list([key])
)
return {
server_id: list(dict.fromkeys(tool for _, tools in group for tool in tools))
for server_id, group in groupby(sorted(expanded, key=itemgetter(0)), key=itemgetter(0))
}
def get_mcp_server_by_name(self, server_name: str, client_ip: str | None = None) -> MCPServer | None:
"""

View file

@ -342,6 +342,15 @@ class TestMCPRequestHandler:
mock_manager.resolve_toolset_tool_permissions = AsyncMock(return_value=toolset_perms)
return mock_manager
def _real_manager_with_toolsets(self, toolset_perms):
"""A real MCPServerManager so the real expand_tool_permissions runs;
only the DB-backed toolset lookup is stubbed"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
manager = MCPServerManager()
manager.resolve_toolset_tool_permissions = AsyncMock(return_value=toolset_perms)
return manager
async def test_get_allowed_mcp_servers_for_key_includes_toolset_servers(self):
"""A key granted only mcp_toolsets must reach the toolset's servers on
every path (list, call, REST); regression for the list-ok/call-403 bug"""
@ -508,6 +517,141 @@ class TestMCPRequestHandler:
assert result is None
@pytest.mark.parametrize(
"direct,via_toolsets,expected",
[
(["*"], None, None),
(["*"], ["read_file"], None),
(None, None, None),
([], None, ()),
(None, ["read_file"], ("read_file",)),
],
)
def test_union_tool_grants_wildcard_and_union_cases(self, direct, via_toolsets, expected):
"""A direct ["*"] makes the level unrestricted even beside a toolset
list (regression: mapping ["*"] to None in expand_tool_permissions let
a same-level toolset list deny every other tool)"""
result = MCPRequestHandler._union_tool_grants(direct, via_toolsets)
if expected is None:
assert result is None
else:
assert result is not None
assert set(result) == set(expected)
def test_union_tool_grants_unions_two_concrete_lists(self):
result = MCPRequestHandler._union_tool_grants(["read_file"], ["write_file"])
assert result is not None
assert set(result) == {"read_file", "write_file"}
async def test_key_wildcard_allows_a_tool_never_enumerated(self):
"""End to end at the key level: object_permission sits on the auth
object already, no team named, so no patching is needed; the real
global manager expands ["*"] and the level reads unrestricted"""
user_api_key_auth = UserAPIKeyAuth(
api_key="test-key",
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="perm-1",
mcp_tool_permissions={"server-a": ["*"]},
),
)
allowed = await MCPRequestHandler.get_allowed_tools_for_server(
server_id="server-a", user_api_key_auth=user_api_key_auth
)
brand_new_tool_allowed = await MCPRequestHandler.is_tool_allowed_for_server(
tool_name="brand_new_tool", server_id="server-a", user_api_key_auth=user_api_key_auth
)
assert allowed is None
assert brand_new_tool_allowed is True
async def test_key_wildcard_stays_capped_by_team_allowlist(self):
"""A wildcard on the key must never widen a team's enumerated ceiling:
the intersection keeps only the team's named tools"""
user_api_key_auth = UserAPIKeyAuth(
api_key="test-key",
team_id="team-1",
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="perm-1",
mcp_tool_permissions={"server-a": ["*"]},
),
)
team_object_permission = self._toolset_only_object_permission([])
team_object_permission.mcp_tool_permissions = {"server-a": ["read_file"]}
manager = self._real_manager_with_toolsets({})
with (
patch.object( # test-quality-ok: stub the DB team loader to drive the real team-server resolution path
MCPRequestHandler, "_get_team_object_permission", AsyncMock(return_value=team_object_permission)
),
patch( # test-quality-ok: isolate the MCP registry, same seam as the sibling tests
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
manager,
),
):
allowed = await MCPRequestHandler.get_allowed_tools_for_server(
server_id="server-a", user_api_key_auth=user_api_key_auth
)
brand_new_tool_allowed = await MCPRequestHandler.is_tool_allowed_for_server(
tool_name="brand_new_tool", server_id="server-a", user_api_key_auth=user_api_key_auth
)
assert allowed == ["read_file"]
assert brand_new_tool_allowed is False
async def test_team_wildcard_stays_capped_by_key_allowlist(self):
"""A wildcard on the team leaves the key's enumerated list as the
effective ceiling"""
user_api_key_auth = UserAPIKeyAuth(
api_key="test-key",
team_id="team-1",
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="perm-1",
mcp_tool_permissions={"server-a": ["read_file"]},
),
)
team_object_permission = self._toolset_only_object_permission([])
team_object_permission.mcp_tool_permissions = {"server-a": ["*"]}
manager = self._real_manager_with_toolsets({})
with (
patch.object( # test-quality-ok: stub the DB team loader to drive the real team-server resolution path
MCPRequestHandler, "_get_team_object_permission", AsyncMock(return_value=team_object_permission)
),
patch( # test-quality-ok: isolate the MCP registry, same seam as the sibling tests
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
manager,
),
):
allowed = await MCPRequestHandler.get_allowed_tools_for_server(
server_id="server-a", user_api_key_auth=user_api_key_auth
)
assert allowed == ["read_file"]
async def test_key_empty_tool_list_stays_deny_all(self):
"""[] on the key is deny-all, distinct from the wildcard: it must not
be widened into allow-all"""
user_api_key_auth = UserAPIKeyAuth(
api_key="test-key",
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="perm-1",
mcp_tool_permissions={"server-a": []},
),
)
allowed = await MCPRequestHandler.get_allowed_tools_for_server(
server_id="server-a", user_api_key_auth=user_api_key_auth
)
read_file_allowed = await MCPRequestHandler.is_tool_allowed_for_server(
tool_name="read_file", server_id="server-a", user_api_key_auth=user_api_key_auth
)
assert allowed == []
assert read_file_allowed is False
# ------------------------------------------------------------------
# LIT-5749: toolsets attached to a TEAM, ORG, or internal USER must be
# enforced exactly like inline tool allowlists, on both axes

View file

@ -8709,6 +8709,35 @@ class TestMCPServerManagerExpandToolPermissions:
result = manager.expand_tool_permissions({"uuid-a": ["read_file"], "alias-a": ["write_file"]})
assert sorted(result["uuid-a"]) == ["read_file", "write_file"]
def test_wildcard_survives_expansion_as_list_entry(self):
"""["*"] stays in the expanded list so the caller's wildcard check
(``_union_tool_grants``) can read it; this function only normalizes
keys and never maps grants to None."""
manager = MCPServerManager()
manager.config_mcp_servers["uuid-a"] = self._make_server("uuid-a", server_name="alpha")
result = manager.expand_tool_permissions({"uuid-a": ["*"]})
assert result == {"uuid-a": ["*"]}
def test_wildcard_unions_with_concrete_names_across_keys_for_same_server(self):
"""An alias key carrying ["*"] unioned with an id key naming one tool
keeps both entries; interpretation of the wildcard belongs to the
caller, not the expansion."""
manager = MCPServerManager()
manager.config_mcp_servers["uuid-a"] = self._make_server("uuid-a", server_name="alias-a", alias="alias-a")
result = manager.expand_tool_permissions({"uuid-a": ["read_file"], "alias-a": ["*"]})
assert sorted(result["uuid-a"]) == ["*", "read_file"]
def test_empty_list_stays_deny_all(self):
"""[] is deny-all, a distinct meaning from no entry (unrestricted);
the key must survive expansion rather than disappear."""
manager = MCPServerManager()
manager.config_mcp_servers["uuid-a"] = self._make_server("uuid-a", server_name="alpha")
result = manager.expand_tool_permissions({"uuid-a": []})
assert result == {"uuid-a": []}
class TestOAuthDiscoverySSRFGuard:
"""SSRF guard for the OAuth metadata discovery follow-up fetches.

View file

@ -133,9 +133,10 @@ describe("MCPToolPermissions", () => {
const selectAllButton = screen.getByRole("button", { name: "Select All" });
await userEvent.click(selectAllButton);
// Verify onChange was called with all tools selected
// Selecting every displayed tool writes the wildcard, which also covers tools the
// server adds later.
expect(mockOnChange).toHaveBeenCalledWith({
[mockServerId]: ["read_wiki_structure", "read_wiki_contents", "ask_question"],
[mockServerId]: ["*"],
});
});
@ -190,6 +191,77 @@ describe("MCPToolPermissions", () => {
});
});
describe("wildcard all-tools grant", () => {
const wildcardServerId = "server-1";
const wildcardServer = { server_id: wildcardServerId, server_name: "Wildcard Server", alias: "Wildcard Server" };
const wildcardTools = [
{ name: "read_wiki_structure", description: "Get documentation topics" },
{ name: "read_wiki_contents", description: "View documentation" },
{ name: "ask_question", description: "Ask questions" },
];
beforeEach(() => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([wildcardServer]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: wildcardTools, error: false });
});
it("renders every tool checked with the future-tools note when the entry is the wildcard", async () => {
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[wildcardServerId]}
toolPermissions={{ [wildcardServerId]: ["*"] }}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("Wildcard Server")).toBeInTheDocument();
expect(screen.getByText("All tools allowed, including tools added to this server later")).toBeInTheDocument();
await userEvent.click(screen.getByText("Flat List"));
for (const checkbox of screen.getAllByRole("checkbox")) {
expect(checkbox).toBeChecked();
}
});
it("writes the wildcard when Select All covers every displayed tool", async () => {
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[wildcardServerId]}
toolPermissions={{ [wildcardServerId]: ["read_wiki_structure"] }}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("read_wiki_structure")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Select All" }));
expect(mockOnChange).toHaveBeenCalledWith({ [wildcardServerId]: ["*"] });
});
it("converts back to an enumerated list when one tool is unchecked from a wildcard grant", async () => {
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[wildcardServerId]}
toolPermissions={{ [wildcardServerId]: ["*"] }}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("read_wiki_structure")).toBeInTheDocument();
await userEvent.click(screen.getByText("Flat List"));
await userEvent.click(screen.getByRole("checkbox", { name: "ask_question" }));
expect(mockOnChange).toHaveBeenCalledWith({
[wildcardServerId]: ["read_wiki_structure", "read_wiki_contents"],
});
});
});
describe("servers reached indirectly", () => {
const groupServer = {
server_id: "srv-group-1",
@ -428,6 +500,8 @@ describe("MCPToolPermissions", () => {
expect(await screen.findByText("list_issues")).toBeInTheDocument();
await userEvent.click(screen.getByText("Select All"));
// A toolset-sourced server never writes the wildcard: that would create a standing direct
// grant outliving the toolset. The write keeps only the tools this level grants itself.
expect(mockOnChange).toHaveBeenCalledWith({ [toolsetServer.server_id]: ["delete_issue"] });
});
@ -849,7 +923,7 @@ describe("MCPToolPermissions", () => {
const written = mockOnChange.mock.calls.at(-1)?.[0] as Record<string, string[]>;
expect(written["github_mcp"]).toEqual(["list_issues"]);
expect(written[twin.server_id]).toEqual(["list_issues", "create_issue", "delete_issue"]);
expect(written[twin.server_id]).toEqual(["*"]);
});
it("says nothing about shared names when every key names one server", async () => {

View file

@ -8,13 +8,14 @@ import { useMCPAccessGroups } from "../../app/(dashboard)/hooks/mcpServers/useMC
import { useMCPToolsets } from "../../app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import { MCP_ALL_TOOLS_WILDCARD, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import {
EffectiveMcpServer,
McpGrantSource,
applyToolPermissionWrite,
emptyMcpAccessGroups,
mcpAllowedToolsFor,
mcpGrantsAllTools,
resolveEffectiveMcpServers,
} from "./effectiveMcpServers";
@ -150,7 +151,12 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
// Every write goes through here so an edit is authoritative for the SERVER, not for one of the
// equivalent keys that may name it.
const writeAllowedTools = (entry: EffectiveMcpServer, allowed: string[]) => {
onChange(applyToolPermissionWrite({ toolPermissions, entry, allowed }));
const names = (serverTools[entry.server.server_id] ?? []).map((t) => t.name);
const next =
entry.source.kind !== "toolset" && names.length > 0 && names.every((n) => allowed.includes(n))
? [MCP_ALL_TOOLS_WILDCARD]
: allowed;
onChange(applyToolPermissionWrite({ toolPermissions, entry, allowed: next }));
};
const handleSelectAll = (entry: EffectiveMcpServer) => {
@ -222,7 +228,8 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
const serverId = server.server_id;
const serverName = server.server_name || server.alias || serverId;
const tools = serverTools[serverId] || [];
const selectedTools = entry.allowedTools ?? tools.map((t) => t.name);
const grantsAll = mcpGrantsAllTools(entry.keyedTools);
const selectedTools = grantsAll ? tools.map((t) => t.name) : entry.allowedTools ?? tools.map((t) => t.name);
const isLoading = loadingTools[serverId];
const error = toolErrors[serverId];
const viewMode = viewModes[serverId] ?? "crud";
@ -247,6 +254,11 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
)}
</div>
{server.description && <p className="text-sm text-muted-foreground">{server.description}</p>}
{grantsAll && (
<p className="text-sm text-muted-foreground mt-1">
All tools allowed, including tools added to this server later
</p>
)}
{entry.ambiguousKeys.length > 0 && (
<p className="text-sm text-amber-700 mt-1">
{`Also granted by ${entry.ambiguousKeys.map((key) => `"${key}"`).join(", ")}, which names another server too. Those tools stay allowed here until the servers no longer share that name`}

View file

@ -4,6 +4,7 @@ import {
applyToolPermissionWrite,
emptyMcpAccessGroups,
mcpAllowedToolsFor,
mcpGrantsAllTools,
mcpServersForIdentifier,
mcpToolPermissionKeyFor,
resolveEffectiveMcpServers,
@ -66,6 +67,16 @@ describe("mcpServersForIdentifier", () => {
});
});
describe("mcpGrantsAllTools", () => {
it("is true only when the union carries the wildcard, never for an absent grant", () => {
expect(mcpGrantsAllTools(["*"])).toBe(true);
expect(mcpGrantsAllTools(["read_file", "*"])).toBe(true);
expect(mcpGrantsAllTools(["read_file"])).toBe(false);
expect(mcpGrantsAllTools([])).toBe(false);
expect(mcpGrantsAllTools(undefined)).toBe(false);
});
});
describe("mcpToolPermissionKeyFor", () => {
const target = server({ server_id: "uuid-1", server_name: "github_mcp", alias: "GitHub" });

View file

@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
import { MCP_ALL_TOOLS_WILDCARD } from "../mcp_tools/constants";
// Mirrors the backend resolver's union (direct + access_group + tool_perm + toolset), so the
// editor shows exactly the servers this permission level entitles.
@ -121,6 +122,12 @@ export const mcpAllowedToolsFor = (
return [...new Set(keys.flatMap((key) => toolPermissions[key] ?? []))];
};
// An allowed-tools union carrying the wildcard grants every current and future tool on the
// server; `undefined` (no entry at all) is unrestricted for a different reason and is not a
// wildcard grant the editor should expand.
export const mcpGrantsAllTools = (allowed: readonly string[] | undefined): boolean =>
allowed !== undefined && allowed.includes(MCP_ALL_TOOLS_WILDCARD);
// Tool names the given toolsets grant on this server, `undefined` when they grant none.
const mcpToolsetToolsFor = (
server: MCPServer,

View file

@ -3,5 +3,8 @@ export const NO_MCP_SERVERS_SENTINEL = "no-mcp-servers";
export const ALL_PROXY_MCP_SERVERS_SENTINEL = "all-proxy-mcpservers";
// Must match the backend MCP_ALL_TOOLS_WILDCARD constant in litellm/constants.py.
export const MCP_ALL_TOOLS_WILDCARD = "*";
export const MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE =
"Tool preview is not available for submissions. Tools will be verified by an admin during review.";