mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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:
parent
efbb3ac87e
commit
c976c16a82
10 changed files with 312 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue