mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat(mcp): let a key scope to zero MCP servers
A key's object_permission.mcp_servers was a non-nullable String[], so an empty list and an unset value both collapsed to [], and the resolver read either one as "inherit the team's full MCP server list". That made it impossible to give a key zero servers under a team that has servers, which pushed keys past provider tool caps. The column is now nullable JSON where null inherits the team, [] means no servers, and a list means exactly those, and the resolver honors the distinction. A data-preserving migration converts the column in place and backfills existing [] to null, so every current key and team keeps inheriting and no live row changes behavior. The dashboard key create and edit forms gain an explicit Inherit / No access / Specific control so the new zero state is reachable without the API
This commit is contained in:
parent
8bc18388e3
commit
c1ca4cfe6d
14 changed files with 366 additions and 98 deletions
|
|
@ -0,0 +1,15 @@
|
|||
-- AlterTable
|
||||
-- Convert object_permission.mcp_servers from a non-nullable String[] to a nullable
|
||||
-- JSONB column so an explicit empty list ([]) can mean "no servers" distinctly from
|
||||
-- NULL ("inherit team scope"). Prisma's auto-diff would DROP/ADD the column (data
|
||||
-- loss); this in-place USING cast preserves data. Existing empty arrays meant
|
||||
-- "inherit" under the old semantics, so they map to NULL (which also means "inherit"
|
||||
-- under the new semantics) -- no live key or team changes behavior.
|
||||
ALTER TABLE "LiteLLM_ObjectPermissionTable"
|
||||
ALTER COLUMN "mcp_servers" DROP DEFAULT,
|
||||
ALTER COLUMN "mcp_servers" TYPE JSONB USING (
|
||||
CASE
|
||||
WHEN "mcp_servers" IS NULL OR cardinality("mcp_servers") = 0 THEN NULL
|
||||
ELSE to_jsonb("mcp_servers")
|
||||
END
|
||||
);
|
||||
|
|
@ -269,7 +269,7 @@ model LiteLLM_UserTable {
|
|||
|
||||
model LiteLLM_ObjectPermissionTable {
|
||||
object_permission_id String @id @default(uuid())
|
||||
mcp_servers String[] @default([])
|
||||
mcp_servers Json? // null = inherit team scope; [] = no servers; ["id", ...] = exactly those servers
|
||||
mcp_access_groups String[] @default([])
|
||||
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
|
||||
vector_stores String[] @default([])
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
|
|||
"""Represents a LiteLLM_ObjectPermissionTable record"""
|
||||
|
||||
object_permission_id: str
|
||||
mcp_servers: Optional[List[str]] = []
|
||||
# None = inherit team scope; [] = no servers; ["id", ...] = exactly those. The
|
||||
# default must stay None so the inherit/zero distinction survives cache
|
||||
# round-trips (serialize drops None via exclude_none, deserialize restores it).
|
||||
mcp_servers: Optional[List[str]] = None
|
||||
mcp_access_groups: Optional[List[str]] = []
|
||||
mcp_tool_permissions: Optional[Dict[str, List[str]]] = None
|
||||
vector_stores: Optional[List[str]] = []
|
||||
|
|
|
|||
|
|
@ -657,19 +657,24 @@ class MCPRequestHandler:
|
|||
#########################################################
|
||||
# Calculate key/team allowed servers using inheritance and intersection logic
|
||||
#########################################################
|
||||
key_set = set(allowed_mcp_servers_for_key)
|
||||
# allowed_mcp_servers_for_key is None when the key declares no scope of
|
||||
# its own (inherit the team) and a list -- possibly empty -- when it does.
|
||||
key_declares_scope = allowed_mcp_servers_for_key is not None
|
||||
key_set = set(allowed_mcp_servers_for_key or [])
|
||||
team_set = set(allowed_mcp_servers_for_team)
|
||||
grants_set = set(key_access_group_grants)
|
||||
|
||||
has_lower_level_mcp_restrictions = bool(key_set or team_set or grants_set)
|
||||
has_lower_level_mcp_restrictions = bool(
|
||||
key_declares_scope or team_set or grants_set
|
||||
)
|
||||
|
||||
# 1. Key/team ceiling. An empty set means "this level does not restrict".
|
||||
if not team_set:
|
||||
base = key_set # no team restriction
|
||||
elif not key_set:
|
||||
base = team_set # key has no own perms → inherits team
|
||||
if not key_declares_scope:
|
||||
base = team_set # key inherits the team (team_set may be empty)
|
||||
elif not team_set:
|
||||
base = key_set # key restricts, team does not
|
||||
else:
|
||||
base = key_set & team_set # both restrict → intersect
|
||||
base = key_set & team_set # both restrict → intersect ([] → zero)
|
||||
|
||||
# 2. Add the key's access-group grants on top. These are additive:
|
||||
# attaching a group to the key grants its servers regardless of the
|
||||
|
|
@ -1014,18 +1019,23 @@ class MCPRequestHandler:
|
|||
@staticmethod
|
||||
async def _get_allowed_mcp_servers_for_key(
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
|
||||
) -> List[str]:
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Get the key's own MCP ceiling from its object_permission
|
||||
(mcp_servers, tag-style mcp_access_groups, mcp_tool_permissions).
|
||||
|
||||
Returns None when the key declares no MCP scope of its own (so it inherits
|
||||
the team's scope) and a list -- possibly empty -- when it does. An explicit
|
||||
empty list means "no servers", which intersects the team down to nothing
|
||||
instead of inheriting it.
|
||||
|
||||
Unified key.access_group_ids are NOT resolved here — they are additive
|
||||
grants handled by _get_key_access_group_mcp_server_extras and unioned on
|
||||
top of the key/team ceiling, so they must not enter this scope (which is
|
||||
intersected against the team).
|
||||
"""
|
||||
if user_api_key_auth is None:
|
||||
return []
|
||||
return None
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
|
|
@ -1056,7 +1066,11 @@ class MCPRequestHandler:
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if key_object_permission is None:
|
||||
return []
|
||||
return None
|
||||
|
||||
# None means the key never set its own list (inherit); [] means it
|
||||
# explicitly scoped itself to no servers.
|
||||
direct_scope_declared = key_object_permission.mcp_servers is not None
|
||||
|
||||
# Permission entries may be server_ids OR names/aliases — expand to ids.
|
||||
direct_mcp_servers = global_mcp_server_manager.expand_permission_list(
|
||||
|
|
@ -1079,12 +1093,16 @@ class MCPRequestHandler:
|
|||
|
||||
# Combine all lists
|
||||
all_servers = direct_mcp_servers + access_group_servers + tool_perm_servers
|
||||
return list(set(all_servers))
|
||||
if all_servers:
|
||||
return list(set(all_servers))
|
||||
|
||||
# Nothing resolved: preserve the inherit-vs-zero distinction.
|
||||
return [] if direct_scope_declared else None
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
f"Failed to get allowed MCP servers for key: {str(e)}"
|
||||
)
|
||||
return []
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _get_allowed_mcp_servers_for_team(
|
||||
|
|
|
|||
|
|
@ -516,19 +516,22 @@ async def get_objectpermissions_for_mcp_server(
|
|||
"""
|
||||
Get all the object permissions records and the associated team and verficiationtoken records that have access to the mcp server
|
||||
"""
|
||||
# mcp_servers is a nullable JSON column (null = inherit, [] = none, [ids] = those),
|
||||
# so it can't be filtered with the scalar-list `has` operator; match in Python.
|
||||
object_permission_records = await ObjectPermissionRepository(
|
||||
prisma_client
|
||||
).table.find_many(
|
||||
where={
|
||||
"mcp_servers": {"has": mcp_server_id},
|
||||
},
|
||||
include={
|
||||
"teams": True,
|
||||
"verification_tokens": True,
|
||||
},
|
||||
)
|
||||
|
||||
return object_permission_records
|
||||
return [
|
||||
record
|
||||
for record in object_permission_records
|
||||
if isinstance(record.mcp_servers, list) and mcp_server_id in record.mcp_servers
|
||||
]
|
||||
|
||||
|
||||
async def get_virtualkeys_for_mcp_server(
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@ async def handle_update_object_permission_common(
|
|||
existing_object_permissions_dict["mcp_tool_permissions"]
|
||||
)
|
||||
|
||||
# mcp_servers is a JSON column; serialize the list (None stays NULL = inherit).
|
||||
if isinstance(existing_object_permissions_dict.get("mcp_servers"), list):
|
||||
existing_object_permissions_dict["mcp_servers"] = safe_dumps(
|
||||
existing_object_permissions_dict["mcp_servers"]
|
||||
)
|
||||
|
||||
#########################################################
|
||||
# Commit the update to the LiteLLM_ObjectPermissionTable
|
||||
#########################################################
|
||||
|
|
@ -185,6 +191,10 @@ async def _set_object_permission(
|
|||
clean_data["mcp_tool_permissions"]
|
||||
)
|
||||
|
||||
# mcp_servers is a JSON column; serialize the list (None was already dropped above).
|
||||
if isinstance(clean_data.get("mcp_servers"), list):
|
||||
clean_data["mcp_servers"] = safe_dumps(clean_data["mcp_servers"])
|
||||
|
||||
created_permission = await ObjectPermissionRepository(prisma_client).table.create(
|
||||
data=clean_data
|
||||
)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ model LiteLLM_UserTable {
|
|||
|
||||
model LiteLLM_ObjectPermissionTable {
|
||||
object_permission_id String @id @default(uuid())
|
||||
mcp_servers String[] @default([])
|
||||
mcp_servers Json? // null = inherit team scope; [] = no servers; ["id", ...] = exactly those servers
|
||||
mcp_access_groups String[] @default([])
|
||||
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
|
||||
vector_stores String[] @default([])
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ model LiteLLM_UserTable {
|
|||
|
||||
model LiteLLM_ObjectPermissionTable {
|
||||
object_permission_id String @id @default(uuid())
|
||||
mcp_servers String[] @default([])
|
||||
mcp_servers Json? // null = inherit team scope; [] = no servers; ["id", ...] = exactly those servers
|
||||
mcp_access_groups String[] @default([])
|
||||
mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]}
|
||||
vector_stores String[] @default([])
|
||||
|
|
|
|||
|
|
@ -2649,7 +2649,7 @@ async def test_mcp_access_group_permission_inheritance_integration():
|
|||
MCPRequestHandler, "_get_allowed_mcp_servers_for_team"
|
||||
) as mock_team:
|
||||
# Key has no permissions, team has servers
|
||||
mock_key.return_value = [] # Key inherits nothing directly
|
||||
mock_key.return_value = None # Key declares no scope -> inherit team
|
||||
mock_team.return_value = [
|
||||
"staff-server-1",
|
||||
"staff-server-2",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,35 @@ class TestObjectPermission:
|
|||
)
|
||||
assert perm.mcp_tool_permissions == {"server1": ["tool1", "tool2"]}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mcp_servers,scenario",
|
||||
[
|
||||
(None, "none_inherits_team"),
|
||||
([], "explicit_empty_is_zero"),
|
||||
],
|
||||
)
|
||||
def test_object_permission_mcp_servers_survive_cache_round_trip(
|
||||
self, mcp_servers, scenario
|
||||
):
|
||||
"""A Redis round-trip serializes through ``model_dump(mode='json',
|
||||
exclude_none=True)`` and re-validates the dict. ``mcp_servers`` must
|
||||
keep the inherit (None) vs explicit-zero ([]) distinction across that
|
||||
boundary, since the MCP permission logic branches on exactly that."""
|
||||
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
||||
|
||||
perm = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="x",
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
|
||||
payload = CacheCodec.serialize(perm)
|
||||
restored = CacheCodec.deserialize(
|
||||
payload, model_type=LiteLLM_ObjectPermissionTable
|
||||
)
|
||||
|
||||
assert restored is not None
|
||||
assert restored.mcp_servers == mcp_servers, f"scenario={scenario}"
|
||||
|
||||
|
||||
class TestOrganization:
|
||||
def test_organization_creation(self):
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class TestMCPRequestHandler:
|
|||
(["server1", "server2"], [], ["server1", "server2"], "key_only"),
|
||||
# Test case 3: No key servers, team has servers (inherit from team)
|
||||
(
|
||||
[],
|
||||
None,
|
||||
["team_server1", "team_server2"],
|
||||
["team_server1", "team_server2"],
|
||||
"inherit_from_team",
|
||||
|
|
@ -100,7 +100,7 @@ class TestMCPRequestHandler:
|
|||
"team_servers,key_servers,expected_servers,scenario",
|
||||
[
|
||||
# Test case 1: Key has no permissions, should inherit from team
|
||||
(["server1", "server2"], [], ["server1", "server2"], "inherit_from_team"),
|
||||
(["server1", "server2"], None, ["server1", "server2"], "inherit_from_team"),
|
||||
# Test case 2: Key has permissions, should use intersection with team
|
||||
(
|
||||
["server1", "server2", "server3"],
|
||||
|
|
@ -203,7 +203,7 @@ class TestMCPRequestHandler:
|
|||
# granted only via key.access_group_ids → caller sees team's server
|
||||
# AND the grant (grant is added on top of the ceiling).
|
||||
(
|
||||
[],
|
||||
None,
|
||||
["test"],
|
||||
["context7"],
|
||||
["context7", "test"],
|
||||
|
|
@ -251,6 +251,53 @@ class TestMCPRequestHandler:
|
|||
result = await MCPRequestHandler.get_allowed_mcp_servers(mock_user_auth)
|
||||
assert sorted(result) == sorted(expected)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key_servers,expected,scenario",
|
||||
[
|
||||
# Explicit [] means the key scoped itself to zero servers: it
|
||||
# intersects the populated team down to nothing rather than
|
||||
# inheriting it. This is the headline behavior the fix introduces.
|
||||
([], [], "explicit_empty_key_scopes_to_zero"),
|
||||
# None means the key declares no scope of its own → inherit the team.
|
||||
(None, ["alpha", "beta"], "none_key_inherits_team"),
|
||||
# A real subset intersects against the team.
|
||||
(["alpha"], ["alpha"], "subset_key_intersects_team"),
|
||||
],
|
||||
)
|
||||
async def test_get_allowed_mcp_servers_key_scope_tristate(
|
||||
self, key_servers, expected, scenario
|
||||
):
|
||||
"""The key's MCP scope is tri-state: explicit ``[]`` (zero servers),
|
||||
``None`` (inherit team), or a concrete list (intersect). A populated
|
||||
team must NOT rescue an explicitly-zero-scoped key."""
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="test-user",
|
||||
team_id="test-team",
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
MCPRequestHandler,
|
||||
"_get_allowed_mcp_servers_for_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=key_servers,
|
||||
),
|
||||
patch.object(
|
||||
MCPRequestHandler,
|
||||
"_get_allowed_mcp_servers_for_team",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["alpha", "beta"],
|
||||
),
|
||||
patch.object(
|
||||
MCPRequestHandler,
|
||||
"_get_key_access_group_mcp_server_extras",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
result = await MCPRequestHandler.get_allowed_mcp_servers(mock_user_auth)
|
||||
assert sorted(result) == sorted(expected), f"scenario={scenario}"
|
||||
|
||||
async def test_access_group_extras_returns_empty_when_no_auth(self):
|
||||
"""No auth object → no additive grants."""
|
||||
result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(None)
|
||||
|
|
@ -3113,7 +3160,7 @@ async def test_get_allowed_mcp_servers_for_team_without_team_id_returns_empty():
|
|||
async def test_get_allowed_mcp_servers_for_key_guard_conditions(
|
||||
user_api_key_auth, prisma_client_value, scenario
|
||||
):
|
||||
"""Ensure guard clauses return [] before hitting get_object_permission."""
|
||||
"""Ensure guard clauses return None (inherit team) before hitting get_object_permission."""
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.auth_checks.get_object_permission",
|
||||
|
|
@ -3124,13 +3171,13 @@ async def test_get_allowed_mcp_servers_for_key_guard_conditions(
|
|||
user_api_key_auth
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert result is None
|
||||
mock_get_perm.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_allowed_mcp_servers_for_key_returns_empty_when_db_returns_none():
|
||||
"""Ensure [] is returned when get_object_permission yields None."""
|
||||
async def test_get_allowed_mcp_servers_for_key_returns_none_when_db_returns_none():
|
||||
"""Ensure None is returned (inherit team) when get_object_permission yields None."""
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
|
|
@ -3153,7 +3200,7 @@ async def test_get_allowed_mcp_servers_for_key_returns_empty_when_db_returns_non
|
|||
user_api_key_auth
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert result is None
|
||||
mock_get_perm.assert_awaited_once()
|
||||
|
||||
|
||||
|
|
@ -3210,6 +3257,62 @@ async def test_get_allowed_mcp_servers_for_key_prefers_in_memory_permission():
|
|||
global_mcp_server_manager.registry.pop("direct-server", None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"mcp_servers,expected,scenario",
|
||||
[
|
||||
# None on the object_permission means the key never set its own list →
|
||||
# inherit the team (the helper signals this with None).
|
||||
(None, None, "mcp_servers_none_inherits_team"),
|
||||
# An explicit empty list means the key scoped itself to zero servers →
|
||||
# the helper returns [] so the combination logic intersects to zero.
|
||||
([], [], "mcp_servers_explicit_empty_is_zero"),
|
||||
# A concrete list passes through (expand_permission_list keeps unknown
|
||||
# ids as-is so we don't need a registered server here).
|
||||
(["x"], ["x"], "mcp_servers_concrete_list"),
|
||||
],
|
||||
)
|
||||
async def test_get_allowed_mcp_servers_for_key_tristate_from_object_permission(
|
||||
mcp_servers, expected, scenario
|
||||
):
|
||||
"""``_get_allowed_mcp_servers_for_key`` must preserve the inherit (None) vs
|
||||
explicit-zero ([]) vs concrete-list distinction from the key's
|
||||
object_permission.mcp_servers when there are no access groups or tool
|
||||
permissions."""
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
||||
|
||||
perms = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="perm-tristate",
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_access_groups=[],
|
||||
mcp_tool_permissions=None,
|
||||
)
|
||||
user_api_key_auth = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="test-user",
|
||||
object_permission=perms,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks.get_object_permission",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_perm,
|
||||
patch.object(
|
||||
MCPRequestHandler,
|
||||
"_get_mcp_servers_from_access_groups",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(
|
||||
user_api_key_auth
|
||||
)
|
||||
|
||||
assert result == expected, f"scenario={scenario}"
|
||||
mock_get_perm.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentMCPPermissions:
|
||||
"""Test agent-level MCP server and tool permission intersection."""
|
||||
|
|
@ -3526,7 +3629,7 @@ class TestOrgMCPPermissions:
|
|||
"org_empty_no_restriction",
|
||||
),
|
||||
(
|
||||
[],
|
||||
None,
|
||||
[],
|
||||
["org_s1", "org_s2"],
|
||||
["org_s1", "org_s2"],
|
||||
|
|
@ -4005,7 +4108,7 @@ async def test_get_allowed_mcp_servers_unions_key_access_group_extras():
|
|||
MCPRequestHandler,
|
||||
"_get_allowed_mcp_servers_for_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(
|
||||
MCPRequestHandler,
|
||||
|
|
@ -4222,7 +4325,7 @@ async def test_get_allowed_mcp_servers_includes_team_access_group_extras_end_to_
|
|||
MCPRequestHandler,
|
||||
"_get_allowed_mcp_servers_for_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(
|
||||
MCPRequestHandler,
|
||||
|
|
@ -4264,7 +4367,7 @@ async def test_allowed_mcp_servers_for_key_excludes_access_group_ids():
|
|||
):
|
||||
result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(auth)
|
||||
|
||||
assert result == []
|
||||
assert result is None
|
||||
mock_resolver.assert_not_called()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -451,25 +451,31 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
delete formValues.allowed_vector_store_ids;
|
||||
}
|
||||
|
||||
// Transform allowed_mcp_servers_and_groups into object_permission format
|
||||
if (
|
||||
formValues.allowed_mcp_servers_and_groups &&
|
||||
(formValues.allowed_mcp_servers_and_groups.servers?.length > 0 ||
|
||||
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0)
|
||||
) {
|
||||
// Transform the MCP scope mode + selector into object_permission format.
|
||||
// The mode is tri-state: "inherit" omits mcp_servers (backend inherits the
|
||||
// team's servers), "none" sets it to [] (explicitly zero), and "specific"
|
||||
// sets the selected servers/access groups/toolsets.
|
||||
const mcpScopeMode = formValues.mcp_scope_mode ?? "inherit";
|
||||
if (mcpScopeMode === "none") {
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups;
|
||||
if (servers && servers.length > 0) {
|
||||
formValues.object_permission.mcp_servers = servers;
|
||||
formValues.object_permission.mcp_servers = [];
|
||||
} else if (mcpScopeMode === "specific") {
|
||||
const { servers, accessGroups, toolsets } = formValues.allowed_mcp_servers_and_groups ?? {};
|
||||
if (!formValues.object_permission) {
|
||||
formValues.object_permission = {};
|
||||
}
|
||||
formValues.object_permission.mcp_servers = servers ?? [];
|
||||
if (accessGroups && accessGroups.length > 0) {
|
||||
formValues.object_permission.mcp_access_groups = accessGroups;
|
||||
}
|
||||
// Remove the original field as it's now part of object_permission
|
||||
delete formValues.allowed_mcp_servers_and_groups;
|
||||
if (toolsets && toolsets.length > 0) {
|
||||
formValues.object_permission.mcp_toolsets = toolsets;
|
||||
}
|
||||
}
|
||||
delete formValues.mcp_scope_mode;
|
||||
delete formValues.allowed_mcp_servers_and_groups;
|
||||
|
||||
// Add MCP tool permissions to object_permission
|
||||
const mcpToolPermissions = formValues.mcp_tool_permissions || {};
|
||||
|
|
@ -1385,46 +1391,81 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers or access groups this key can access">
|
||||
MCP Servers / Access Groups{" "}
|
||||
<Tooltip title="Inherit the team's MCP servers, grant no MCP access, or pick specific servers/access groups for this key">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
help="Select MCP servers or access groups this key can access"
|
||||
name="mcp_scope_mode"
|
||||
initialValue="inherit"
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken}
|
||||
teamId={selectedCreateKeyTeam?.team_id ?? null}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Hidden field to register mcp_tool_permissions with the form */}
|
||||
<Form.Item name="mcp_tool_permissions" initialValue={{}} hidden>
|
||||
<Input type="hidden" />
|
||||
<Radio.Group>
|
||||
<Radio value="inherit">Inherit from team</Radio>
|
||||
<Radio value="none">No MCP access</Radio>
|
||||
<Radio value="specific">Specific servers</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups ||
|
||||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
|
||||
prevValues.mcp_scope_mode !== currentValues.mcp_scope_mode
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div className="mt-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken}
|
||||
selectedServers={form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{() =>
|
||||
form.getFieldValue("mcp_scope_mode") === "specific" && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers or access groups this key can access">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
help="Select MCP servers or access groups this key can access"
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken}
|
||||
teamId={selectedCreateKeyTeam?.team_id ?? null}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.allowed_mcp_servers_and_groups !==
|
||||
currentValues.allowed_mcp_servers_and_groups ||
|
||||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div className="mt-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken}
|
||||
selectedServers={
|
||||
form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []
|
||||
}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
{/* Hidden field to register mcp_tool_permissions with the form */}
|
||||
<Form.Item name="mcp_tool_permissions" initialValue={{}} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
|||
import PolicySelector from "@/components/policies/PolicySelector";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { Form, Input, Radio, Select, Switch, Tooltip } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
|
|
@ -78,6 +78,21 @@ const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): strin
|
|||
return "default";
|
||||
};
|
||||
|
||||
type McpScopeMode = "inherit" | "none" | "specific";
|
||||
|
||||
const getMcpScopeMode = (keyData: KeyResponse): McpScopeMode => {
|
||||
const mcpServers = keyData.object_permission?.mcp_servers;
|
||||
const hasAccessGroups = (keyData.object_permission?.mcp_access_groups?.length ?? 0) > 0;
|
||||
|
||||
if (mcpServers == null && !hasAccessGroups) {
|
||||
return "inherit";
|
||||
}
|
||||
if (Array.isArray(mcpServers) && mcpServers.length === 0 && !hasAccessGroups) {
|
||||
return "none";
|
||||
}
|
||||
return "specific";
|
||||
};
|
||||
|
||||
export function KeyEditView({
|
||||
keyData,
|
||||
onCancel,
|
||||
|
|
@ -179,6 +194,7 @@ export function KeyEditView({
|
|||
prompts: keyData.metadata?.prompts,
|
||||
tags: keyData.metadata?.tags,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_scope_mode: getMcpScopeMode(keyData),
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
accessGroups: keyData.object_permission?.mcp_access_groups || [],
|
||||
|
|
@ -212,6 +228,7 @@ export function KeyEditView({
|
|||
prompts: keyData.metadata?.prompts,
|
||||
tags: keyData.metadata?.tags,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_scope_mode: getMcpScopeMode(keyData),
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
accessGroups: keyData.object_permission?.mcp_access_groups || [],
|
||||
|
|
@ -612,13 +629,22 @@ export function KeyEditView({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="MCP Servers / Access Groups" name="mcp_servers_and_groups">
|
||||
<MCPServerSelector
|
||||
onChange={(val) => form.setFieldValue("mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
MCP Servers / Access Groups{" "}
|
||||
<Tooltip title="Inherit the team's MCP servers, grant no MCP access, or pick specific servers/access groups for this key">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="mcp_scope_mode"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="inherit">Inherit from team</Radio>
|
||||
<Radio value="none">No MCP access</Radio>
|
||||
<Radio value="specific">Specific servers</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* Hidden field to register mcp_tool_permissions with the form */}
|
||||
|
|
@ -629,20 +655,34 @@ export function KeyEditView({
|
|||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.mcp_scope_mode !== currentValues.mcp_scope_mode ||
|
||||
prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups ||
|
||||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div className="mb-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken || ""}
|
||||
selectedServers={form.getFieldValue("mcp_servers_and_groups")?.servers || []}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{() =>
|
||||
form.getFieldValue("mcp_scope_mode") === "specific" && (
|
||||
<>
|
||||
<Form.Item label="MCP Servers / Access Groups" name="mcp_servers_and_groups">
|
||||
<MCPServerSelector
|
||||
onChange={(val) => form.setFieldValue("mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="mb-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken || ""}
|
||||
selectedServers={form.getFieldValue("mcp_servers_and_groups")?.servers || []}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Agents / Access Groups" name="agents_and_groups">
|
||||
|
|
|
|||
|
|
@ -195,7 +195,13 @@ export default function KeyInfoView({
|
|||
delete formValues.vector_stores;
|
||||
}
|
||||
|
||||
if (formValues.mcp_servers_and_groups !== undefined) {
|
||||
// MCP scope is tri-state: "inherit" sends mcp_servers = null (backend inherits
|
||||
// the team's servers), "none" sends [] (explicitly zero), and "specific" sends
|
||||
// the selected servers/access groups/toolsets. The mode is the source of truth,
|
||||
// so switching specific -> inherit correctly resets a previously-set scope.
|
||||
const mcpScopeMode = formValues.mcp_scope_mode;
|
||||
const isSpecificMcpScope = mcpScopeMode === "specific";
|
||||
if (mcpScopeMode !== undefined) {
|
||||
const { servers, accessGroups, toolsets } = formValues.mcp_servers_and_groups || {
|
||||
servers: [],
|
||||
accessGroups: [],
|
||||
|
|
@ -203,18 +209,18 @@ export default function KeyInfoView({
|
|||
};
|
||||
formValues.object_permission = {
|
||||
...currentKeyData.object_permission,
|
||||
mcp_servers: servers || [],
|
||||
mcp_access_groups: accessGroups || [],
|
||||
mcp_toolsets: toolsets || [],
|
||||
mcp_servers: isSpecificMcpScope ? servers || [] : mcpScopeMode === "none" ? [] : null,
|
||||
mcp_access_groups: isSpecificMcpScope ? accessGroups || [] : [],
|
||||
mcp_toolsets: isSpecificMcpScope ? toolsets || [] : [],
|
||||
};
|
||||
// Remove mcp_servers_and_groups from the top level as it should be in object_permission
|
||||
delete formValues.mcp_servers_and_groups;
|
||||
}
|
||||
delete formValues.mcp_scope_mode;
|
||||
delete formValues.mcp_servers_and_groups;
|
||||
|
||||
// Handle MCP tool permissions
|
||||
// Tool permissions only apply when specific servers are chosen; clear them otherwise.
|
||||
if (formValues.mcp_tool_permissions !== undefined) {
|
||||
const mcpToolPermissions = formValues.mcp_tool_permissions || {};
|
||||
if (Object.keys(mcpToolPermissions).length > 0) {
|
||||
if (isSpecificMcpScope && Object.keys(mcpToolPermissions).length > 0) {
|
||||
formValues.object_permission = {
|
||||
...formValues.object_permission,
|
||||
mcp_tool_permissions: mcpToolPermissions,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue