From bddc76e56663da86766e2b83137e1812a59c0569 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 26 Jun 2026 11:51:57 -0700 Subject: [PATCH] feat(mcp): add all-team-mcps key sentinel and unify duplicate MCP sentinel enums Add SpecialMCPServerNames.all_team_mcp_servers ("all-team-mcps") so a key can be scoped to exactly its team's MCP servers. It resolves to the team set at request time, tracks the team as its grant changes, and fails closed to zero when the key is teamless or the team grants nothing; a teamless key carrying it is rejected at write time even for a proxy admin. The key create and edit selectors expose an All Team MCPs option only when a team is selected, mutually exclusive with the other sentinels Unify the two parallel sentinel systems by deleting the legacy SpecialMCPServerName enum (all-team-mcpservers, all-proxy-mcpservers) and routing the discovery path get_all_mcp_servers_for_user and the reserved-server-id guard through the live grant parser, so discovery agrees with the access resolver. Add regression coverage for all-team resolution, the fail-closed paths, the reserved-id guard, and the discovery expansion --- .../mcp_server/auth/user_api_key_auth_mcp.py | 24 ++-- litellm/proxy/_experimental/mcp_server/db.py | 10 +- .../mcp_server/mcp_server_manager.py | 3 + .../mcp_server/permission_grant.py | 20 +++- litellm/proxy/_types.py | 6 +- .../mcp_management_endpoints.py | 12 +- .../object_permission_utils.py | 17 ++- .../auth/test_user_api_key_auth_mcp.py | 59 ++++++++++ .../mcp_server/test_mcp_server_manager.py | 26 +++++ .../mcp_server/test_permission_grant.py | 27 ++++- .../proxy/db/mcp_server/test_db.py | 52 ++++++++- .../test_mcp_management_endpoints.py | 44 +++++++ .../test_object_permission_utils.py | 64 ++++++++++ ui/litellm-dashboard/eslint-metrics.json | 2 +- .../MCPServerSelector.test.tsx | 103 ++++++++++++++--- .../MCPServerSelector.tsx | 109 +++++++++--------- .../src/components/mcp_tools/constants.ts | 3 + .../organisms/create_key_button.tsx | 5 +- .../permissions/MCPServerPermissions.test.tsx | 22 +++- .../permissions/MCPServerPermissions.tsx | 25 +++- .../components/templates/key_edit_view.tsx | 6 +- 21 files changed, 530 insertions(+), 109 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 92ebd248af0..238b174dd4f 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -9,6 +9,7 @@ from starlette.types import Scope from litellm._logging import verbose_logger from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL from litellm.proxy._experimental.mcp_server.permission_grant import ( + AllTeamServers, NoServers, parse_mcp_server_grant, ) @@ -647,12 +648,11 @@ class MCPRequestHandler: user_api_key_auth ) ) + key_grant = parse_mcp_server_grant(allowed_mcp_servers_for_key) # The key explicitly opted out of every MCP server. This overrides # team inheritance and additive grants (mirrors no-default-models). - if isinstance( - parse_mcp_server_grant(allowed_mcp_servers_for_key), NoServers - ): + if isinstance(key_grant, NoServers): return [] allowed_mcp_servers_for_team = ( @@ -677,7 +677,11 @@ class MCPRequestHandler: has_lower_level_mcp_restrictions = bool(key_set or team_set or grants_set) # 1. Key/team ceiling. An empty set means "this level does not restrict". - if not team_set: + if isinstance(key_grant, AllTeamServers): + # The key explicitly requested its team's full grant: resolve to + # exactly the team set, failing closed when the team grants nothing. + base = team_set + elif not team_set: base = key_set # no team restriction elif not key_set: base = team_set # key has no own perms → inherits team @@ -1071,13 +1075,13 @@ class MCPRequestHandler: if key_object_permission is None: return [] - # Sentinel opt-out: surface it unexpanded so the caller can short-circuit - # to zero servers instead of inheriting the team. - if isinstance( - parse_mcp_server_grant(key_object_permission.mcp_servers or []), - NoServers, - ): + # Sentinels are surfaced unexpanded so the caller can short-circuit: + # no-mcp-servers -> zero servers, all-team-mcps -> the team's own set. + key_grant = parse_mcp_server_grant(key_object_permission.mcp_servers or []) + if isinstance(key_grant, NoServers): return [SpecialMCPServerNames.no_mcp_servers.value] + if isinstance(key_grant, AllTeamServers): + return [SpecialMCPServerNames.all_team_mcp_servers.value] # Permission entries may be server_ids OR names/aliases — expand to ids. direct_mcp_servers = global_mcp_server_manager.expand_permission_list( diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index bc1068d77d0..2571982328f 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -17,10 +17,13 @@ from litellm.proxy._types import ( MCPEnvVarScope, MCPSubmissionsSummary, NewMCPServerRequest, - SpecialMCPServerName, UpdateMCPServerRequest, UserAPIKeyAuth, ) +from litellm.proxy._experimental.mcp_server.permission_grant import ( + AllTeamServers, + parse_mcp_server_grant, +) from litellm.proxy.common_utils.encrypt_decrypt_utils import ( _get_salt_key, decrypt_value_helper, @@ -494,9 +497,10 @@ async def get_all_mcp_servers_for_user( ) mcp_server_ids.update(token_mcp_servers) - # check for special team membership + # all-team-mcps expands to the key's team servers, so discovery matches + # what the access resolver grants at request time. if ( - SpecialMCPServerName.all_team_servers in mcp_server_ids + isinstance(parse_mcp_server_grant(token_mcp_servers), AllTeamServers) and user.team_id is not None ): team_mcp_servers = await get_mcp_servers_by_team( diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index e28d0ef578a..7221e11b838 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -53,6 +53,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError from litellm.proxy._experimental.mcp_server.permission_grant import ( AllServers, + AllTeamServers, ExplicitServers, NoServers, parse_mcp_server_grant, @@ -4190,6 +4191,8 @@ class MCPServerManager: match grant: case AllServers(): return list(registry.keys()) + case AllTeamServers(): + return [SpecialMCPServerNames.all_team_mcp_servers.value] case NoServers(): return [SpecialMCPServerNames.no_mcp_servers.value] case ExplicitServers(identifiers=explicit): diff --git a/litellm/proxy/_experimental/mcp_server/permission_grant.py b/litellm/proxy/_experimental/mcp_server/permission_grant.py index 0b7778cf2a6..95c02e29af6 100644 --- a/litellm/proxy/_experimental/mcp_server/permission_grant.py +++ b/litellm/proxy/_experimental/mcp_server/permission_grant.py @@ -16,6 +16,7 @@ MCP_GRANT_SENTINELS: frozenset[str] = frozenset( { SpecialMCPServerNames.no_mcp_servers.value, SpecialMCPServerNames.all_proxy_mcp_servers.value, + SpecialMCPServerNames.all_team_mcp_servers.value, } ) @@ -25,6 +26,16 @@ class AllServers: """Grant every MCP server on the proxy, including ones added later.""" +@dataclass(frozen=True, slots=True) +class AllTeamServers: + """Grant every MCP server the holder's team can reach. + + Key-only: it is resolved against the team at request time, so it tracks the + team grant and fails closed (zero servers) when the key has no team or the + team grants nothing. + """ + + @dataclass(frozen=True, slots=True) class NoServers: """Block all MCP servers.""" @@ -37,18 +48,21 @@ class ExplicitServers: identifiers: frozenset[str] -MCPServerGrant = Union[AllServers, NoServers, ExplicitServers] +MCPServerGrant = Union[AllServers, AllTeamServers, NoServers, ExplicitServers] def parse_mcp_server_grant(raw: Iterable[str]) -> MCPServerGrant: """Resolve precedence once, most-restrictive first. - ``no-mcp-servers`` blocks everything and beats ``all-proxy-mcps``; absent any - sentinel the entries are explicit identifiers. + ``no-mcp-servers`` blocks everything; ``all-team-mcps`` caps to the team and + so beats the broader ``all-proxy-mcps``; absent any sentinel the entries are + explicit identifiers. """ values = frozenset(raw) if SpecialMCPServerNames.no_mcp_servers.value in values: return NoServers() + if SpecialMCPServerNames.all_team_mcp_servers.value in values: + return AllTeamServers() if SpecialMCPServerNames.all_proxy_mcp_servers.value in values: return AllServers() return ExplicitServers(values) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d3279b29a06..a9d08f6d8d0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1229,11 +1229,6 @@ from litellm.models.team import LiteLLM_ModelTable as LiteLLM_ModelTable # noqa # MCP Types -class SpecialMCPServerName(str, enum.Enum): - all_team_servers = "all-team-mcpservers" - all_proxy_servers = "all-proxy-mcpservers" - - class MCPApprovalStatus(str, enum.Enum): pending_review = "pending_review" active = "active" @@ -2986,6 +2981,7 @@ class SpecialModelNames(enum.Enum): class SpecialMCPServerNames(enum.Enum): no_mcp_servers = "no-mcp-servers" all_proxy_mcp_servers = "all-proxy-mcps" + all_team_mcp_servers = "all-team-mcps" class SpecialProxyStrings(enum.Enum): diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 4ab308990b5..cdc9abf26b4 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -163,11 +163,13 @@ if MCP_AVAILABLE: MCPUserEnvVarsStatus, NewMCPServerRequest, RejectMCPServerRequest, - SpecialMCPServerName, UpdateMCPServerRequest, UserAPIKeyAuth, UserMCPManagementMode, ) + from litellm.proxy._experimental.mcp_server.permission_grant import ( + MCP_GRANT_SENTINELS, + ) from litellm.proxy.auth.user_api_key_auth import ( _user_api_key_auth_builder, user_api_key_auth, @@ -1451,11 +1453,9 @@ if MCP_AVAILABLE: }, ) - # Block reserved special server IDs - if ( - SpecialMCPServerName.all_team_servers == payload.server_id - or SpecialMCPServerName.all_proxy_servers == payload.server_id - ): + # Block reserved special server IDs — a grant sentinel must never be + # usable as a real server_id. + if payload.server_id in MCP_GRANT_SENTINELS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index 7f5b0176c3e..a2807dea96e 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -15,6 +15,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._experimental.mcp_server.permission_grant import ( MCP_GRANT_SENTINELS, AllServers, + AllTeamServers, ExplicitServers, MCPServerGrant, NoServers, @@ -372,7 +373,10 @@ async def _resolve_team_allowed_mcp_servers( """ grant = parse_mcp_server_grant(team_object_permission.mcp_servers or []) match grant: - case NoServers(): + case NoServers() | AllTeamServers(): + # no-mcp-servers blocks everything; all-team-mcps is key-only and + # meaningless on a team (a team cannot grant "its own team's + # servers"), so a misconfigured team fails closed to no servers. return set() case AllServers(): from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( @@ -697,6 +701,17 @@ async def validate_key_mcp_servers_against_team( ) }, ) + if isinstance(key_grant, AllTeamServers) and team_obj is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "all-team-mcps can only be granted to a key that belongs to a " + "team. It resolves to the team's MCP servers at request time, so " + "a teamless key would reach zero servers." + ) + }, + ) if not any((requested_servers, requested_access_groups, requested_toolsets)): return object_permission diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 5b7c4948c56..83ea385051b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -280,6 +280,65 @@ class TestMCPRequestHandler: assert result == [SpecialMCPServerNames.no_mcp_servers.value] + @pytest.mark.parametrize( + "team_servers,expected", + [ + (["team_server1", "team_server2"], ["team_server1", "team_server2"]), + # Fail closed: a team that grants nothing leaves an all-team-mcps key + # with zero servers rather than inheriting the whole proxy. + ([], []), + ], + ) + async def test_all_team_mcps_sentinel_resolves_to_team_set( + self, team_servers, expected + ): + """A key scoped to all-team-mcps resolves to exactly its team's servers, + tracking the team grant and never leaking the sentinel marker.""" + user_api_key_auth = UserAPIKeyAuth( + api_key="test-key", user_id="test-user", team_id="test-team" + ) + key_object_permission = MagicMock() + key_object_permission.mcp_servers = [ + SpecialMCPServerNames.all_team_mcp_servers.value + ] + + with patch.object( + MCPRequestHandler, + "_get_key_object_permission", + return_value=key_object_permission, + ), patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_team", + new_callable=AsyncMock, + return_value=team_servers, + ): + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) + + assert sorted(result) == sorted(expected) + assert SpecialMCPServerNames.all_team_mcp_servers.value not in result + + async def test_get_allowed_mcp_servers_for_key_returns_all_team_marker(self): + """_get_allowed_mcp_servers_for_key surfaces the all-team-mcps sentinel + unexpanded so the caller can map it onto the team set, ignoring any other + entries on the key.""" + user_api_key_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + key_object_permission = MagicMock() + key_object_permission.mcp_servers = [ + SpecialMCPServerNames.all_team_mcp_servers.value, + "some-other-server", + ] + + with patch.object( + MCPRequestHandler, + "_get_key_object_permission", + return_value=key_object_permission, + ): + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( + user_api_key_auth + ) + + assert result == [SpecialMCPServerNames.all_team_mcp_servers.value] + async def test_permission_inheritance_edge_cases(self): """Test edge cases in permission inheritance""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 1d87af54c8c..0e8338f2260 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -209,6 +209,32 @@ class TestMCPServerManager: sentinel = SpecialMCPServerNames.no_mcp_servers.value assert manager.expand_permission_list([sentinel]) == [sentinel] + async def test_expand_permission_list_all_team_mcps_passthrough(self): + """all-team-mcps cannot be expanded here (it needs team context, which + the registry-only manager lacks), so it is surfaced unexpanded for the + key resolver to map onto the team set. Pin that the literal survives and + is never confused with a registered server.""" + manager = MCPServerManager() + await manager.add_server( + LiteLLM_MCPServerTable( + server_id="srv-x", + alias="srv-x", + description="", + url=None, + transport=MCPTransport.stdio, + command="python", + args=["-m", "server"], + env={}, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + sentinel = SpecialMCPServerNames.all_team_mcp_servers.value + assert manager.expand_permission_list([sentinel]) == [sentinel] + # Mixed with a real id the sentinel still dominates (most-restrictive + # grant), so the named server is NOT additionally resolved. + assert manager.expand_permission_list([sentinel, "srv-x"]) == [sentinel] + async def test_create_mcp_client_stdio(self): """Test creating MCP client for stdio transport""" manager = MCPServerManager() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_permission_grant.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_permission_grant.py index 430d9bcfb5b..f21a4165f0e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_permission_grant.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_permission_grant.py @@ -4,6 +4,7 @@ import pytest from litellm.proxy._experimental.mcp_server.permission_grant import ( AllServers, + AllTeamServers, ExplicitServers, MCPServerGrant, NoServers, @@ -13,6 +14,7 @@ from litellm.proxy._types import SpecialMCPServerNames NO_MCP = SpecialMCPServerNames.no_mcp_servers.value ALL_PROXY = SpecialMCPServerNames.all_proxy_mcp_servers.value +ALL_TEAM = SpecialMCPServerNames.all_team_mcp_servers.value def test_concrete_only_returns_explicit_servers() -> None: @@ -32,11 +34,31 @@ def test_no_mcp_sentinel_returns_no_servers() -> None: assert parse_mcp_server_grant([NO_MCP]) == NoServers() +def test_all_team_sentinel_returns_all_team_servers() -> None: + assert parse_mcp_server_grant([ALL_TEAM]) == AllTeamServers() + + +def test_all_team_sentinel_dominates_concrete_ids() -> None: + assert parse_mcp_server_grant([ALL_TEAM, "srv-a"]) == AllTeamServers() + + def test_block_all_wins_over_all_proxy_regardless_of_order() -> None: assert parse_mcp_server_grant([NO_MCP, ALL_PROXY]) == NoServers() assert parse_mcp_server_grant([ALL_PROXY, NO_MCP]) == NoServers() +def test_block_all_wins_over_all_team_regardless_of_order() -> None: + assert parse_mcp_server_grant([NO_MCP, ALL_TEAM]) == NoServers() + assert parse_mcp_server_grant([ALL_TEAM, NO_MCP]) == NoServers() + + +def test_all_team_beats_all_proxy_regardless_of_order() -> None: + # all-team caps to the team and so is the more restrictive grant; it wins + # over the broader all-proxy when both somehow appear. + assert parse_mcp_server_grant([ALL_PROXY, ALL_TEAM]) == AllTeamServers() + assert parse_mcp_server_grant([ALL_TEAM, ALL_PROXY]) == AllTeamServers() + + def test_empty_list_returns_empty_explicit_servers() -> None: assert parse_mcp_server_grant([]) == ExplicitServers(frozenset()) @@ -49,15 +71,18 @@ def test_grants_are_frozen() -> None: def test_grants_equal_by_value() -> None: assert AllServers() == AllServers() + assert AllTeamServers() == AllTeamServers() assert NoServers() == NoServers() assert ExplicitServers(frozenset({"a"})) == ExplicitServers(frozenset({"a"})) assert AllServers() != NoServers() + assert AllServers() != AllTeamServers() def test_union_alias_admits_each_variant() -> None: grants: tuple[MCPServerGrant, ...] = ( AllServers(), + AllTeamServers(), NoServers(), ExplicitServers(frozenset({"a"})), ) - assert len(grants) == 3 + assert len(grants) == 4 diff --git a/tests/test_litellm/proxy/db/mcp_server/test_db.py b/tests/test_litellm/proxy/db/mcp_server/test_db.py index ff6400ac5b7..b03042b9bd1 100644 --- a/tests/test_litellm/proxy/db/mcp_server/test_db.py +++ b/tests/test_litellm/proxy/db/mcp_server/test_db.py @@ -1,6 +1,7 @@ import json import os import sys +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -8,8 +9,57 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path -from litellm.proxy._experimental.mcp_server.db import get_mcp_servers_by_team +from litellm.proxy._experimental.mcp_server.db import ( + get_all_mcp_servers_for_user, + get_mcp_servers_by_team, +) +from litellm.proxy._types import SpecialMCPServerNames, UserAPIKeyAuth + +DB_MODULE = "litellm.proxy._experimental.mcp_server.db" def test_fetch_mcp_servers_by_team(): assert True == True + + +@pytest.mark.asyncio +@patch(f"{DB_MODULE}.get_mcp_servers", new_callable=AsyncMock) +@patch(f"{DB_MODULE}.get_mcp_servers_by_team", new_callable=AsyncMock) +@patch(f"{DB_MODULE}.get_mcp_servers_by_verificationtoken", new_callable=AsyncMock) +async def test_get_all_mcp_servers_for_user_all_team_mcps_expands_to_team( + mock_token_servers, mock_team_servers, mock_get_servers +): + """A key scoped to all-team-mcps surfaces its team's servers in discovery, + matching the access resolver. Pins that the live sentinel value drives the + expansion (the removed legacy all-team-mcpservers value would not).""" + mock_token_servers.return_value = [ + SpecialMCPServerNames.all_team_mcp_servers.value + ] + mock_team_servers.return_value = ["team-srv-1", "team-srv-2"] + mock_get_servers.return_value = [] + + user = UserAPIKeyAuth(api_key="k", user_id="u", team_id="team-1") + await get_all_mcp_servers_for_user(MagicMock(), user) + + mock_team_servers.assert_awaited_once() + queried_ids = set(mock_get_servers.call_args.args[1]) + assert {"team-srv-1", "team-srv-2"} <= queried_ids + + +@pytest.mark.asyncio +@patch(f"{DB_MODULE}.get_mcp_servers", new_callable=AsyncMock) +@patch(f"{DB_MODULE}.get_mcp_servers_by_team", new_callable=AsyncMock) +@patch(f"{DB_MODULE}.get_mcp_servers_by_verificationtoken", new_callable=AsyncMock) +async def test_get_all_mcp_servers_for_user_explicit_servers_skip_team_expansion( + mock_token_servers, mock_team_servers, mock_get_servers +): + """Without the all-team-mcps sentinel, discovery never pulls in the team's + servers — proving the expansion is gated on the sentinel, not unconditional.""" + mock_token_servers.return_value = ["explicit-srv"] + mock_get_servers.return_value = [] + + user = UserAPIKeyAuth(api_key="k", user_id="u", team_id="team-1") + await get_all_mcp_servers_for_user(MagicMock(), user) + + mock_team_servers.assert_not_awaited() + assert set(mock_get_servers.call_args.args[1]) == {"explicit-srv"} diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index f40904e234d..bdeea7023ee 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -25,6 +25,7 @@ from litellm.proxy._types import ( LitellmUserRoles, MCPTransport, NewMCPServerRequest, + SpecialMCPServerNames, UpdateMCPServerRequest, UserAPIKeyAuth, ) @@ -2590,6 +2591,49 @@ class TestUpdateMCPServer: assert result.alias == "Updated Test Server" +class TestAddMCPServerReservedIds: + """A grant sentinel must never be usable as a real MCP server_id — otherwise + a real server could shadow the permission sentinel and corrupt grant parsing. + Pins that the guard tracks the live SpecialMCPServerNames values.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "reserved_id", + [ + SpecialMCPServerNames.no_mcp_servers.value, + SpecialMCPServerNames.all_proxy_mcp_servers.value, + SpecialMCPServerNames.all_team_mcp_servers.value, + ], + ) + async def test_create_rejects_grant_sentinel_server_id(self, reserved_id): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + add_mcp_server, + ) + + payload = NewMCPServerRequest( + server_id=reserved_id, + alias="echo", + url="https://echo.example.com/mcp", + transport=MCPTransport.http, + ) + admin = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user" + ) + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload", + MagicMock(), + ), + pytest.raises(HTTPException) as exc_info, + ): + await add_mcp_server(payload=payload, user_api_key_dict=admin) + assert exc_info.value.status_code == 400 + + class TestAddMCPServerAtomicity: """A committed MCP server must survive a post-write registry refresh failure. diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index f5fa670676e..66bf4d36c40 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -1098,3 +1098,67 @@ async def test_validate_teamless_non_admin_all_proxy_raises( is_proxy_admin=False, ) assert exc_info.value.status_code == 403 + + +# ---- Tests for the all-team-mcps grant ---- + +ALL_TEAM = SpecialMCPServerNames.all_team_mcp_servers.value + + +def test_extract_requested_mcp_server_ids_excludes_all_team_sentinel(): + obj_perm = {"mcp_servers": [ALL_TEAM, "server-1"]} + assert _extract_requested_mcp_server_ids(obj_perm) == {"server-1"} + + +@pytest.mark.asyncio +async def test_validate_key_all_team_in_team_allowed_and_preserved(): + """A key in a team may carry all-team-mcps: it resolves to the team grant at + request time, so the validator accepts it and leaves the sentinel intact + (so it keeps tracking the team) without enumerating servers.""" + team_obj = _make_team_obj(mcp_servers=["server-1"]) + obj_perm = {"mcp_servers": [ALL_TEAM]} + result = await validate_key_mcp_servers_against_team( + object_permission=obj_perm, + team_obj=team_obj, + ) + assert result == obj_perm + assert obj_perm["mcp_servers"] == [ALL_TEAM] + + +@pytest.mark.asyncio +async def test_validate_key_all_team_teamless_raises(): + """all-team-mcps on a teamless key is rejected, not silently resolved to + zero: there is no team to track, so it is a meaningless grant.""" + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": [ALL_TEAM]}, + team_obj=None, + ) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_validate_key_all_team_teamless_admin_still_raises(): + """Unlike all-proxy-mcps (which a proxy admin may assign to a teamless key), + all-team-mcps stays team-only even for an admin — it has no team to resolve + against.""" + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": [ALL_TEAM]}, + team_obj=None, + is_proxy_admin=True, + ) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_resolve_team_allowed_mcp_servers_all_team_returns_empty(): + """A team carrying all-team-mcps is nonsensical (a team cannot grant 'its own + team's servers' recursively), so the team resolver fails closed to empty + rather than the registry.""" + mock_perm = MagicMock(spec=LiteLLM_ObjectPermissionTable) + mock_perm.mcp_servers = [ALL_TEAM] + mock_perm.mcp_access_groups = ["group-a"] + mock_perm.mcp_tool_permissions = {"server-x": ["tool1"]} + result = await _resolve_team_allowed_mcp_servers(mock_perm) + assert result == set() diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 4490673f839..875564acba8 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,5 +1,5 @@ { - "@typescript-eslint/no-explicit-any": 2030, + "@typescript-eslint/no-explicit-any": 2024, "complexity": 128, "max-depth": 61 } 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 f1b33a3534d..0c601679dd7 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 @@ -3,7 +3,11 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import MCPServerSelector from "./MCPServerSelector"; -import { ALL_PROXY_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import { + ALL_PROXY_MCPS_SENTINEL, + ALL_TEAM_MCPS_SENTINEL, + NO_MCP_SERVERS_SENTINEL, +} from "../mcp_tools/constants"; vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({ useMCPServers: vi.fn(), @@ -45,15 +49,22 @@ const mockUseMCPServers = vi.mocked(useMCPServers); const mockUseMCPAccessGroups = vi.mocked(useMCPAccessGroups); const mockUseMCPToolsets = vi.mocked(useMCPToolsets); +const setupMcpHooks = (servers: { server_id: string; server_name: string }[]) => { + vi.clearAllMocks(); + mockUseMCPServers.mockReturnValue({ data: servers, isLoading: false } as unknown as ReturnType); + mockUseMCPAccessGroups.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType); + mockUseMCPToolsets.mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType); +}; + describe("MCPServerSelector no-mcp-servers option", () => { beforeEach(() => { - vi.clearAllMocks(); - mockUseMCPServers.mockReturnValue({ - data: [{ server_id: "srv-1", server_name: "Server One" }], - isLoading: false, - } as any); - mockUseMCPAccessGroups.mockReturnValue({ data: [], isLoading: false } as any); - mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any); + setupMcpHooks([{ server_id: "srv-1", server_name: "Server One" }]); }); const optionByValue = (value: string) => @@ -101,13 +112,7 @@ describe("MCPServerSelector no-mcp-servers option", () => { describe("MCPServerSelector all-proxy-mcps option", () => { beforeEach(() => { - vi.clearAllMocks(); - mockUseMCPServers.mockReturnValue({ - data: [{ server_id: "srv-1", server_name: "Server One" }], - isLoading: false, - } as any); - mockUseMCPAccessGroups.mockReturnValue({ data: [], isLoading: false } as any); - mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any); + setupMcpHooks([{ server_id: "srv-1", server_name: "Server One" }]); }); const optionByValue = (value: string) => @@ -152,3 +157,71 @@ describe("MCPServerSelector all-proxy-mcps option", () => { expect(optionByValue(ALL_PROXY_MCPS_SENTINEL)?.disabled).toBe(false); }); }); + +describe("MCPServerSelector all-team-mcps option", () => { + beforeEach(() => { + setupMcpHooks([{ server_id: "srv-1", server_name: "Server One" }]); + }); + + const optionByValue = (value: string) => + Array.from(screen.getByTestId("mcp-select").querySelectorAll("option")).find( + (o) => (o as HTMLOptionElement).value === value, + ) as HTMLOptionElement | undefined; + + it("hides All Team MCPs when no team is selected, even with allowAllTeamMcps", () => { + renderWithProviders( + , + ); + expect(optionByValue(ALL_TEAM_MCPS_SENTINEL)).toBeUndefined(); + }); + + it("shows All Team MCPs once a team is selected", () => { + renderWithProviders( + , + ); + expect(optionByValue(ALL_TEAM_MCPS_SENTINEL)).toBeDefined(); + }); + + it("emits an exclusive sentinel when All Team MCPs is selected", async () => { + const onChange = vi.fn(); + renderWithProviders( + , + ); + await userEvent.selectOptions(screen.getByTestId("mcp-select"), [ALL_TEAM_MCPS_SENTINEL]); + + expect(onChange).toHaveBeenCalledWith({ servers: [ALL_TEAM_MCPS_SENTINEL], accessGroups: [], toolsets: [] }); + }); + + it("makes the three exclusive sentinels mutually exclusive", () => { + renderWithProviders( + , + ); + expect(optionByValue(ALL_TEAM_MCPS_SENTINEL)?.disabled).toBe(false); + expect(optionByValue(NO_MCP_SERVERS_SENTINEL)?.disabled).toBe(true); + expect(optionByValue("srv-1")?.disabled).toBe(true); + }); +}); 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 c966fd61e28..fee616387d6 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -3,7 +3,11 @@ import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers" import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import { Select } from "antd"; import React from "react"; -import { ALL_PROXY_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants"; +import { + ALL_PROXY_MCPS_SENTINEL, + ALL_TEAM_MCPS_SENTINEL, + NO_MCP_SERVERS_SENTINEL, +} from "@/components/mcp_tools/constants"; interface MCPServerSelectorProps { onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void; @@ -19,28 +23,34 @@ interface MCPServerSelectorProps { teamId?: string | null; allowNoMcpServers?: boolean; allowAllProxyMcps?: boolean; + allowAllTeamMcps?: boolean; } const TOOLSET_PREFIX = "toolset:"; -type MCPSelection = { servers: string[]; accessGroups: string[]; toolsets: string[] }; +type ExclusiveSentinel = { value: string; label: string; caption: string; color: string }; -// "No MCP Servers" and "All Proxy MCPs" are mutually exclusive with everything -// else. The most restrictive ("block all") wins, mirroring the backend -// precedence where the no-mcp-servers sentinel overrides every grant. -const resolveExclusiveSelection = ( - selected: string[], +// Each entry grants a whole class of servers, so it is mutually exclusive with +// every other pick. Ordered most-restrictive first to mirror the backend grant +// precedence (block-all > team > proxy); the first match wins. "All Team MCPs" +// only appears once a team is chosen, since it resolves against that team at +// request time and is meaningless on a teamless key. +const buildExclusiveSentinels = ( allowNoMcpServers: boolean, + allowAllTeamMcps: boolean, allowAllProxyMcps: boolean, -): MCPSelection | null => { - if (allowNoMcpServers && selected.includes(NO_MCP_SERVERS_SENTINEL)) { - return { servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [], toolsets: [] }; - } - if (allowAllProxyMcps && selected.includes(ALL_PROXY_MCPS_SENTINEL)) { - return { servers: [ALL_PROXY_MCPS_SENTINEL], accessGroups: [], toolsets: [] }; - } - return null; -}; + teamId: string | null | undefined, +): ExclusiveSentinel[] => [ + ...(allowNoMcpServers + ? [{ value: NO_MCP_SERVERS_SENTINEL, label: "No MCP Servers", caption: "Block all", color: "#8c8c8c" }] + : []), + ...(allowAllTeamMcps && !!teamId + ? [{ value: ALL_TEAM_MCPS_SENTINEL, label: "All Team MCPs", caption: "Team's servers", color: "#13c2c2" }] + : []), + ...(allowAllProxyMcps + ? [{ value: ALL_PROXY_MCPS_SENTINEL, label: "All Proxy MCPs", caption: "Every server", color: "#1890ff" }] + : []), +]; const MCPServerSelector: React.FC = ({ onChange, @@ -52,6 +62,7 @@ const MCPServerSelector: React.FC = ({ teamId, allowNoMcpServers = false, allowAllProxyMcps = false, + allowAllTeamMcps = false, }) => { const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId); const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups(); @@ -101,15 +112,21 @@ const MCPServerSelector: React.FC = ({ ...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`), ]; - const hasNoMcpServersSelected = allowNoMcpServers && selectedValues.includes(NO_MCP_SERVERS_SENTINEL); - const hasAllProxyMcpsSelected = allowAllProxyMcps && selectedValues.includes(ALL_PROXY_MCPS_SENTINEL); - const hasExclusiveSelected = hasNoMcpServersSelected || hasAllProxyMcpsSelected; + const exclusiveSentinels = buildExclusiveSentinels( + allowNoMcpServers, + allowAllTeamMcps, + allowAllProxyMcps, + teamId, + ); + + const selectedExclusive = exclusiveSentinels.find((s) => selectedValues.includes(s.value)) ?? null; + const hasExclusiveSelected = selectedExclusive !== null; // Handle selection const handleChange = (selected: string[]) => { - const exclusive = resolveExclusiveSelection(selected, allowNoMcpServers, allowAllProxyMcps); + const exclusive = exclusiveSentinels.find((s) => selected.includes(s.value)); if (exclusive) { - onChange(exclusive); + onChange({ servers: [exclusive.value], accessGroups: [], toolsets: [] }); return; } const toolsetsSelected = selected @@ -121,38 +138,22 @@ const MCPServerSelector: React.FC = ({ onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected }); }; - const renderExclusiveOptions = () => [ - ...(allowAllProxyMcps - ? [ - -
- All Proxy MCPs - Every server -
-
, - ] - : []), - ...(allowNoMcpServers - ? [ - -
- No MCP Servers - Block all -
-
, - ] - : []), - ]; + const renderExclusiveOptions = () => + exclusiveSentinels.map((sentinel) => ( + +
+ {sentinel.label} + + {sentinel.caption} + +
+
+ )); return (
@@ -168,7 +169,7 @@ const MCPServerSelector: React.FC = ({ style={{ width: "100%" }} disabled={disabled} filterOption={(input, option) => { - if (option?.value === NO_MCP_SERVERS_SENTINEL || option?.value === ALL_PROXY_MCPS_SENTINEL) return true; + if (exclusiveSentinels.some((s) => s.value === option?.value)) return true; const searchText = options.find((opt) => opt.value === option?.value)?.searchText || ""; return searchText.toLowerCase().includes(input.toLowerCase()); }} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/constants.ts b/ui/litellm-dashboard/src/components/mcp_tools/constants.ts index 4eb9b2f6558..6c8b87a5975 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/constants.ts +++ b/ui/litellm-dashboard/src/components/mcp_tools/constants.ts @@ -3,3 +3,6 @@ export const NO_MCP_SERVERS_SENTINEL = "no-mcp-servers"; // Must match the backend SpecialMCPServerNames.all_proxy_mcp_servers enum value. export const ALL_PROXY_MCPS_SENTINEL = "all-proxy-mcps"; + +// Must match the backend SpecialMCPServerNames.all_team_mcp_servers enum value. +export const ALL_TEAM_MCPS_SENTINEL = "all-team-mcps"; 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 2a448d63300..057522b6479 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -32,7 +32,7 @@ import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/Budg import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { Team } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; -import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import { ALL_TEAM_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import NotificationsManager from "../molecules/notifications_manager"; import { @@ -1402,6 +1402,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp teamId={selectedCreateKeyTeam?.team_id ?? null} placeholder="Select MCP servers or access groups (optional)" allowNoMcpServers + allowAllTeamMcps /> @@ -1423,7 +1424,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp accessToken={accessToken} selectedServers={( form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || [] - ).filter((s: string) => s !== NO_MCP_SERVERS_SENTINEL)} + ).filter((s: string) => s !== NO_MCP_SERVERS_SENTINEL && s !== ALL_TEAM_MCPS_SENTINEL)} toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}} onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })} /> diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx index 8a3149a9719..a224f8261f1 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import MCPServerPermissions from "./MCPServerPermissions"; import * as networking from "../networking"; -import { ALL_PROXY_MCPS_SENTINEL } from "../mcp_tools/constants"; +import { ALL_PROXY_MCPS_SENTINEL, ALL_TEAM_MCPS_SENTINEL } from "../mcp_tools/constants"; vi.mock("../networking"); @@ -271,6 +271,26 @@ describe("MCPServerPermissions", () => { expect(screen.queryByText(ALL_PROXY_MCPS_SENTINEL)).not.toBeInTheDocument(); }); + it("should display the all-team-mcps grant instead of listing the sentinel as a server", async () => { + /** + * The all-team-mcps sentinel renders the "team's servers" grant state rather + * than a literal server row, so a viewer sees the key tracks its team. + */ + vi.mocked(networking.fetchMCPServers).mockResolvedValue([]); + + render( + , + ); + + expect(await screen.findByText(/All team MCP servers/)).toBeInTheDocument(); + expect(screen.queryByText(ALL_TEAM_MCPS_SENTINEL)).not.toBeInTheDocument(); + }); + it("should handle multiple servers with different tool permissions", async () => { /** * Tests that multiple servers can each have their own tool permissions diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index 079f6609320..be45e702d50 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -4,7 +4,7 @@ import { ServerIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/ import { Tooltip } from "antd"; import { fetchMCPServers, fetchMCPToolsets } from "../networking"; import { MCPServer, MCPToolset } from "../mcp_tools/types"; -import { ALL_PROXY_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import { ALL_PROXY_MCPS_SENTINEL, ALL_TEAM_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; interface MCPServerPermissionsProps { mcpServers: string[]; @@ -97,11 +97,17 @@ export function MCPServerPermissions({ const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL); const grantsAllProxyMcps = mcpServers.includes(ALL_PROXY_MCPS_SENTINEL); + const grantsAllTeamMcps = mcpServers.includes(ALL_TEAM_MCPS_SENTINEL); // Merge servers and access groups into one list const mergedItems = [ ...mcpServers - .filter((server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCPS_SENTINEL) + .filter( + (server) => + server !== NO_MCP_SERVERS_SENTINEL && + server !== ALL_PROXY_MCPS_SENTINEL && + server !== ALL_TEAM_MCPS_SENTINEL, + ) .map((server) => ({ type: "server", value: server })), ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })), ]; @@ -112,8 +118,11 @@ export function MCPServerPermissions({
MCP Servers - - {blocksAllMcpServers ? "Blocked" : grantsAllProxyMcps ? "All" : totalCount} + + {blocksAllMcpServers ? "Blocked" : grantsAllProxyMcps ? "All" : grantsAllTeamMcps ? "Team" : totalCount}
@@ -131,6 +140,14 @@ export function MCPServerPermissions({ All proxy MCP servers — access to every MCP server registered on the proxy, including ones added later
+ ) : grantsAllTeamMcps ? ( +
+ + + All team MCP servers — access to every MCP server this key's team can reach, tracking the team as its + grant changes + +
) : totalCount > 0 ? (
{mergedItems.map((item, index) => { diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index e991db8069e..988293b49bb 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -19,7 +19,7 @@ import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor"; import { KeyResponse } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; -import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import { ALL_TEAM_MCPS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import NotificationsManager from "../molecules/notifications_manager"; import { getPromptsList, modelAvailableCall, tagListCall } from "../networking"; @@ -626,8 +626,10 @@ export function KeyEditView({ onChange={(val) => form.setFieldValue("mcp_servers_and_groups", val)} value={form.getFieldValue("mcp_servers_and_groups")} accessToken={accessToken || ""} + teamId={keyData.team_id ?? null} placeholder="Select MCP servers or access groups (optional)" allowNoMcpServers + allowAllTeamMcps /> @@ -648,7 +650,7 @@ export function KeyEditView({ s !== NO_MCP_SERVERS_SENTINEL, + (s: string) => s !== NO_MCP_SERVERS_SENTINEL && s !== ALL_TEAM_MCPS_SENTINEL, )} toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}} onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}