mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
92fb55522a
commit
ba3fce2bda
18 changed files with 601 additions and 32 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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", {});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue