From ba3fce2bdaaafe1dbacb3dd37681ab03cb52f733 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 15 Oct 2026 15:16:05 -0700 Subject: [PATCH] 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> --- litellm/proxy/_lazy_openapi_snapshot.json | 18 ++ .../mcp_management_endpoints.py | 139 ++++++++++-- .../test_mcp_management_endpoints.py | 197 ++++++++++++++++++ .../hooks/mcpServers/useMCPServers.test.ts | 14 +- .../hooks/mcpServers/useMCPServers.ts | 7 +- .../_components/mcp_servers.test.tsx | 2 +- .../users/_components/user_edit_view.test.tsx | 7 + .../users/_components/user_edit_view.tsx | 1 + .../MCPServerSelector.test.tsx | 74 +++++++ .../MCPServerSelector.tsx | 23 +- .../MCPToolPermissions.test.tsx | 2 +- .../effectiveMcpServers.ts | 2 +- .../src/components/networking.tsx | 13 +- .../organisms/create_key_button.tsx | 7 + .../components/templates/keyEditFormValues.ts | 6 + .../key_edit_view.integration.test.tsx | 102 +++++++++ .../components/templates/key_edit_view.tsx | 17 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 18 files changed, 601 insertions(+), 32 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 9cf0b813836..20819a59a43 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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", diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f69d9db08a0..d014ff6cf07 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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: diff --git a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py index 941ecd32e14..47aea89843f 100644 --- a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -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 = ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.test.ts index 52b58f9e318..4b83740bc09 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.test.ts @@ -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([]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts index 597c5f7b2da..61a0899e013 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts @@ -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({ - 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, }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index 1583cd09910..c47822c346f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -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 () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx index d1f309c9f99..156b27672d5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx @@ -738,6 +738,13 @@ describe("UserEditView", () => { expect(form).not.toHaveAttribute("novalidate"); }); + it("asks for the MCP servers scoped to the edited user", async () => { + renderWithProviders(); + 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"] }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx index 14a0401714f..647761bb44b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx @@ -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)" /> )} diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx index 47fb5643b22..6e2c48afaab 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx @@ -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( + , + ); + + expect(mockUseMCPServers).toHaveBeenCalledWith("team-1", "user-1"); + }); + + it("lists every server while no access group is selected", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + 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( + , + ); + + 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( + , + ); + + await openSelector(user); + expect(optionByLabel("Wiki A")).toBeUndefined(); + await user.keyboard("{Escape}"); + + rerender(); + await openSelector(user); + + expect(optionByLabel("Wiki A")).toBeDefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx index 969db57462f..2d78d757aab 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -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 = ({ onChange, value, @@ -31,16 +48,18 @@ const MCPServerSelector: React.FC = ({ 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 = ({ 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", diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx index afa547abce6..3757720c699 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx @@ -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); }); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts index fd17a1725ec..34afb5974a0 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts +++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts @@ -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 []; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 2a2ce213216..ac1f7d84bcb 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4858,11 +4858,20 @@ export interface ConnectFlowStatus { export const fetchConnectFlow = async (flowHandle: string): Promise => 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); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index eb8c96a675a..a15f6c6e31c 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -294,6 +294,11 @@ const CreateKey: React.FC = ({ 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 = ({ 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 = ({ 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 /> diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts index 0ae60bbcc81..35f09011ab9 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts @@ -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) => { + setValue("mcp_servers_and_groups", { servers: [], accessGroups: [], toolsets: [] }); + setValue("mcp_tool_permissions", {}); +}; diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index e48d3a591ef..ead17fa0c95 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -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; }) => (