diff --git a/litellm/constants.py b/litellm/constants.py index a86be55d654..67021ae2abc 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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. diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index cdf52e6dc8d..a93ffaeac9f 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 312dcb27d89..be4df55ff58 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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: """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 24f09e79dbb..fc4d7b45785 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -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 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index cd5dae1269a..f3d37a858ca 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -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. diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx index 149d231fff6..d3e3f204813 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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; 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 () => { diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx index e26f1a6f511..7edeaedff2e 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx @@ -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 = ({ // 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 = ({ 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 = ({ )} {server.description &&

{server.description}

} + {grantsAll && ( +

+ All tools allowed, including tools added to this server later +

+ )} {entry.ambiguousKeys.length > 0 && (

{`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`} diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts index 487c6f9f55e..07f2b0e2508 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts +++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts @@ -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" }); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts index b3ba24f3c59..c9e85fef31b 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts +++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts @@ -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, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/constants.ts b/ui/litellm-dashboard/src/components/mcp_tools/constants.ts index 66ab1a352f4..eef98383d2a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/constants.ts +++ b/ui/litellm-dashboard/src/components/mcp_tools/constants.ts @@ -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.";