feat(ui): scope the MCP server selector to the team, user and access groups it edits (#46565)

GET /v1/mcp/server takes an optional user_id (admin, or the user themselves) and returns the union of the servers each of that user's teams reaches plus allow_all_keys servers. A user in no team gets the unscoped list, a deleted team still listed on the user is skipped, an unknown user is a 404 and a restricted virtual key is refused like for team_id. team_id keeps precedence when both are passed.

The dashboard MCPServerSelector passes the team and user it edits (key edit sends keyData.team_id and user_id, key create sends the picked team and owner, user edit sends the user) and narrows the server options to members of the access groups selected in the same control, keeping allow_all_keys servers and servers already granted so they can still be removed.

Resolves LIT-7784

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-15 15:16:05 -07:00 • committed by GitHub
parent 92fb55522a
commit ba3fce2bda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 601 additions and 32 deletions

View file

@ -42855,6 +42855,24 @@
"title": "Team Id"
}
},
{
"description": "Filter MCP servers by internal user scope. When provided (and team_id is not), returns the union of the servers each of the user's teams has access to plus globally available (allow_all_keys) servers. A user who belongs to no team gets the unscoped list. Used by the Internal User and Virtual Key edit UIs.",
"in": "query",
"name": "user_id",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Filter MCP servers by internal user scope. When provided (and team_id is not), returns the union of the servers each of the user's teams has access to plus globally available (allow_all_keys) servers. A user who belongs to no team gets the unscoped list. Used by the Internal User and Virtual Key edit UIs.",
"title": "User Id"
}
},
{
"description": "Annotate each returned server with connected_app_reachable: whether a connected app authorized by the calling user (a gateway OAuth session) is served this server on the aggregate MCP endpoint.",
"in": "query",

View file

@ -23,6 +23,7 @@ from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from itertools import chain
from typing import (
TYPE_CHECKING,
Annotated,
@ -209,6 +210,7 @@ if MCP_AVAILABLE:
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
LiteLLM_ObjectPermissionTable,
LitellmUserRoles,
MakeMCPServersPublicRequest,
MCPApprovalStatus,
@ -1207,6 +1209,43 @@ if MCP_AVAILABLE:
return "view_all"
return "restricted"
def _redacted_server_tables(server_ids: Iterable[str]) -> list[LiteLLM_MCPServerTable]:
registered: Final = (global_mcp_server_manager.get_mcp_server_by_id(server_id) for server_id in server_ids)
tables: Final = [
global_mcp_server_manager.build_mcp_server_table(server) for server in registered if server is not None
]
return _redact_mcp_credentials_list(tables)
async def _granted_mcp_server_ids(
object_permission_id: str | None,
loaded: LiteLLM_ObjectPermissionTable | None,
) -> frozenset[str]:
"""Server ids an object_permission row grants directly, through access groups, tool permissions or
toolsets, resolved by the same resolvers runtime MCP auth uses."""
from litellm.proxy.auth.auth_checks import get_object_permission
from litellm.proxy.management_helpers.object_permission_utils import (
_resolve_team_allowed_mcp_servers, # pyright: ignore[reportPrivateUsage] # same resolver runtime MCP auth calls
)
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
if object_permission_id is None:
return frozenset()
object_permission: Final = loaded or await get_object_permission(
object_permission_id=object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
if object_permission is None:
return frozenset()
toolset_grants: Final = await global_mcp_server_manager.resolve_toolset_tool_permissions(
toolset_ids=list(object_permission.mcp_toolsets or [])
)
return frozenset(
await _resolve_team_allowed_mcp_servers(
team_object_permission=object_permission, prisma_client=prisma_client
)
) | frozenset(toolset_grants)
@catalog_operation(lambda: global_mcp_server_manager)
async def _get_team_scoped_mcp_server_list(
team_id: str,
@ -1236,15 +1275,51 @@ if MCP_AVAILABLE:
if not all_allowed_ids:
return []
# Collect servers from registry
servers: Final[list[LiteLLM_MCPServerTable]] = []
for server_id in all_allowed_ids:
server = global_mcp_server_manager.get_mcp_server_by_id(server_id)
if server is not None:
mcp_server_table = global_mcp_server_manager.build_mcp_server_table(server)
servers.append(mcp_server_table)
return _redacted_server_tables(all_allowed_ids)
return _redact_mcp_credentials_list(servers)
@catalog_operation(lambda: global_mcp_server_manager)
async def _get_user_scoped_mcp_server_list(
user_id: str,
) -> Sequence[LiteLLM_MCPServerTable] | None:
"""
Return MCP servers scoped to an internal user: the union of the team-scoped
lists of every team the user belongs to plus the servers the user's own
object_permission grants. Used by the Internal User and
Virtual Key edit UIs to populate the MCP server dropdown. ``None`` when the
user is in no team, so the caller falls back to the unscoped list.
"""
from litellm.proxy.auth.auth_checks import TeamNotFoundError, get_user_object
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
try:
user_obj: Final = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
check_db_only=True,
)
except UserNotFoundError as e:
raise HTTPException(status_code=404, detail={"error": str(e)}) from e
if user_obj is None or not user_obj.teams:
return None
async def _team_servers(team_id: str) -> Sequence[LiteLLM_MCPServerTable] | None:
try:
return await _get_team_scoped_mcp_server_list(team_id)
except TeamNotFoundError:
return None
per_team: Final = [await _team_servers(team_id) for team_id in user_obj.teams]
existing_teams: Final = [servers for servers in per_team if servers is not None]
if not existing_teams:
return None
own_grants: Final = _redacted_server_tables(
await _granted_mcp_server_ids(user_obj.object_permission_id, user_obj.object_permission)
)
by_id: Final = {server.server_id: server for server in chain.from_iterable([*existing_teams, own_grants])}
return list(by_id.values())
@catalog_operation(lambda: global_mcp_server_manager)
async def _resolve_accessible_mcp_servers(
@ -1279,6 +1354,28 @@ if MCP_AVAILABLE:
return frozenset()
return frozenset(await global_mcp_server_manager.get_allowed_mcp_servers(admitted))
async def _user_scoped_servers_for_request(
user_api_key_dict: UserAPIKeyAuth,
user_id: str | None,
) -> Sequence[LiteLLM_MCPServerTable] | None:
"""The user-scoped list for ``GET /v1/mcp/server?user_id=``, or ``None`` when no user
scope applies (no user_id, or the user belongs to no team). Only admins may scope to
another user, and restricted virtual keys may not use the filter at all."""
if not isinstance(user_id, str) or not user_id.strip():
return None
if _is_restricted_virtual_key_request(user_api_key_dict):
raise HTTPException(
status_code=403,
detail="Restricted virtual keys cannot query user-scoped MCP servers.",
)
sanitized_user_id: Final = user_id.strip()
if not user_api_key_has_admin_view(user_api_key_dict) and sanitized_user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=403,
detail="You do not have permission to view MCP servers for this user.",
)
return await _get_user_scoped_mcp_server_list(sanitized_user_id)
@router.get(
"/server",
description="Returns the mcp server list with associated teams",
@ -1294,6 +1391,13 @@ if MCP_AVAILABLE:
"servers the team has access to plus globally available (allow_all_keys) servers. "
"Used by the Create Key UI to show team-scoped MCP servers.",
),
user_id: str | None = Query(
None,
description="Filter MCP servers by internal user scope. When provided (and team_id is "
"not), returns the union of the servers each of the user's teams has access to plus "
"globally available (allow_all_keys) servers. A user who belongs to no team gets the "
"unscoped list. Used by the Internal User and Virtual Key edit UIs.",
),
connected_app_view: bool = Query(
False,
description="Annotate each returned server with connected_app_reachable: whether a "
@ -1310,6 +1414,10 @@ if MCP_AVAILABLE:
# Filter by team scope (for Create Key UI)
curl --location 'http://localhost:4000/v1/mcp/server?team_id=team-123' \
--header 'Authorization: Bearer your_api_key_here'
# Filter by internal user scope (for User / Key edit UI)
curl --location 'http://localhost:4000/v1/mcp/server?user_id=user-123' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
@ -1355,8 +1463,13 @@ if MCP_AVAILABLE:
await _get_team_scoped_mcp_server_list(sanitized_team_id), key=_mcp_server_display_order
)
else:
servers: Final = await _resolve_accessible_mcp_servers(user_api_key_dict)
redacted_mcp_servers = sorted(_redact_mcp_credentials_list(servers), key=_mcp_server_display_order)
user_scoped_servers: Final = await _user_scoped_servers_for_request(user_api_key_dict, user_id)
servers: Final = (
user_scoped_servers
if user_scoped_servers is not None
else _redact_mcp_credentials_list(await _resolve_accessible_mcp_servers(user_api_key_dict))
)
redacted_mcp_servers = sorted(servers, key=_mcp_server_display_order)
if connected_app_view is True and is_ui_session_credential(user_api_key_dict):
reachable_ids: Final = await _connected_app_reachable_server_ids(user_api_key_dict)
@ -1366,14 +1479,14 @@ if MCP_AVAILABLE:
# Annotate has_user_credential for BYOK servers (single batched query)
from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client
user_id: Final = user_api_key_dict.user_id or ""
if user_id and _byok_prisma_client is not None:
caller_user_id: Final = user_api_key_dict.user_id or ""
if caller_user_id and _byok_prisma_client is not None:
byok_server_ids: Final = [s.server_id for s in redacted_mcp_servers if getattr(s, "is_byok", False)]
if byok_server_ids:
cred_rows: Final[
Sequence[prisma_models.LiteLLM_MCPUserCredentials]
] = await MCPUserCredentialsRepository(_byok_prisma_client).table.find_many(
where={"user_id": user_id, "server_id": {"in": byok_server_ids}}
where={"user_id": caller_user_id, "server_id": {"in": byok_server_ids}}
)
cred_set: Final = {r.server_id for r in cred_rows}
for server in redacted_mcp_servers:

View file

@ -1800,6 +1800,203 @@ class TestTeamScopedMCPServerAccess:
assert len(result) == 1
class TestUserScopedMCPServerAccess:
"""``GET /v1/mcp/server?user_id=`` scopes the dropdown to the teams the user belongs to."""
_MODULE: Final = "litellm.proxy.management_endpoints.mcp_management_endpoints"
@staticmethod
def _user_with_teams(teams: list[str]) -> LiteLLM_UserTable:
return LiteLLM_UserTable(user_id="scoped_user", teams=teams)
@staticmethod
def _team_scoped(per_team: Mapping[str, list[str]]):
async def _list(team_id: str) -> list[LiteLLM_MCPServerTable]:
from litellm.proxy.auth.auth_checks import TeamNotFoundError
if team_id not in per_team:
raise TeamNotFoundError(team_id=team_id)
return [generate_mock_mcp_server_db_record(server_id=server_id) for server_id in per_team[team_id]]
return AsyncMock(side_effect=_list)
@pytest.mark.asyncio
async def test_admin_gets_the_union_of_the_users_teams(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
per_team: Final = {"team-a": ["server-1", "server-2"], "team-b": ["server-2", "server-3"]}
with (
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
AsyncMock(return_value=self._user_with_teams(["team-a", "team-b"])),
),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped(per_team)),
patch(f"{self._MODULE}._resolve_accessible_mcp_servers", AsyncMock()) as unscoped,
):
result: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="scoped_user")
assert sorted(server.server_id for server in result) == ["server-1", "server-2", "server-3"]
unscoped.assert_not_awaited()
@pytest.mark.asyncio
async def test_the_users_own_grants_stay_listed_next_to_their_teams_servers(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
own_grant: Final = LiteLLM_ObjectPermissionTable(object_permission_id="op-1", mcp_servers=["solo-d"])
user: Final = LiteLLM_UserTable(
user_id="scoped_user", teams=["team-a"], object_permission_id="op-1", object_permission=own_grant
)
registry: Final = MagicMock()
registry.get_mcp_server_by_id = MagicMock(
side_effect=lambda server_id: server_id if server_id == "solo-d" else None
)
registry.build_mcp_server_table = MagicMock(
side_effect=lambda server_id: generate_mock_mcp_server_db_record(server_id=server_id)
)
registry.resolve_toolset_tool_permissions = AsyncMock(return_value={})
resolver: Final = AsyncMock(return_value={"solo-d"})
with (
patch("litellm.proxy.auth.auth_checks.get_user_object", AsyncMock(return_value=user)),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped({"team-a": ["server-1"]})),
patch("litellm.proxy.management_helpers.object_permission_utils._resolve_team_allowed_mcp_servers", resolver),
patch(f"{self._MODULE}.global_mcp_server_manager", registry),
):
result: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="scoped_user")
assert sorted(server.server_id for server in result) == ["server-1", "solo-d"]
assert resolver.await_args.kwargs["team_object_permission"] is own_grant
@pytest.mark.asyncio
async def test_a_server_the_user_reaches_only_through_an_own_toolset_stays_listed(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
own_grant: Final = LiteLLM_ObjectPermissionTable(object_permission_id="op-1", mcp_toolsets=["ts-1"])
user: Final = LiteLLM_UserTable(
user_id="scoped_user", teams=["team-a"], object_permission_id="op-1", object_permission=own_grant
)
registry: Final = MagicMock()
registry.get_mcp_server_by_id = MagicMock(
side_effect=lambda server_id: server_id if server_id == "toolset-only" else None
)
registry.build_mcp_server_table = MagicMock(
side_effect=lambda server_id: generate_mock_mcp_server_db_record(server_id=server_id)
)
registry.resolve_toolset_tool_permissions = AsyncMock(return_value={"toolset-only": ["search"]})
with (
patch("litellm.proxy.auth.auth_checks.get_user_object", AsyncMock(return_value=user)),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped({"team-a": ["server-1"]})),
patch(
"litellm.proxy.management_helpers.object_permission_utils._resolve_team_allowed_mcp_servers",
AsyncMock(return_value=set()),
),
patch(f"{self._MODULE}.global_mcp_server_manager", registry),
):
result: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="scoped_user")
assert sorted(server.server_id for server in result) == ["server-1", "toolset-only"]
registry.resolve_toolset_tool_permissions.assert_awaited_once_with(toolset_ids=["ts-1"])
@pytest.mark.asyncio
async def test_a_team_the_user_still_lists_but_that_was_deleted_is_skipped(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
with (
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
AsyncMock(return_value=self._user_with_teams(["team-a", "team-deleted"])),
),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped({"team-a": ["server-1"]})),
):
result: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="scoped_user")
assert [server.server_id for server in result] == ["server-1"]
@pytest.mark.asyncio
async def test_user_whose_only_teams_were_deleted_gets_the_unscoped_list(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
everything: Final = [generate_mock_mcp_server_db_record(server_id="server-9")]
with (
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
AsyncMock(return_value=self._user_with_teams(["team-deleted"])),
),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped({})),
patch(f"{self._MODULE}._resolve_accessible_mcp_servers", AsyncMock(return_value=everything)),
):
result: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="scoped_user")
assert [server.server_id for server in result] == ["server-9"]
@pytest.mark.asyncio
async def test_user_in_no_team_gets_the_unscoped_list(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
everything: Final = [generate_mock_mcp_server_db_record(server_id="server-9")]
with (
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
AsyncMock(return_value=self._user_with_teams([])),
),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped({})) as team_scoped,
patch(f"{self._MODULE}._resolve_accessible_mcp_servers", AsyncMock(return_value=everything)),
):
result: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="scoped_user")
assert [server.server_id for server in result] == ["server-9"]
team_scoped.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_admin_may_only_scope_to_themselves(self):
member: Final = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="scoped_user"
)
with (
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
AsyncMock(return_value=self._user_with_teams(["team-a"])),
),
patch(f"{self._MODULE}._get_team_scoped_mcp_server_list", self._team_scoped({"team-a": ["server-1"]})),
):
own: Final = await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=member, user_id="scoped_user")
with pytest.raises(HTTPException) as exc_info:
await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=member, user_id="someone_else")
assert [server.server_id for server in own] == ["server-1"]
assert exc_info.value.status_code == 403
assert "permission" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_restricted_virtual_key_cannot_use_user_id_filter(self):
restricted: Final = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="scoped_user",
api_key="sk-restricted",
allowed_routes=["mcp_routes"],
)
with pytest.raises(HTTPException) as exc_info:
await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=restricted, user_id="scoped_user")
assert exc_info.value.status_code == 403
assert "Restricted virtual key" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_unknown_user_is_a_404(self):
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user")
with patch(
"litellm.proxy.auth.auth_checks.get_user_object",
AsyncMock(side_effect=UserNotFoundError(user_id="ghost")),
):
with pytest.raises(HTTPException) as exc_info:
await mgmt_endpoints.fetch_all_mcp_servers(user_api_key_dict=admin, user_id="ghost")
assert exc_info.value.status_code == 404
class TestFetchAllMCPServersOrdering:
def test_display_order_is_case_insensitive_name_then_id(self) -> None:
servers: Final = (

View file

@ -74,7 +74,7 @@ describe("useMCPServers", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken, undefined);
expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken, undefined, undefined, undefined);
expect(result.current.data).toEqual(mockServers);
});
@ -120,6 +120,18 @@ describe("useMCPServers", () => {
expect(result.current.data).toBeUndefined();
});
it("forwards the user scope to the server list call", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue(mockServers);
const { result } = renderHook(() => useMCPServers(null, "user-7"), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken, null, undefined, "user-7");
});
it("should return empty array when API returns empty list", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);

View file

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

View file

@ -501,7 +501,7 @@ describe("MCPServers", () => {
// Verify the API was called
// Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock
expect(networking.fetchMCPServers).toHaveBeenCalledWith("123", undefined);
expect(networking.fetchMCPServers).toHaveBeenCalledWith("123", undefined, undefined, undefined);
});
it("should fetch and merge health status for servers", async () => {

View file

@ -738,6 +738,13 @@ describe("UserEditView", () => {
expect(form).not.toHaveAttribute("novalidate");
});
it("asks for the MCP servers scoped to the edited user", async () => {
renderWithProviders(<UserEditView {...defaultProps} />);
await waitFor(() => {
expect(networking.fetchMCPServers).toHaveBeenCalledWith(expect.anything(), undefined, undefined, "user-123");
});
});
it("shows the tool matrix for servers the user reaches only through an access group or toolset", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: "srv-group", server_name: "Group Server", alias: "Group Server", mcp_access_groups: ["group-a"] },

View file

@ -403,6 +403,7 @@ export function UserEditView({
onChange={onChange}
value={value}
accessToken={accessToken || ""}
userId={isBulkEdit ? null : userData.user_id}
placeholder="Select MCP servers or access groups (optional)"
/>
)}

View file

@ -170,3 +170,77 @@ describe("MCPServerSelector all-proxy-mcpservers option", () => {
expect(optionByLabel("Server One")).toHaveAttribute("aria-disabled", "true");
});
});
describe("MCPServerSelector scoping", () => {
const catalog = [
{ server_id: "srv-a", server_name: "Wiki A", mcp_access_groups: ["g1"] },
{ server_id: "srv-b", server_name: "Wiki B", mcp_access_groups: ["g1", "g2"] },
{ server_id: "srv-c", server_name: "Tools C", mcp_access_groups: ["g2"] },
{ server_id: "srv-d", server_name: "Solo D" },
{ server_id: "srv-e", server_name: "Public E", allow_all_keys: true },
];
beforeEach(() => {
vi.clearAllMocks();
mockUseMCPServers.mockReturnValue({ data: catalog, isLoading: false } as any);
mockUseMCPAccessGroups.mockReturnValue({ data: ["g1", "g2"], isLoading: false } as any);
mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any);
});
it("asks the server list for the team and user scope it was given", () => {
renderWithProviders(
<MCPServerSelector
accessToken="tok"
onChange={vi.fn()}
value={{ servers: [], accessGroups: [] }}
teamId="team-1"
userId="user-1"
/>,
);
expect(mockUseMCPServers).toHaveBeenCalledWith("team-1", "user-1");
});
it("lists every server while no access group is selected", async () => {
const user = userEvent.setup();
renderWithProviders(
<MCPServerSelector accessToken="tok" onChange={vi.fn()} value={{ servers: [], accessGroups: [] }} />,
);
await openSelector(user);
expect(["Wiki A", "Wiki B", "Tools C", "Solo D", "Public E"].map(optionByLabel).every(Boolean)).toBe(true);
});
it("narrows the servers to members of the selected access groups, keeping allow-all and granted ones", async () => {
const user = userEvent.setup();
renderWithProviders(
<MCPServerSelector accessToken="tok" onChange={vi.fn()} value={{ servers: ["srv-d"], accessGroups: ["g1"] }} />,
);
await openSelector(user);
expect(optionByLabel("Wiki A")).toBeDefined();
expect(optionByLabel("Wiki B")).toBeDefined();
expect(optionByLabel("Public E")).toBeDefined();
expect(optionByLabel("Solo D")).toBeDefined();
expect(optionByLabel("Tools C")).toBeUndefined();
});
it("widens again once the access group is deselected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const { rerender } = renderWithProviders(
<MCPServerSelector accessToken="tok" onChange={onChange} value={{ servers: [], accessGroups: ["g2"] }} />,
);
await openSelector(user);
expect(optionByLabel("Wiki A")).toBeUndefined();
await user.keyboard("{Escape}");
rerender(<MCPServerSelector accessToken="tok" onChange={onChange} value={{ servers: [], accessGroups: [] }} />);
await openSelector(user);
expect(optionByLabel("Wiki A")).toBeDefined();
});
});

View file

@ -4,6 +4,8 @@ import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolset
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import React from "react";
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants";
import type { MCPServer } from "@/components/mcp_tools/types";
import { accessGroupNamesOf } from "./effectiveMcpServers";
interface MCPServerSelectorProps {
onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
@ -17,12 +19,27 @@ interface MCPServerSelectorProps {
placeholder?: string;
disabled?: boolean;
teamId?: string | null;
userId?: string | null;
allowNoMcpServers?: boolean;
allowAllProxyMcpServers?: boolean;
}
const TOOLSET_PREFIX = "toolset:";
export const serversWithinAccessGroups = (
servers: readonly MCPServer[],
selectedAccessGroups: readonly string[],
selectedServers: readonly string[],
): readonly MCPServer[] => {
if (selectedAccessGroups.length === 0) return servers;
return servers.filter(
(server) =>
server.allow_all_keys === true ||
selectedServers.includes(server.server_id) ||
accessGroupNamesOf(server).some((group) => selectedAccessGroups.includes(group)),
);
};
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
onChange,
value,
@ -31,16 +48,18 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
placeholder = "Select MCP servers",
disabled = false,
teamId,
userId,
allowNoMcpServers = false,
allowAllProxyMcpServers = false,
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId, userId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
const { data: toolsets = [], isLoading: toolsetsLoading } = useMCPToolsets();
const loading = serversLoading || groupsLoading || toolsetsLoading;
const accessGroupSet = new Set(accessGroups);
const visibleServers = serversWithinAccessGroups(mcpServers, value?.accessGroups ?? [], value?.servers ?? []);
// Combine options: access groups + servers + toolsets
const options = [
@ -49,7 +68,7 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
value: group,
description: "Access Group",
})),
...mcpServers.map((server) => ({
...visibleServers.map((server) => ({
label: `${server.server_name || server.server_id} (${server.server_id})`,
value: server.server_id,
description: "MCP Server",

View file

@ -83,7 +83,7 @@ describe("MCPToolPermissions", () => {
// Verify API calls
// Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock
expect(networking.fetchMCPServers).toHaveBeenCalledWith("123", undefined);
expect(networking.fetchMCPServers).toHaveBeenCalledWith("123", undefined, undefined, undefined);
// listMCPTools uses the accessToken prop directly
expect(networking.listMCPTools).toHaveBeenCalledWith(mockAccessToken, mockServerId);
});

View file

@ -48,7 +48,7 @@ interface ResolveInput {
// Access groups come back as plain names, but older records carry `{ name }` objects.
const accessGroupRefSchema = z.union([z.string(), z.object({ name: z.string() })]);
const accessGroupNamesOf = (server: MCPServer): readonly string[] =>
export const accessGroupNamesOf = (server: MCPServer): readonly string[] =>
(server.mcp_access_groups ?? []).flatMap((group) => {
const parsed = accessGroupRefSchema.safeParse(group);
if (!parsed.success) return [];

View file

@ -4858,11 +4858,20 @@ export interface ConnectFlowStatus {
export const fetchConnectFlow = async (flowHandle: string): Promise<ConnectFlowStatus> =>
apiClient.get(`/authorize/flow`, { query: { flow: flowHandle }, credentials: "include" });
export const fetchMCPServers = async (accessToken: string, teamId?: string | null, connectedAppView?: boolean) => {
export const fetchMCPServers = async (
accessToken: string,
teamId?: string | null,
connectedAppView?: boolean,
userId?: string | null,
) => {
try {
return await apiClient.get(`/v1/mcp/server`, {
accessToken,
query: { team_id: teamId || undefined, connected_app_view: connectedAppView || undefined },
query: {
team_id: teamId || undefined,
connected_app_view: connectedAppView || undefined,
user_id: userId || undefined,
},
});
} catch (error) {
console.error("Failed to fetch MCP servers:", error);

View file

@ -294,6 +294,11 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const keyOwner = effectiveKeyOwner(requestedKeyOwner, ownerScope);
const ownerLimitedToTeamMembers = keyOwner === "another_user" && ownerScope === "team_members";
const memberOwnerOptions = teamMemberOwnerOptions(selectedCreateKeyTeam, userID);
const createKeyOwner = form.watch("user_id");
useEffect(() => {
form.setValue("allowed_mcp_servers_and_groups", { servers: [], accessGroups: [] });
form.setValue("mcp_tool_permissions", {});
}, [createKeyOwner, form]);
const ownerRequiresTeam = keyOwner === "service_account" || ownerLimitedToTeamMembers;
const handleCancel = () => {
setIsModalVisible(false);
@ -527,6 +532,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
}
// Clear MCP server selection when team changes (available servers may differ)
form.setValue("allowed_mcp_servers_and_groups", { servers: [], accessGroups: [] });
form.setValue("mcp_tool_permissions", {});
}, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole, form]);
// Apply deferred model prefill once the available model list arrives.
@ -1615,6 +1621,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
value={control.value as McpSelectorValue | undefined}
accessToken={accessToken}
teamId={selectedCreateKeyTeam?.team_id ?? null}
userId={typeof createKeyOwner === "string" ? createKeyOwner : null}
placeholder="Select MCP servers or access groups (optional)"
allowNoMcpServers
/>

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { UseFormSetValue } from "react-hook-form";
import { KeyResponse } from "../key_team_helpers/key_list";
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
@ -219,3 +220,8 @@ export const toSubmittedValues = (
auto_rotate: values.auto_rotate,
rotation_interval: values.rotation_interval,
});
export const clearMcpGrants = (setValue: UseFormSetValue<KeyEditFormValues>) => {
setValue("mcp_servers_and_groups", { servers: [], accessGroups: [], toolsets: [] });
setValue("mcp_tool_permissions", {});
};

View file

@ -5,6 +5,7 @@ import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../
import { KeyResponse } from "../key_team_helpers/key_list";
import { MODEL_MAX_BUDGET_PREMIUM_HINT } from "../key_team_helpers/ModelMaxBudgetEditor";
import {
fetchMCPServers,
getPassThroughEndpointsCall,
getPoliciesList,
getUiSettings,
@ -125,13 +126,19 @@ vi.mock("../mcp_server_management/MCPServerSelector", () => ({
default: ({
value,
onChange,
teamId,
userId,
}: {
value?: { servers?: string[]; accessGroups?: string[]; toolsets?: string[] };
onChange?: (v: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
teamId?: string | null;
userId?: string | null;
}) => (
<button
type="button"
data-testid="mcp-server-selector"
data-team-id={teamId ?? ""}
data-user-id={userId ?? ""}
onClick={() => onChange?.({ servers: ["mcp-1"], accessGroups: [], toolsets: value?.toolsets ?? [] })}
>
pick mcp server
@ -1513,6 +1520,101 @@ describe("KeyEditView", () => {
});
});
it("scopes the MCP server selector to the key's team and owner", async () => {
renderWithProviders(
<KeyEditView
keyData={{ ...MOCK_KEY_DATA, team_id: "group-maple", user_id: "owner-7" }}
onCancel={() => {}}
onSubmit={vi.fn()}
accessToken=""
userID=""
userRole="Admin"
premiumUser={false}
/>,
);
const selector = await screen.findByTestId("mcp-server-selector");
expect(selector).toHaveAttribute("data-team-id", "group-maple");
expect(selector).toHaveAttribute("data-user-id", "owner-7");
});
it("keeps a saved MCP grant editable when it falls outside the key's team scope", async () => {
const server = (server_id: string, server_name: string) => ({
server_id,
server_name,
url: `https://${server_id}.example`,
transport: "http",
auth_type: "none",
created_at: "",
created_by: "",
updated_at: "",
updated_by: "",
});
vi.mocked(fetchMCPServers).mockImplementation(async (_token, teamId) =>
teamId ? [server("srv-a", "team-wiki")] : [server("srv-a", "team-wiki"), server("srv-b", "direct-grant")],
);
testQueryClient.clear();
renderWithProviders(
<KeyEditView
keyData={{
...MOCK_KEY_DATA,
team_id: "group-maple",
object_permission: { mcp_servers: ["srv-b"], mcp_tool_permissions: { "srv-b": ["read"] } },
}}
onCancel={() => {}}
onSubmit={vi.fn()}
accessToken=""
userID=""
userRole="Admin"
premiumUser={false}
/>,
);
expect(await screen.findByText("direct-grant")).toBeInTheDocument();
expect(screen.getByTestId("mcp-server-selector")).toHaveAttribute("data-team-id", "group-maple");
vi.mocked(fetchMCPServers).mockResolvedValue([]);
});
it("rescopes the MCP server selector and drops stale MCP grants when the team is changed before saving", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<KeyEditView
keyData={{
...MOCK_KEY_DATA,
organization_id: "org-1",
team_id: "group-maple",
object_permission: { mcp_servers: ["srv-a"], mcp_tool_permissions: { "srv-a": ["read"] } },
}}
onCancel={() => {}}
onSubmit={onSubmit}
accessToken=""
userID=""
userRole="Admin"
premiumUser={false}
/>,
);
const selector = await screen.findByTestId("mcp-server-selector");
expect(selector).toHaveAttribute("data-team-id", "group-maple");
await waitFor(() => {
expect(screen.getAllByRole("button", { name: "Clear" })).toHaveLength(2);
});
await userEvent.click(screen.getAllByRole("button", { name: "Clear" })[1]);
await waitFor(() => {
expect(screen.getByTestId("mcp-server-selector")).toHaveAttribute("data-team-id", "");
});
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
expect(onSubmit.mock.calls[0][0]).toMatchObject({
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
mcp_tool_permissions: {},
});
});
it("clears the organization and its dependent team in the update payload", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderWithProviders(

View file

@ -45,6 +45,7 @@ import {
McpServersAndGroups,
toKeyEditFormValues,
toSubmittedValues,
clearMcpGrants,
} from "./keyEditFormValues";
import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor";
import { END_USER_BUDGET_HINT, EndUserBudgetSelect } from "../key_team_helpers/EndUserBudgetSelect";
@ -146,9 +147,8 @@ export function KeyEditView({
const selectedModels = (form.watch("models") as string[] | undefined) ?? [];
const allowedRoutes = parseAllowedRoutes(allowedRoutesValue);
const isModelsDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes");
const mcpSelection = form.watch("mcp_servers_and_groups") as
| { servers?: string[]; accessGroups?: string[]; toolsets?: string[] }
| undefined;
const mcpSelection = form.watch("mcp_servers_and_groups") as McpServersAndGroups | undefined;
const mcpScope = { teamId: form.watch("team_id") ?? null, userId: keyData.user_id };
const mcpToolPermissions = form.watch("mcp_tool_permissions");
useEffect(() => {
@ -332,11 +332,11 @@ export function KeyEditView({
const handleTeamChange = (setField: (value: string | null) => void, teamId: string | null) => {
setField(teamId);
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
if (selectedTeam?.organization_id) {
setSelectedOrganizationId(selectedTeam.organization_id);
form.setValue("organization_id", selectedTeam.organization_id);
}
clearMcpGrants(form.setValue);
const organizationId = teams?.find((t) => t.team_id === teamId)?.organization_id;
if (!organizationId) return;
setSelectedOrganizationId(organizationId);
form.setValue("organization_id", organizationId);
};
const handleDisabledCallbacksChange = (internalValues: string[]) => {
@ -761,6 +761,7 @@ export function KeyEditView({
onChange={onChange}
value={value as McpServersAndGroups | undefined}
accessToken={accessToken || ""}
{...mcpScope}
placeholder="Select MCP servers or access groups (optional)"
allowNoMcpServers
/>

View file

@ -78689,6 +78689,8 @@ export interface operations {
query?: {
/** @description Filter MCP servers by team scope. When provided, returns only servers the team has access to plus globally available (allow_all_keys) servers. Used by the Create Key UI to show team-scoped MCP servers. */
team_id?: string | null;
/** @description Filter MCP servers by internal user scope. When provided (and team_id is not), returns the union of the servers each of the user's teams has access to plus globally available (allow_all_keys) servers. A user who belongs to no team gets the unscoped list. Used by the Internal User and Virtual Key edit UIs. */
user_id?: string | null;
/** @description Annotate each returned server with connected_app_reachable: whether a connected app authorized by the calling user (a gateway OAuth session) is served this server on the aggregate MCP endpoint. */
connected_app_view?: boolean;
};