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; }) => (