Deny MCP server assignment by default when team has no config; scope dropdown to team

Security change: a team with no MCP object_permission now blocks all MCP server
assignments on keys (except allow_all_keys servers). Previously "no config" meant
"no restriction", which allowed any user with view_all to assign arbitrary servers.

Also adds team_id query param to GET /v1/mcp/server and GET /v1/mcp/access_groups
so the UI Create Key modal dropdown is filtered to the selected team's servers. Threads
teamId through MCPServerSelector → hooks → networking.tsx, and wires it to the
Form.useWatch("team_id") in key_edit_view.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-07 10:10:44 -08:00
parent 45124c904e
commit b24dd4b1d1
8 changed files with 190 additions and 46 deletions

View file

@ -452,20 +452,33 @@ if MCP_AVAILABLE:
dependencies=[Depends(user_api_key_auth)],
)
async def get_mcp_access_groups(
team_id: Optional[str] = Query(
None,
description="When provided, return only access groups the team is permitted to use.",
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get MCP access groups available to the user. Non-admins only see groups
they have access to via their teams; admins see all groups.
Get MCP access groups available to the user. When team_id is provided, returns only
the access groups the team is configured to use. Non-admins without a team_id only
see groups accessible via their own teams.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy.management_helpers.object_permission_utils import (
get_allowed_mcp_access_groups_for_user,
get_team_mcp_permissions,
)
from litellm.proxy.proxy_server import prisma_client
# When a specific team is requested, return only that team's configured groups
if team_id is not None:
team_perms = await get_team_mcp_permissions(team_id, prisma_client)
if team_perms is not None:
return {"access_groups": sorted(team_perms["mcp_access_groups"])}
# Team has no restrictions — fall through to return all groups below
access_groups = set()
# Get from config-loaded servers
@ -569,6 +582,10 @@ if MCP_AVAILABLE:
response_model=List[LiteLLM_MCPServerTable],
)
async def fetch_all_mcp_servers(
team_id: Optional[str] = Query(
None,
description="When provided, return only MCP servers the team is permitted to use.",
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -578,6 +595,29 @@ if MCP_AVAILABLE:
--header 'Authorization: Bearer your_api_key_here'
```
"""
from litellm.proxy.proxy_server import prisma_client
# When a specific team is requested, filter to that team's allowed servers
if team_id is not None:
from litellm.proxy.management_helpers.object_permission_utils import (
get_team_mcp_permissions,
)
team_perms = await get_team_mcp_permissions(team_id, prisma_client)
if team_perms is not None:
# Team has explicit restrictions — return only its allowed servers
allowed_ids = set(team_perms["mcp_servers"])
allow_all_ids = set(global_mcp_server_manager.get_allow_all_keys_server_ids())
all_servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered()
filtered = [
s for s in all_servers
if s.server_id in allowed_ids or s.server_id in allow_all_ids
]
return _redact_mcp_credentials_list(filtered)
else:
# Team has no MCP restrictions — return all servers
all_servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered()
return _redact_mcp_credentials_list(all_servers)
user_mcp_management_mode = _get_user_mcp_management_mode()
is_restricted_virtual_key = _is_restricted_virtual_key_request(

View file

@ -240,6 +240,71 @@ async def get_allowed_mcp_access_groups_for_user(
return allowed_groups
async def get_team_mcp_permissions(
team_id: str,
prisma_client: Optional[PrismaClient],
) -> Optional[Dict[str, Any]]:
"""
Return the MCP servers and access groups that a team is explicitly permitted to use.
Returns a dict:
{
"mcp_servers": List[str], # expanded server IDs (direct + access-group-resolved + tool perm keys)
"mcp_access_groups": List[str], # team's raw access group names
}
Returns None when the team has no object_permission (meaning no restrictions configured).
Callers treat None as "no restriction" (show/allow all servers).
"""
try:
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
except ImportError:
return None
if prisma_client is None or user_api_key_cache is None:
return None
try:
team_obj = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except HTTPException:
return None
if team_obj is None or team_obj.object_permission is None:
return None
obj_perm = team_obj.object_permission
server_ids: List[str] = list(obj_perm.mcp_servers or [])
if obj_perm.mcp_access_groups:
try:
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
resolved = await MCPRequestHandler._get_mcp_servers_from_access_groups(
obj_perm.mcp_access_groups
)
server_ids.extend(resolved)
except Exception as e:
verbose_proxy_logger.warning(
f"get_team_mcp_permissions: failed to resolve access groups: {e}"
)
if obj_perm.mcp_tool_permissions:
server_ids.extend(obj_perm.mcp_tool_permissions.keys())
return {
"mcp_servers": list(set(server_ids)),
"mcp_access_groups": list(obj_perm.mcp_access_groups or []),
}
async def validate_key_mcp_servers_against_team(
object_permission: Optional[Union[Dict, Any]],
team_obj: Optional[LiteLLM_TeamTableCachedObj],
@ -247,11 +312,14 @@ async def validate_key_mcp_servers_against_team(
"""
Validate that a key's requested MCP servers/access groups are allowed by its team.
Mirrors the runtime intersection logic: only restricts when the team has restrictions
configured (non-empty). allow_all_keys servers always pass validation.
Security model:
- Team has explicit MCP config key must be a subset of team's servers/groups
- Team has no MCP config (object_permission is None) only allow_all_keys servers pass;
all other MCP server assignments are denied (deny-by-default)
- allow_all_keys servers always pass regardless of team config
Raises HTTPException(403) if the key requests MCP servers or access groups that
the team does not allow.
Raises HTTPException(403) if the key requests MCP servers or access groups the team
does not allow.
"""
if object_permission is None or team_obj is None:
return
@ -267,16 +335,49 @@ async def validate_key_mcp_servers_against_team(
if not key_mcp_servers and not key_mcp_access_groups:
return
# Get allow_all_keys server IDs - these bypass all per-key restrictions
allow_all_server_ids: Set[str] = set()
try:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
allow_all_server_ids = set(global_mcp_server_manager.get_allow_all_keys_server_ids())
except Exception as e:
verbose_proxy_logger.warning(
f"validate_key_mcp_servers_against_team: failed to get allow_all_keys servers: {e}"
)
team_object_permission = team_obj.object_permission
if team_object_permission is None:
# Team has no MCP config - no restriction
# Team has no MCP config: deny-by-default — only allow_all_keys servers are permitted
disallowed = [s for s in key_mcp_servers if s not in allow_all_server_ids]
if disallowed:
raise HTTPException(
status_code=403,
detail={
"error": (
f"MCP servers not allowed: {disallowed}. "
"The key's team has no MCP servers configured. "
"Ask an admin to add MCP servers to the team."
)
},
)
if key_mcp_access_groups:
raise HTTPException(
status_code=403,
detail={
"error": (
f"MCP access groups not allowed: {key_mcp_access_groups}. "
"The key's team has no MCP access groups configured. "
"Ask an admin to add MCP access groups to the team."
)
},
)
return
# Build the team's allowed server set from direct servers + access group resolution + tool permissions
team_allowed_servers: List[str] = []
if team_object_permission.mcp_servers:
team_allowed_servers.extend(team_object_permission.mcp_servers)
# Team has MCP config — build its allowed server set
team_allowed_servers: List[str] = list(team_object_permission.mcp_servers or [])
if team_object_permission.mcp_access_groups:
try:
@ -296,21 +397,8 @@ async def validate_key_mcp_servers_against_team(
if team_object_permission.mcp_tool_permissions:
team_allowed_servers.extend(team_object_permission.mcp_tool_permissions.keys())
# Get allow_all_keys server IDs - these bypass per-key restrictions
allow_all_server_ids: set = set()
try:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
allow_all_server_ids = set(global_mcp_server_manager.get_allow_all_keys_server_ids())
except Exception as e:
verbose_proxy_logger.warning(
f"validate_key_mcp_servers_against_team: failed to get allow_all_keys servers: {e}"
)
# Validate key's mcp_servers only when the team has server restrictions configured
if key_mcp_servers and team_allowed_servers:
# Validate mcp_servers
if key_mcp_servers:
team_allowed_set = set(team_allowed_servers)
disallowed = [
s for s in key_mcp_servers
@ -327,9 +415,9 @@ async def validate_key_mcp_servers_against_team(
},
)
# Validate key's mcp_access_groups only when the team has access group restrictions configured
if key_mcp_access_groups and team_object_permission.mcp_access_groups:
team_access_group_set = set(team_object_permission.mcp_access_groups)
# Validate mcp_access_groups
if key_mcp_access_groups:
team_access_group_set = set(team_object_permission.mcp_access_groups or [])
disallowed_groups = [
g for g in key_mcp_access_groups if g not in team_access_group_set
]

View file

@ -6515,8 +6515,10 @@ async def test_mcp_validation_key_creation_allows_permitted_server():
@pytest.mark.asyncio
async def test_mcp_validation_no_restriction_when_team_has_no_mcp_config():
"""When the team has no object_permission, any MCP server is allowed."""
async def test_mcp_validation_deny_by_default_when_team_has_no_mcp_config():
"""When team has no object_permission, non-allow_all_keys servers are denied (deny-by-default)."""
from fastapi import HTTPException
from litellm.proxy.management_helpers.object_permission_utils import (
validate_key_mcp_servers_against_team,
)
@ -6524,8 +6526,11 @@ async def test_mcp_validation_no_restriction_when_team_has_no_mcp_config():
team_obj = _make_team_obj_with_mcp_servers() # no object_permission
object_permission = {"mcp_servers": ["any-server"]}
# Should not raise
await validate_key_mcp_servers_against_team(object_permission, team_obj)
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(object_permission, team_obj)
assert exc_info.value.status_code == 403
assert "team has no MCP servers configured" in str(exc_info.value.detail)
@pytest.mark.asyncio

View file

@ -2,13 +2,14 @@ import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { fetchMCPAccessGroups } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const mcpAccessGroupsKeys = createQueryKeys("mcpAccessGroups");
export const useMCPAccessGroups = () => {
export const useMCPAccessGroups = (teamId?: string) => {
const { accessToken } = useAuthorized();
return useQuery<string[]>({
queryKey: mcpAccessGroupsKeys.list({}),
queryFn: async () => await fetchMCPAccessGroups(accessToken!),
queryKey: mcpAccessGroupsKeys.list({ teamId }),
queryFn: async () => await fetchMCPAccessGroups(accessToken!, teamId),
enabled: Boolean(accessToken),
});
};

View file

@ -6,11 +6,11 @@ import useAuthorized from "../useAuthorized";
const mcpServersKeys = createQueryKeys("mcpServers");
export const useMCPServers = () => {
export const useMCPServers = (teamId?: string) => {
const { accessToken } = useAuthorized();
return useQuery<MCPServer[]>({
queryKey: mcpServersKeys.list({}),
queryFn: async () => await fetchMCPServers(accessToken!),
queryKey: mcpServersKeys.list({ teamId }),
queryFn: async () => await fetchMCPServers(accessToken!, teamId),
enabled: !!accessToken,
});
};

View file

@ -13,6 +13,7 @@ interface MCPServerSelectorProps {
accessToken: string;
placeholder?: string;
disabled?: boolean;
teamId?: string;
}
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
@ -22,9 +23,10 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
accessToken,
placeholder = "Select MCP servers",
disabled = false,
teamId,
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers();
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups(teamId);
const loading = serversLoading || groupsLoading;

View file

@ -6973,10 +6973,13 @@ export const fetchDiscoverableMCPServers = async (accessToken: string) => {
}
};
export const fetchMCPServers = async (accessToken: string) => {
export const fetchMCPServers = async (accessToken: string, teamId?: string) => {
try {
// Construct base URL
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
const baseUrl = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
const url = teamId
? `${baseUrl}?${new URLSearchParams({ team_id: teamId }).toString()}`
: baseUrl;
console.log("Fetching MCP servers from:", url);
@ -7042,10 +7045,13 @@ export const fetchMCPServerHealth = async (accessToken: string, serverIds?: stri
}
};
export const fetchMCPAccessGroups = async (accessToken: string) => {
export const fetchMCPAccessGroups = async (accessToken: string, teamId?: string) => {
try {
// Construct base URL
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/access_groups` : `/v1/mcp/access_groups`;
const baseUrl = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/access_groups` : `/v1/mcp/access_groups`;
const url = teamId
? `${baseUrl}?${new URLSearchParams({ team_id: teamId }).toString()}`
: baseUrl;
console.log("Fetching MCP access groups from:", url);

View file

@ -85,6 +85,7 @@ export function KeyEditView({
premiumUser = false,
}: KeyEditViewProps) {
const [form] = Form.useForm();
const selectedTeamId = Form.useWatch("team_id", form) as string | undefined;
const [promptsList, setPromptsList] = useState<string[]>([]);
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
const team = teams?.find((team) => team.team_id === keyData.team_id);
@ -567,6 +568,7 @@ export function KeyEditView({
value={form.getFieldValue("mcp_servers_and_groups")}
accessToken={accessToken || ""}
placeholder="Select MCP servers or access groups (optional)"
teamId={selectedTeamId}
/>
</Form.Item>