diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index 8c11b8fd654..b25c58d3f93 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -198,6 +198,7 @@ mcp_servers: - `http` - Streamable HTTP transport - `stdio` - Standard Input/Output transport - **Command**: The command to execute for stdio transport (required for stdio) +- **allow_all_keys**: Set to `true` to make the server available to every LiteLLM API key, even if the key/team doesn't list the server in its MCP permissions. - **Args**: Array of arguments to pass to the command (optional for stdio) - **Env**: Environment variables to set for the stdio process (optional for stdio) - **Description**: Optional description for the server diff --git a/docs/my-website/docs/mcp_control.md b/docs/my-website/docs/mcp_control.md index c8c3d8e10f3..2a3a7bc5a05 100644 --- a/docs/my-website/docs/mcp_control.md +++ b/docs/my-website/docs/mcp_control.md @@ -13,6 +13,7 @@ LiteLLM provides fine-grained permission management for MCP servers, allowing yo - **Restrict MCP access by entity**: Control which keys, teams, or organizations can access specific MCP servers - **Tool-level filtering**: Automatically filter available tools based on entity permissions - **Centralized control**: Manage all MCP permissions from the LiteLLM Admin UI or API +- **One-click public MCPs**: Mark specific servers as available to every LiteLLM API key when you don't need per-key restrictions This ensures that only authorized entities can discover and use MCP tools, providing an additional security layer for your MCP infrastructure. @@ -95,6 +96,48 @@ mcp_servers: - If you specify both `allowed_tools` and `disallowed_tools`, the allowed list takes priority - Tool names are case-sensitive +## Public MCP Servers (allow_all_keys) + +Some MCP servers are meant to be shared broadly—think internal knowledge bases, calendar integrations, or other low-risk utilities where every team should be able to connect without requesting access. Instead of adding those servers to every key, team, or organization, enable the new `allow_all_keys` toggle. + + + + +1. Open **MCP Servers → Add / Edit** in the Admin UI. +2. Expand **Permission Management / Access Control**. +3. Toggle **Allow All LiteLLM Keys** on. + +Allow all LiteLLM keys toggle in MCP UI + +The toggle makes the server “public” without touching existing access groups. + + + + +Set `allow_all_keys: true` to mark the server as public: + +```yaml title="Make an MCP server public" showLineNumbers +mcp_servers: + deepwiki: + url: https://mcp.deepwiki.com/mcp + allow_all_keys: true +``` + + + + +### When to use it + +- You have shared MCP utilities where fine-grained ACLs would only add busywork. +- You want a “default enabled” experience for internal users, while still being able to layer tool-level restrictions. +- You’re onboarding new teams and want the safest MCPs available out of the box. + +Once enabled, LiteLLM automatically includes the server for every key during tool discovery/calls—no extra virtual-key or team configuration is required. + --- ## Allow/Disallow MCP Tool Parameters diff --git a/docs/my-website/img/mcp_oauth.png b/docs/my-website/img/mcp_oauth.png new file mode 100644 index 00000000000..e504ccc86bb Binary files /dev/null and b/docs/my-website/img/mcp_oauth.png differ diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260105151539_add_allow_all_keys_to_mcp_servers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260105151539_add_allow_all_keys_to_mcp_servers/migration.sql new file mode 100644 index 00000000000..8d3e02bd051 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260105151539_add_allow_all_keys_to_mcp_servers/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "allow_all_keys" BOOLEAN NOT NULL DEFAULT false; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index ea47b6ed03b..e565135bbc4 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -211,6 +211,7 @@ model LiteLLM_MCPServerTable { authorization_url String? token_url String? registration_url String? + allow_all_keys Boolean @default(false) } // Generate Tokens for Proxy @@ -748,4 +749,4 @@ model LiteLLM_SkillsTable { created_by String? updated_at DateTime @default(now()) @updatedAt updated_by String? -} \ No newline at end of file +} diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d193fc27fb9..a5ac966062e 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -260,6 +260,7 @@ class MCPServerManager: allowed_params=server_config.get("allowed_params", None), access_groups=server_config.get("access_groups", None), static_headers=server_config.get("static_headers", None), + allow_all_keys=bool(server_config.get("allow_all_keys", False)), ) self.config_mcp_servers[server_id] = new_server @@ -549,6 +550,7 @@ class MCPServerManager: access_groups=getattr(mcp_server, "mcp_access_groups", None), allowed_tools=getattr(mcp_server, "allowed_tools", None), disallowed_tools=getattr(mcp_server, "disallowed_tools", None), + allow_all_keys=mcp_server.allow_all_keys, ) return new_server @@ -581,6 +583,14 @@ class MCPServerManager: all_servers = list(self.get_registry().values()) return {server.server_id for server in all_servers} + def get_allow_all_keys_server_ids(self) -> List[str]: + """Return server IDs that bypass per-key restrictions.""" + return [ + server.server_id + for server in self.get_registry().values() + if server.allow_all_keys + ] + async def get_allowed_mcp_servers( self, user_api_key_auth: Optional[UserAPIKeyAuth] = None ) -> List[str]: @@ -593,6 +603,8 @@ class MCPServerManager: if user_api_key_auth and _user_has_admin_view(user_api_key_auth): return list(self.get_registry().keys()) + allow_all_server_ids = self.get_allow_all_keys_server_ids() + try: allowed_mcp_servers = await MCPRequestHandler.get_allowed_mcp_servers( user_api_key_auth @@ -600,14 +612,17 @@ class MCPServerManager: verbose_logger.debug( f"Allowed MCP Servers for user api key auth: {allowed_mcp_servers}" ) - if len(allowed_mcp_servers) == 0: + combined_servers = set(allowed_mcp_servers) + combined_servers.update(allow_all_server_ids) + + if len(combined_servers) == 0: verbose_logger.debug( "No allowed MCP Servers found for user api key auth." ) - return allowed_mcp_servers + return list(combined_servers) except Exception as e: verbose_logger.warning(f"Failed to get allowed MCP servers: {str(e)}.") - return [] + return allow_all_server_ids async def get_tools_for_server(self, server_id: str) -> List[MCPTool]: """ @@ -2238,6 +2253,7 @@ class MCPServerManager: authorization_url=server.authorization_url, token_url=server.token_url, registration_url=server.registration_url, + allow_all_keys=server.allow_all_keys, ) async def get_all_mcp_servers_with_health_and_teams( @@ -2331,6 +2347,7 @@ class MCPServerManager: authorization_url=server.authorization_url, token_url=server.token_url, registration_url=server.registration_url, + allow_all_keys=server.allow_all_keys, ) list_mcp_servers.append(mcp_server_table) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7abfe7a96bc..fa32f60c073 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1037,6 +1037,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None + allow_all_keys: bool = False @model_validator(mode="before") @classmethod @@ -1097,6 +1098,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None + allow_all_keys: bool = False @model_validator(mode="before") @classmethod @@ -1149,6 +1151,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None + allow_all_keys: bool = False class MakeMCPServersPublicRequest(LiteLLMPydanticObjectBase): @@ -3821,4 +3824,4 @@ class LiteLLM_ManagedVectorStoresTable(LiteLLMPydanticObjectBase): class ResponseLiteLLM_ManagedVectorStore(TypedDict, total=False): - vector_store: LiteLLM_ManagedVectorStoresTable \ No newline at end of file + vector_store: LiteLLM_ManagedVectorStoresTable diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 9111f53a517..a871a6637a2 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -212,6 +212,7 @@ if MCP_AVAILABLE: authorization_url=payload.authorization_url, token_url=payload.token_url, registration_url=payload.registration_url, + allow_all_keys=payload.allow_all_keys, ) def get_prisma_client_or_throw(message: str): diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index ea47b6ed03b..e565135bbc4 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -211,6 +211,7 @@ model LiteLLM_MCPServerTable { authorization_url String? token_url String? registration_url String? + allow_all_keys Boolean @default(false) } // Generate Tokens for Proxy @@ -748,4 +749,4 @@ model LiteLLM_SkillsTable { created_by String? updated_at DateTime @default(now()) @updatedAt updated_by String? -} \ No newline at end of file +} diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 869037546ce..96fd79f466b 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -7,12 +7,14 @@ from litellm.proxy._types import MCPAuthType, MCPTransportType # MCPInfo now allows arbitrary additional fields for custom metadata MCPInfo = Dict[str, Any] + class MCPOAuthMetadata(BaseModel): scopes: Optional[List[str]] = None authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None + class MCPServer(BaseModel): server_id: str name: str @@ -47,4 +49,5 @@ class MCPServer(BaseModel): args: Optional[List[str]] = None env: Optional[Dict[str, str]] = None access_groups: Optional[List[str]] = None + allow_all_keys: bool = False model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/schema.prisma b/schema.prisma index ea47b6ed03b..e565135bbc4 100644 --- a/schema.prisma +++ b/schema.prisma @@ -211,6 +211,7 @@ model LiteLLM_MCPServerTable { authorization_url String? token_url String? registration_url String? + allow_all_keys Boolean @default(false) } // Generate Tokens for Proxy @@ -748,4 +749,4 @@ model LiteLLM_SkillsTable { created_by String? updated_at DateTime @default(now()) @updatedAt updated_by String? -} \ No newline at end of file +} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index a1fbddec586..8062243dfdd 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -1057,6 +1057,110 @@ async def test_list_tools_multiple_servers_prefixed_names(): assert names == ["jira-toolA", "zapier-toolA"] +@pytest.mark.asyncio +async def test_mcp_manager_allows_public_servers_without_permissions(): + try: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + except ImportError: + pytest.skip("MCP server not available") + + manager = MCPServerManager() + public_server = MCPServer( + server_id="public", + name="public", + transport=MCPTransport.http, + allow_all_keys=True, + ) + manager.registry = {public_server.server_id: public_server} + + with patch( + "litellm.proxy.management_endpoints.common_utils._user_has_admin_view", + return_value=False, + ), patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPRequestHandler.get_allowed_mcp_servers", + AsyncMock(return_value=[]), + ): + allowed = await manager.get_allowed_mcp_servers(UserAPIKeyAuth()) + + assert allowed == ["public"] + + +@pytest.mark.asyncio +async def test_mcp_manager_returns_public_when_permission_lookup_fails(): + try: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + except ImportError: + pytest.skip("MCP server not available") + + manager = MCPServerManager() + public_server = MCPServer( + server_id="public", + name="public", + transport=MCPTransport.http, + allow_all_keys=True, + ) + manager.registry = {public_server.server_id: public_server} + + with patch( + "litellm.proxy.management_endpoints.common_utils._user_has_admin_view", + return_value=False, + ), patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPRequestHandler.get_allowed_mcp_servers", + AsyncMock(side_effect=Exception("boom")), + ): + allowed = await manager.get_allowed_mcp_servers(UserAPIKeyAuth()) + + assert allowed == ["public"] + + +@pytest.mark.asyncio +async def test_mcp_manager_merges_public_and_restricted_servers(): + try: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + except ImportError: + pytest.skip("MCP server not available") + + manager = MCPServerManager() + public_server = MCPServer( + server_id="public", + name="public", + transport=MCPTransport.http, + allow_all_keys=True, + ) + scoped_server = MCPServer( + server_id="restricted", + name="restricted", + transport=MCPTransport.http, + ) + manager.registry = { + public_server.server_id: public_server, + scoped_server.server_id: scoped_server, + } + + with patch( + "litellm.proxy.management_endpoints.common_utils._user_has_admin_view", + return_value=False, + ), patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPRequestHandler.get_allowed_mcp_servers", + AsyncMock(return_value=["restricted"]), + ): + allowed = await manager.get_allowed_mcp_servers(UserAPIKeyAuth()) + + assert set(allowed) == {"public", "restricted"} + + @pytest.mark.asyncio async def test_call_mcp_tool_user_unauthorized_access(): """Test that a user cannot call a tool from a server they don't have access to""" diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx new file mode 100644 index 00000000000..3784680062c --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { Form } from "antd"; + +import MCPPermissionManagement from "./MCPPermissionManagement"; + +const defaultProps = { + availableAccessGroups: [], + mcpServer: null, + searchValue: "", + setSearchValue: () => {}, + getAccessGroupOptions: () => [], +}; + +describe("MCPPermissionManagement", () => { +const expandPanel = async () => { + const user = userEvent.setup(); + const headerButton = screen.getByRole("button", { + name: /permission management/i, + }); + await user.click(headerButton); + return user; +}; + +const renderWithForm = (props = {}) => { + const Wrapper: React.FC = ({ children }) => { + const [form] = Form.useForm(); + return ( +
+ {children} +
+ ); + }; + + return render( + + + , + ); +}; + + it("should default allow_all_keys switch to unchecked for new servers", async () => { + renderWithForm(); + await expandPanel(); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "false"); + }); + + it("should reflect allow_all_keys when editing an existing server", async () => { + renderWithForm({ + mcpServer: { + server_id: "server-1", + url: "https://example.com", + created_at: "2024-01-01T00:00:00Z", + created_by: "user", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user", + allow_all_keys: true, + }, + }); + + const user = await expandPanel(); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "true"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-checked", "false"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx index 9286e4825cf..efc34e32672 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import { Form, Select, Tooltip, Collapse, Input, Space, Button } from "antd"; +import { Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd"; import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; import { MCPServer } from "./types"; const { Panel } = Collapse; @@ -38,6 +38,11 @@ const MCPPermissionManagement: React.FC = ({ })); form.setFieldValue("static_headers", staticHeaders); } + if (typeof mcpServer.allow_all_keys === "boolean") { + form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys); + } + } else { + form.setFieldValue("allow_all_keys", false); } }, [mcpServer, form]); @@ -57,6 +62,26 @@ const MCPPermissionManagement: React.FC = ({ className="border-0" >
+
+
+ + Allow All LiteLLM Keys + + + + +

Enable if this server should be "public" to all keys.

+
+ + + +
+ diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index e9f9636bf2c..d72b7c4f676 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -188,6 +188,7 @@ const CreateMCPServer: React.FC = ({ static_headers: staticHeadersList, stdio_config: rawStdioConfig, credentials: credentialValues, + allow_all_keys: allowAllKeysRaw, ...restValues } = values; @@ -278,6 +279,7 @@ const CreateMCPServer: React.FC = ({ mcp_access_groups: accessGroups, alias: restValues.alias, allowed_tools: allowedTools.length > 0 ? allowedTools : null, + allow_all_keys: Boolean(allowAllKeysRaw), static_headers: staticHeaders, }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index b4486bdfb23..82f85f75eda 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -286,7 +286,12 @@ const MCPServerEdit: React.FC = ({ if (!accessToken) return; try { // Ensure access groups is always a string array - const { static_headers: staticHeadersList, credentials: credentialValues, ...restValues } = values; + const { + static_headers: staticHeadersList, + credentials: credentialValues, + allow_all_keys: allowAllKeysRaw, + ...restValues + } = values; const accessGroups = (restValues.mcp_access_groups || []).map((g: any) => typeof g === "string" ? g : g.name || String(g), @@ -339,6 +344,7 @@ const MCPServerEdit: React.FC = ({ allowed_tools: allowedTools.length > 0 ? allowedTools : null, disallowed_tools: restValues.disallowed_tools || [], static_headers: staticHeaders, + allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), }; const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx index 541c0bbb2f3..6a4e9c105ff 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -229,6 +229,25 @@ export const MCPServerView: React.FC = ({ Auth Type
{handleAuth(mcpServer.auth_type)}
+
+ Allow All LiteLLM Keys +
+ {mcpServer.allow_all_keys ? ( + + Enabled + + ) : ( + + Disabled + + )} + {mcpServer.allow_all_keys && ( + + All keys can access this MCP server + + )} +
+
Access Groups
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 0d21f04779b..cf938e21b3a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -152,6 +152,7 @@ export interface MCPServer { teams?: Team[]; mcp_access_groups?: string[]; allowed_tools?: string[]; + allow_all_keys?: boolean; } export interface MCPServerProps {