Merge pull request #18639 from BerriAI/litellm_feat_mcp_global_mode

[feat] mcp global mode
This commit is contained in:
YutaSaito 2026-01-05 17:34:06 +09:00 committed by GitHub
commit d5e4a43be3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 311 additions and 9 deletions

View file

@ -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

View file

@ -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.
<Tabs>
<TabItem value="ui" label="UI">
1. Open **MCP Servers → Add / Edit** in the Admin UI.
2. Expand **Permission Management / Access Control**.
3. Toggle **Allow All LiteLLM Keys** on.
<Image
img={require('../img/mcp_allow_all_ui.png')}
style={{width: '80%', display: 'block', margin: '1rem auto'}}
alt="Allow all LiteLLM keys toggle in MCP UI"
/>
The toggle makes the server “public” without touching existing access groups.
</TabItem>
<TabItem value="config" label="config.yaml">
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
```
</TabItem>
</Tabs>
### 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.
- Youre 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "allow_all_keys" BOOLEAN NOT NULL DEFAULT false;

View file

@ -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?
}
}

View file

@ -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)

View file

@ -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
vector_store: LiteLLM_ManagedVectorStoresTable

View file

@ -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):

View file

@ -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?
}
}

View file

@ -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)

View file

@ -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?
}
}

View file

@ -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"""

View file

@ -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 (
<Form form={form} initialValues={{ allow_all_keys: false }}>
{children}
</Form>
);
};
return render(
<Wrapper>
<MCPPermissionManagement {...defaultProps} {...props} />
</Wrapper>,
);
};
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");
});
});

View file

@ -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<MCPPermissionManagementProps> = ({
}));
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<MCPPermissionManagementProps> = ({
className="border-0"
>
<div className="space-y-6 pt-4">
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-sm font-medium text-gray-700 flex items-center">
Allow All LiteLLM Keys
<Tooltip title="When enabled, every API key can access this MCP server.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
<p className="text-sm text-gray-600 mt-1">Enable if this server should be &quot;public&quot; to all keys.</p>
</div>
<Form.Item
name="allow_all_keys"
valuePropName="checked"
initialValue={mcpServer?.allow_all_keys ?? false}
className="mb-0"
>
<Switch />
</Form.Item>
</div>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">

View file

@ -188,6 +188,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
static_headers: staticHeadersList,
stdio_config: rawStdioConfig,
credentials: credentialValues,
allow_all_keys: allowAllKeysRaw,
...restValues
} = values;
@ -278,6 +279,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
mcp_access_groups: accessGroups,
alias: restValues.alias,
allowed_tools: allowedTools.length > 0 ? allowedTools : null,
allow_all_keys: Boolean(allowAllKeysRaw),
static_headers: staticHeaders,
};

View file

@ -286,7 +286,12 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
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);

View file

@ -229,6 +229,25 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
<Text className="font-medium">Auth Type</Text>
<div>{handleAuth(mcpServer.auth_type)}</div>
</div>
<div>
<Text className="font-medium">Allow All LiteLLM Keys</Text>
<div className="flex items-center gap-2">
{mcpServer.allow_all_keys ? (
<span className="px-2 py-1 bg-green-50 text-green-700 rounded-md text-sm">
Enabled
</span>
) : (
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-md text-sm">
Disabled
</span>
)}
{mcpServer.allow_all_keys && (
<Text className="text-xs text-gray-500">
All keys can access this MCP server
</Text>
)}
</div>
</div>
<div>
<Text className="font-medium">Access Groups</Text>
<div>

View file

@ -152,6 +152,7 @@ export interface MCPServer {
teams?: Team[];
mcp_access_groups?: string[];
allowed_tools?: string[];
allow_all_keys?: boolean;
}
export interface MCPServerProps {