Litellm mcp access group on UI (#12470)

* added mcp tools on internal user and divide it by teams

* add support for server api call

* Added frontend for test key

* added tools used output

* fix ui for servers

* All servers to personal

* change columns format

* revert ui logic

* Added vertical align

* fix mapped tests

* fix lint

* fix lint

* remove extra file

* fix ui test

* comments fixes

* change query type

* change query type

* mcp acces group init

* add ability to change server display on ui through access groups

* Mcp access group names UI (#12486)

* Added ui changes to reflect mcp_access_groups

* fix edit mcp page

* change to string array (#12491)

* change to string array

* Remove print

* add ability to change server display on ui through access groups

* Litellm mcp access groups accesses (#12498)

* added mcp access groups for keys and teams

* added access groups above servers

* fixed ruff

* fixed mypy

* revert couple changes
This commit is contained in:
Jugal D. Bhatt
2025-07-11 10:29:25 +05:30
committed by GitHub
parent 9d9562d0e8
commit e3437795bb
21 changed files with 951 additions and 480 deletions
@@ -155,7 +155,8 @@ model LiteLLM_UserTable {
model LiteLLM_ObjectPermissionTable {
object_permission_id String @id @default(uuid())
mcp_servers String[] @default([])
vector_stores String[] @default([])
vector_stores String[] @default([])
mcp_access_groups String[] @default([])
teams LiteLLM_TeamTable[]
verification_tokens LiteLLM_VerificationToken[]
organizations LiteLLM_OrganizationTable[]
@@ -176,6 +177,7 @@ model LiteLLM_MCPServerTable {
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
}
// Generate Tokens for Proxy
@@ -220,7 +220,17 @@ class MCPRequestHandler:
if key_object_permission is None:
return []
return key_object_permission.mcp_servers or []
# Get direct MCP servers
direct_mcp_servers = key_object_permission.mcp_servers or []
# Get MCP servers from access groups
access_group_servers = await MCPRequestHandler._get_mcp_servers_from_access_groups(
key_object_permission.mcp_access_groups or []
)
# Combine both lists
all_servers = direct_mcp_servers + access_group_servers
return list(set(all_servers))
@staticmethod
async def _get_allowed_mcp_servers_for_team(
@@ -257,4 +267,132 @@ class MCPRequestHandler:
if object_permissions is None:
return []
return object_permissions.mcp_servers or []
# Get direct MCP servers
direct_mcp_servers = object_permissions.mcp_servers or []
# Get MCP servers from access groups
access_group_servers = await MCPRequestHandler._get_mcp_servers_from_access_groups(
object_permissions.mcp_access_groups or []
)
# Combine both lists
all_servers = direct_mcp_servers + access_group_servers
return list(set(all_servers))
@staticmethod
async def _get_mcp_servers_from_access_groups(
access_groups: List[str]
) -> List[str]:
"""
Resolve MCP access groups to server IDs by querying the MCP server table
"""
from litellm.proxy.proxy_server import prisma_client
if not access_groups or prisma_client is None:
return []
try:
# Find all MCP servers that have any of the specified access groups
mcp_servers = await prisma_client.db.litellm_mcpservertable.find_many(
where={
"mcp_access_groups": {
"hasSome": access_groups
}
}
)
# Extract server IDs
server_ids = [server.server_id for server in mcp_servers]
return server_ids
except Exception as e:
verbose_logger.debug(f"Error getting MCP servers from access groups: {e}")
return []
@staticmethod
async def get_mcp_access_groups(
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[str]:
"""
Get list of MCP access groups for the given user/key based on permissions
"""
from typing import List
access_groups: List[str] = []
access_groups_for_key = (
await MCPRequestHandler._get_mcp_access_groups_for_key(user_api_key_auth)
)
access_groups_for_team = (
await MCPRequestHandler._get_mcp_access_groups_for_team(user_api_key_auth)
)
#########################################################
# If team has access groups, then key must have a subset of the team's access groups
#########################################################
if len(access_groups_for_team) > 0:
for access_group in access_groups_for_key:
if access_group in access_groups_for_team:
access_groups.append(access_group)
else:
access_groups = access_groups_for_key
return list(set(access_groups))
@staticmethod
async def _get_mcp_access_groups_for_key(
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[str]:
from litellm.proxy.proxy_server import prisma_client
if user_api_key_auth is None:
return []
if user_api_key_auth.object_permission_id is None:
return []
if prisma_client is None:
verbose_logger.debug("prisma_client is None")
return []
key_object_permission = (
await prisma_client.db.litellm_objectpermissiontable.find_unique(
where={"object_permission_id": user_api_key_auth.object_permission_id},
)
)
if key_object_permission is None:
return []
return key_object_permission.mcp_access_groups or []
@staticmethod
async def _get_mcp_access_groups_for_team(
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[str]:
"""
Get MCP access groups for the team
"""
from litellm.proxy.proxy_server import prisma_client
if user_api_key_auth is None:
return []
if user_api_key_auth.team_id is None:
return []
if prisma_client is None:
verbose_logger.debug("prisma_client is None")
return []
team_obj: Optional[LiteLLM_TeamTable] = (
await prisma_client.db.litellm_teamtable.find_unique(
where={"team_id": user_api_key_auth.team_id},
)
)
if team_obj is None:
verbose_logger.debug("team_obj is None")
return []
object_permissions = team_obj.object_permission
if object_permissions is None:
return []
return object_permissions.mcp_access_groups or []
+28 -8
View File
@@ -219,18 +219,28 @@ async def create_mcp_server(
if data.server_id is None:
data.server_id = str(uuid.uuid4())
# json dumps mcp_info
# Convert model to dict and handle JSON fields
data_dict = data.model_dump()
# Handle mcp_info serialization
mcp_info: Optional[str] = None
if data.mcp_info is not None:
mcp_info = safe_dumps(data.mcp_info)
del data.mcp_info
del data_dict["mcp_info"]
# Handle mcp_access_groups - it's already a List[str], no need to serialize
mcp_access_groups: Optional[list] = None
if data.mcp_access_groups is not None:
mcp_access_groups = data.mcp_access_groups
del data_dict["mcp_access_groups"]
mcp_server_record = await prisma_client.db.litellm_mcpservertable.create(
data={
**data.model_dump(),
**data_dict,
"created_by": touched_by,
"updated_by": touched_by,
"mcp_info": mcp_info,
"mcp_access_groups": mcp_access_groups,
}
)
return mcp_server_record
@@ -243,22 +253,32 @@ async def update_mcp_server(
Update a new mcp server record in the db
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# json dumps mcp_info
# Convert model to dict and handle JSON fields
data_dict = data.model_dump()
# Handle mcp_info serialization
mcp_info: Optional[str] = None
if data.mcp_info is not None:
mcp_info = safe_dumps(data.mcp_info)
del data.mcp_info
del data_dict["mcp_info"]
# Handle mcp_access_groups - it's already a List[str], no need to serialize
mcp_access_groups: Optional[list] = None
if data.mcp_access_groups is not None:
mcp_access_groups = data.mcp_access_groups
del data_dict["mcp_access_groups"]
mcp_server_record = await prisma_client.db.litellm_mcpservertable.update(
where={
"server_id": data.server_id,
},
data={
**data.model_dump(),
"mcp_info": mcp_info,
**data_dict,
"created_by": touched_by,
"updated_by": touched_by,
"mcp_info": mcp_info,
"mcp_access_groups": mcp_access_groups,
},
)
return mcp_server_record
File diff suppressed because one or more lines are too long
+7
View File
@@ -669,6 +669,7 @@ class ModelParams(LiteLLMPydanticObjectBase):
class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
mcp_servers: Optional[List[str]] = None
mcp_access_groups: Optional[List[str]] = None
vector_stores: Optional[List[str]] = None
@@ -848,6 +849,8 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
auth_type: Optional[MCPAuthType] = None
url: str
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
@@ -859,6 +862,8 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
auth_type: Optional[MCPAuthType] = None
url: str
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
@@ -876,6 +881,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
mcp_access_groups: List[str] = Field(default_factory=list)
mcp_info: Optional[MCPInfo] = None
@@ -1268,6 +1274,7 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
object_permission_id: str
mcp_servers: Optional[List[str]] = []
mcp_access_groups: Optional[List[str]] = []
vector_stores: Optional[List[str]] = []
@@ -10,6 +10,8 @@ Endpoints here:
- PUT `/v1/mcp/server` - Edits an existing mcp server.
- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`.
- GET `/v1/mcp/tools - lists all the tools available for a key
- GET `/v1/mcp/access_groups` - lists all available MCP access groups
"""
import importlib
@@ -109,6 +111,43 @@ if MCP_AVAILABLE:
return {"tools": tools}
@router.get(
"/access_groups",
tags=["mcp"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_mcp_access_groups(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get all available MCP access groups from the database
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"error": "Database not connected. Connect a database to your proxy"
},
)
try:
# Get all MCP servers and extract their access groups
mcp_servers = await prisma_client.db.litellm_mcpservertable.find_many()
# Extract all unique access groups
access_groups = set()
for server in mcp_servers:
if server.mcp_access_groups:
access_groups.update(server.mcp_access_groups)
# Convert to sorted list
access_groups_list = sorted(list(access_groups))
return {"access_groups": access_groups_list}
except Exception as e:
verbose_proxy_logger.debug(f"Error getting MCP access groups: {e}")
return {"access_groups": []}
## FastAPI Routes
@router.get(
@@ -221,6 +260,7 @@ if MCP_AVAILABLE:
created_by=server.created_by,
updated_at=server.updated_at,
updated_by=server.updated_by,
mcp_access_groups=server.mcp_access_groups if server.mcp_access_groups is not None else [],
mcp_info=server.mcp_info,
teams=cast(List[Dict[str, str | None]], server_to_teams_map.get(server.server_id, []))
)
+3
View File
@@ -155,6 +155,7 @@ model LiteLLM_UserTable {
model LiteLLM_ObjectPermissionTable {
object_permission_id String @id @default(uuid())
mcp_servers String[] @default([])
mcp_access_groups String[] @default([])
vector_stores String[] @default([])
teams LiteLLM_TeamTable[]
verification_tokens LiteLLM_VerificationToken[]
@@ -176,6 +177,8 @@ model LiteLLM_MCPServerTable {
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
}
// Generate Tokens for Proxy
+14 -12
View File
@@ -155,6 +155,7 @@ model LiteLLM_UserTable {
model LiteLLM_ObjectPermissionTable {
object_permission_id String @id @default(uuid())
mcp_servers String[] @default([])
mcp_access_groups String[] @default([])
vector_stores String[] @default([])
teams LiteLLM_TeamTable[]
verification_tokens LiteLLM_VerificationToken[]
@@ -164,18 +165,19 @@ model LiteLLM_ObjectPermissionTable {
// Holds the MCP server configuration
model LiteLLM_MCPServerTable {
server_id String @id @default(uuid())
alias String?
description String?
url String
transport String @default("sse")
spec_version String @default("2025-03-26")
auth_type String?
created_at DateTime? @default(now()) @map("created_at")
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
mcp_info Json? @default("{}")
server_id String @id @default(uuid())
alias String?
description String?
url String
transport String @default("sse")
spec_version String @default("2025-03-26")
auth_type String?
created_at DateTime? @default(now()) @map("created_at")
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
mcp_info Json? @default("{}")
mcp_access_groups String[]
}
// Generate Tokens for Proxy
@@ -3,8 +3,8 @@ import { Text } from "@tremor/react";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
interface PremiumMCPSelectorProps {
onChange: (values: string[]) => void;
value: string[];
onChange: (value: { servers: string[]; accessGroups: string[] }) => void;
value: { servers: string[]; accessGroups: string[] };
accessToken: string;
placeholder?: string;
premiumUser?: boolean;
@@ -33,6 +33,7 @@ import {
getPossibleUserRoles,
userFilterUICall,
keyCreateServiceAccountCall,
fetchMCPAccessGroups,
} from "./networking";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import PremiumVectorStoreSelector from "./common_components/PremiumVectorStoreSelector";
@@ -178,6 +179,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({
>({});
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [userSearchLoading, setUserSearchLoading] = useState<boolean>(false);
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
const handleOk = () => {
setIsModalVisible(false);
@@ -199,6 +202,22 @@ const CreateKey: React.FC<CreateKeyProps> = ({
}
}, [accessToken, userID, userRole]);
const fetchMcpAccessGroups = async () => {
try {
if (accessToken == null) {
return;
}
const groups = await fetchMCPAccessGroups(accessToken);
setMcpAccessGroups(groups);
} catch (error) {
console.error("Failed to fetch MCP access groups:", error);
}
};
useEffect(() => {
fetchMcpAccessGroups();
}, [accessToken]);
useEffect(() => {
const fetchGuardrails = async () => {
@@ -309,6 +328,16 @@ const CreateKey: React.FC<CreateKeyProps> = ({
// Remove the original field as it's now part of object_permission
delete formValues.allowed_mcp_server_ids;
}
// Transform allowed_mcp_access_groups into object_permission format
if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups;
// Remove the original field as it's now part of object_permission
delete formValues.allowed_mcp_access_groups;
}
let response;
if (keyOwner === "service_account") {
response = await keyCreateServiceAccountCall(accessToken, formValues);
@@ -584,290 +613,290 @@ const CreateKey: React.FC<CreateKeyProps> = ({
{/* Section 3: Optional Settings */}
{!isFormDisabled && (
<div className="mb-8">
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<Title className="m-0">Optional Settings</Title>
</AccordionHeader>
<AccordionBody>
<Form.Item
className="mt-4"
label={
<span>
Max Budget (USD){' '}
<Tooltip title="Maximum amount in USD this key can spend. When reached, the key will be blocked from making further requests">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="max_budget"
help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`}
rules={[
{
validator: async (_, value) => {
if (
value &&
team &&
team.max_budget !== null &&
value > team.max_budget
) {
throw new Error(
`Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`
);
}
},
},
]}
>
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Reset Budget{' '}
<Tooltip title="How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="budget_duration"
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue('budget_duration', value)} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Tokens per minute Limit (TPM){' '}
<Tooltip title="Maximum number of tokens this key can process per minute. Helps control usage and costs">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="tpm_limit"
help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`}
rules={[
{
validator: async (_, value) => {
if (
value &&
team &&
team.tpm_limit !== null &&
value > team.tpm_limit
) {
throw new Error(
`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`
);
}
},
},
]}
>
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Requests per minute Limit (RPM){' '}
<Tooltip title="Maximum number of API requests this key can make per minute. Helps prevent abuse and manage load">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="rpm_limit"
help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`}
rules={[
{
validator: async (_, value) => {
if (
value &&
team &&
team.rpm_limit !== null &&
value > team.rpm_limit
) {
throw new Error(
`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`
);
}
},
},
]}
>
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label={
<span>
Expire Key{' '}
<Tooltip title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days)">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="duration"
className="mt-4"
>
<TextInput placeholder="e.g., 30d" />
</Form.Item>
<Form.Item
label={
<span>
Guardrails{' '}
<Tooltip title="Apply safety guardrails to this key to filter content or enforce policies">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
>
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</a>
</Tooltip>
</span>
}
name="guardrails"
className="mt-4"
help="Select existing guardrails or enter new ones"
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="Select or enter guardrails"
options={guardrailsList.map(name => ({ value: name, label: name }))}
/>
</Form.Item>
<Form.Item
label={
<span>
Allowed Vector Stores{' '}
<Tooltip title="Select which vector stores this key can access. If none selected, the key will have access to all available vector stores">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="allowed_vector_store_ids"
className="mt-4"
help="Select vector stores this key can access. Leave empty for access to all vector stores"
>
<PremiumVectorStoreSelector
onChange={(values) => form.setFieldValue('allowed_vector_store_ids', values)}
value={form.getFieldValue('allowed_vector_store_ids')}
accessToken={accessToken}
placeholder="Select vector stores (optional)"
premiumUser={premiumUser}
/>
</Form.Item>
<Form.Item
label={
<span>
Allowed MCP Servers{' '}
<Tooltip title="Select which MCP servers this key can access. If none selected, the key will have access to all available MCP servers">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="allowed_mcp_server_ids"
className="mt-4"
help="Select MCP servers this key can access. Leave empty for access to all MCP servers"
>
<PremiumMCPSelector
onChange={(values) => form.setFieldValue('allowed_mcp_server_ids', values)}
value={form.getFieldValue('allowed_mcp_server_ids')}
accessToken={accessToken}
placeholder="Select MCP servers (optional)"
premiumUser={premiumUser}
/>
</Form.Item>
<Form.Item
label={
<span>
Metadata{' '}
<Tooltip title="JSON object with additional information about this key. Used for tracking or custom logic">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="metadata"
className="mt-4"
>
<Input.TextArea
rows={4}
placeholder="Enter metadata as JSON"
/>
</Form.Item>
<Form.Item
label={
<span>
Tags{' '}
<Tooltip title="Tags for tracking spend and/or doing tag-based routing. Used for analytics and filtering">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="tags"
className="mt-4"
help={`Tags for tracking spend and/or doing tag-based routing.`}
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="Enter tags"
tokenSeparators={[',']}
options={predefinedTags}
/>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Logging Settings</b>
</AccordionHeader>
<AccordionBody>
<div className="mt-4">
<PremiumLoggingSettings
value={loggingSettings}
onChange={setLoggingSettings}
premiumUser={premiumUser}
/>
</div>
</AccordionBody>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<div className="flex items-center gap-2">
<b>Advanced Settings</b>
<Tooltip title={
<Accordion className="mt-4 mb-4" onClick={() => { if (!mcpAccessGroupsLoaded) { fetchMcpAccessGroups(); setMcpAccessGroupsLoaded(true); } }}>
<AccordionHeader>
<Title className="m-0">Optional Settings</Title>
</AccordionHeader>
<AccordionBody>
<Form.Item
className="mt-4"
label={
<span>
Learn more about advanced settings in our{' '}
<a
href={proxyBaseUrl ? `${proxyBaseUrl}/#/key%20management/generate_key_fn_key_generate_post`: `/#/key%20management/generate_key_fn_key_generate_post`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300"
>
documentation
</a>
Max Budget (USD){' '}
<Tooltip title="Maximum amount in USD this key can spend. When reached, the key will be blocked from making further requests">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}>
<InfoCircleOutlined className="text-gray-400 hover:text-gray-300 cursor-help" />
</Tooltip>
</div>
</AccordionHeader>
<AccordionBody>
<SchemaFormFields
schemaComponent="GenerateKeyRequest"
form={form}
excludedFields={['key_alias', 'team_id', 'models', 'duration', 'metadata', 'tags', 'guardrails', "max_budget", "budget_duration", "tpm_limit", "rpm_limit"]}
}
name="max_budget"
help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`}
rules={[
{
validator: async (_, value) => {
if (
value &&
team &&
team.max_budget !== null &&
value > team.max_budget
) {
throw new Error(
`Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`
);
}
},
},
]}
>
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Reset Budget{' '}
<Tooltip title="How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="budget_duration"
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
>
<BudgetDurationDropdown onChange={(value) => form.setFieldValue('budget_duration', value)} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Tokens per minute Limit (TPM){' '}
<Tooltip title="Maximum number of tokens this key can process per minute. Helps control usage and costs">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="tpm_limit"
help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`}
rules={[
{
validator: async (_, value) => {
if (
value &&
team &&
team.tpm_limit !== null &&
value > team.tpm_limit
) {
throw new Error(
`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`
);
}
},
},
]}
>
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
className="mt-4"
label={
<span>
Requests per minute Limit (RPM){' '}
<Tooltip title="Maximum number of API requests this key can make per minute. Helps prevent abuse and manage load">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="rpm_limit"
help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`}
rules={[
{
validator: async (_, value) => {
if (
value &&
team &&
team.rpm_limit !== null &&
value > team.rpm_limit
) {
throw new Error(
`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`
);
}
},
},
]}
>
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label={
<span>
Expire Key{' '}
<Tooltip title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days)">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="duration"
className="mt-4"
>
<TextInput placeholder="e.g., 30d" />
</Form.Item>
<Form.Item
label={
<span>
Guardrails{' '}
<Tooltip title="Apply safety guardrails to this key to filter content or enforce policies">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
>
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</a>
</Tooltip>
</span>
}
name="guardrails"
className="mt-4"
help="Select existing guardrails or enter new ones"
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="Select or enter guardrails"
options={guardrailsList.map(name => ({ value: name, label: name }))}
/>
</AccordionBody>
</Accordion>
</AccordionBody>
</Accordion>
</div>
</Form.Item>
<Form.Item
label={
<span>
Allowed Vector Stores{' '}
<Tooltip title="Select which vector stores this key can access. If none selected, the key will have access to all available vector stores">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="allowed_vector_store_ids"
className="mt-4"
help="Select vector stores this key can access. Leave empty for access to all vector stores"
>
<PremiumVectorStoreSelector
onChange={(values) => form.setFieldValue('allowed_vector_store_ids', values)}
value={form.getFieldValue('allowed_vector_store_ids')}
accessToken={accessToken}
placeholder="Select vector stores (optional)"
premiumUser={premiumUser}
/>
</Form.Item>
<Form.Item
label={
<span>
Allowed MCP Servers{' '}
<Tooltip title="Select which MCP servers or access groups this key can access. Leave empty for access to all.">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="allowed_mcp_servers_and_groups"
className="mt-4"
help="Select MCP servers or access groups this key can access. Leave empty for access to all."
>
<PremiumMCPSelector
onChange={val => form.setFieldValue('allowed_mcp_servers_and_groups', val)}
value={form.getFieldValue('allowed_mcp_servers_and_groups')}
accessToken={accessToken}
placeholder="Select MCP servers or access groups (optional)"
premiumUser={premiumUser}
/>
</Form.Item>
<Form.Item
label={
<span>
Metadata{' '}
<Tooltip title="JSON object with additional information about this key. Used for tracking or custom logic">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="metadata"
className="mt-4"
>
<Input.TextArea
rows={4}
placeholder="Enter metadata as JSON"
/>
</Form.Item>
<Form.Item
label={
<span>
Tags{' '}
<Tooltip title="Tags for tracking spend and/or doing tag-based routing. Used for analytics and filtering">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="tags"
className="mt-4"
help={`Tags for tracking spend and/or doing tag-based routing.`}
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="Enter tags"
tokenSeparators={[',']}
options={predefinedTags}
/>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Logging Settings</b>
</AccordionHeader>
<AccordionBody>
<div className="mt-4">
<PremiumLoggingSettings
value={loggingSettings}
onChange={setLoggingSettings}
premiumUser={premiumUser}
/>
</div>
</AccordionBody>
</Accordion>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<div className="flex items-center gap-2">
<b>Advanced Settings</b>
<Tooltip title={
<span>
Learn more about advanced settings in our{' '}
<a
href={proxyBaseUrl ? `${proxyBaseUrl}/#/key%20management/generate_key_fn_key_generate_post`: `/#/key%20management/generate_key_fn_key_generate_post`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300"
>
documentation
</a>
</span>
}>
<InfoCircleOutlined className="text-gray-400 hover:text-gray-300 cursor-help" />
</Tooltip>
</div>
</AccordionHeader>
<AccordionBody>
<SchemaFormFields
schemaComponent="GenerateKeyRequest"
form={form}
excludedFields={['key_alias', 'team_id', 'models', 'duration', 'metadata', 'tags', 'guardrails', "max_budget", "budget_duration", "tpm_limit", "rpm_limit"]}
/>
</AccordionBody>
</Accordion>
</AccordionBody>
</Accordion>
</div>
)}
<div style={{ textAlign: "right", marginTop: "10px" }}>
@@ -9,6 +9,7 @@ import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
import EditLoggingSettings from "./team/EditLoggingSettings";
import { extractLoggingSettings, formatMetadataForDisplay } from "./key_info_utils";
import { fetchMCPAccessGroups } from "./networking";
interface KeyEditViewProps {
keyData: KeyResponse;
@@ -54,6 +55,20 @@ export function KeyEditView({
const [userModels, setUserModels] = useState<string[]>([]);
const team = teams?.find(team => team.team_id === keyData.team_id);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
const fetchMcpAccessGroups = async () => {
if (!accessToken) return;
if (mcpAccessGroupsLoaded) return;
try {
const groups = await fetchMCPAccessGroups(accessToken);
setMcpAccessGroups(groups);
setMcpAccessGroupsLoaded(true);
} catch (error) {
console.error("Failed to fetch MCP access groups:", error);
}
};
useEffect(() => {
const fetchModels = async () => {
@@ -185,16 +200,15 @@ export function KeyEditView({
/>
</Form.Item>
<Form.Item label="MCP Servers" name="mcp_servers">
<Form.Item label="MCP Servers / Access Groups" name="mcp_servers_and_groups">
<MCPServerSelector
onChange={(values) => form.setFieldValue('mcp_servers', values)}
value={form.getFieldValue('mcp_servers')}
accessToken={accessToken || ""}
placeholder="Select MCP servers"
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>
<Form.Item label="Team ID" name="team_id">
<Select
placeholder="Select team"
@@ -1,12 +1,11 @@
import React, { useEffect, useState } from 'react';
import { Select } from 'antd';
import { fetchMCPServers } from '../networking';
import { fetchMCPServers, fetchMCPAccessGroups } from '../networking';
import { MCPServer } from '../mcp_tools/types';
interface MCPServerSelectorProps {
onChange: (selectedMCPServers: string[]) => void;
value?: string[];
onChange: (selected: { servers: string[]; accessGroups: string[] }) => void;
value?: { servers: string[]; accessGroups: string[] };
className?: string;
accessToken: string;
placeholder?: string;
@@ -22,51 +21,93 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
disabled = false
}) => {
const [mcpServers, setMCPServers] = useState<MCPServer[]>([]);
const [accessGroups, setAccessGroups] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchMCPServerList = async () => {
const fetchData = async () => {
if (!accessToken) return;
setLoading(true);
try {
const response = await fetchMCPServers(accessToken);
if (response && Array.isArray(response)) {
// Direct array response
setMCPServers(response);
} else if (response.data && Array.isArray(response.data)) {
// Response with data wrapper
setMCPServers(response.data);
}
const [serversRes, groupsRes] = await Promise.all([
fetchMCPServers(accessToken),
fetchMCPAccessGroups(accessToken)
]);
let servers = Array.isArray(serversRes) ? serversRes : (serversRes.data || []);
let groups = Array.isArray(groupsRes) ? groupsRes : (groupsRes.data || []);
setMCPServers(servers);
setAccessGroups(groups);
} catch (error) {
console.error("Error fetching MCP servers:", error);
console.error("Error fetching MCP servers or access groups:", error);
} finally {
setLoading(false);
}
};
fetchMCPServerList();
fetchData();
}, [accessToken]);
// Combine options, access groups first
const options = [
...accessGroups.map(group => ({
label: group,
value: group,
isAccessGroup: true
})),
...mcpServers.map(server => ({
label: `${server.alias || server.server_id} (${server.server_id})`,
value: server.server_id,
isAccessGroup: false
}))
];
// Flatten value for Select
const selectedValues = [
...(value?.servers || []),
...(value?.accessGroups || [])
];
// Handle selection
const handleChange = (selected: string[]) => {
const servers = selected.filter(v => !accessGroups.includes(v));
const accessGroupsSelected = selected.filter(v => accessGroups.includes(v));
onChange({ servers, accessGroups: accessGroupsSelected });
};
return (
<div>
<Select
mode="multiple"
placeholder={placeholder}
onChange={onChange}
value={value}
onChange={handleChange}
value={selectedValues}
loading={loading}
className={className}
options={mcpServers.map(server => ({
label: `${server.alias} (${server.server_id})`,
value: server.server_id,
title: server.description || server.alias,
}))}
optionFilterProp="label"
showSearch
style={{ width: '100%' }}
disabled={disabled}
/>
>
{options.map(opt => (
<Select.Option
key={opt.value}
value={opt.value}
>
{opt.isAccessGroup && (
<span style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: '#1890ff',
marginRight: 8,
verticalAlign: 'middle',
}} />
)}
{opt.label}
{opt.isAccessGroup && <span style={{ color: '#1890ff', marginLeft: 8 }}>(Access Group)</span>}
</Select.Option>
))}
</Select>
</div>
);
};
@@ -6,8 +6,10 @@ import {
Select,
message,
Button as AntdButton,
Space,
Input,
} from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { MCPServer, MCPServerCostInfo } from "./types";
@@ -33,20 +35,26 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [tools, setTools] = useState<any[]>([]);
const handleCreate = async (formValues: Record<string, any>) => {
setIsLoading(true);
try {
// Transform access groups into objects with name property
const accessGroups = formValues.mcp_access_groups
// Prepare the payload with cost configuration
const payload = {
...formValues,
mcp_info: {
server_name: formValues.alias || formValues.url,
description: formValues.description,
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null
}
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null,
},
mcp_access_groups: accessGroups
};
console.log(`Payload: ${JSON.stringify(payload)}`);
@@ -252,19 +260,37 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="2024-11-05">2024-11-05</Select.Option>
</Select>
</Form.Item>
</div>
{/* Connection Status Section */}
<div className="mt-8 pt-6 border-t border-gray-200">
<MCPConnectionStatus
accessToken={accessToken}
formValues={formValues}
onToolsLoaded={setTools}
/>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
MCP Access Groups
<Tooltip title="Specify access groups for this MCP server. Users must be in at least one of these groups to access the server.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="mcp_access_groups"
className="mb-4"
>
<Select
mode="tags"
showSearch
placeholder="Select existing groups or type to create new ones"
optionFilterProp="children"
tokenSeparators={[',']}
options={mcpAccessGroups.map((group) => ({
value: group,
label: group
}))}
maxTagCount="responsive"
allowClear
/>
</Form.Item>
</div>
{/* Cost Configuration Section */}
<div className="mt-6">
<div className="mt-8 pt-6 border-t border-gray-200">
<MCPServerCostConfig
value={costConfig}
onChange={setCostConfig}
@@ -56,6 +56,20 @@ export const mcpServerColumns = (
</span>
),
},
{
id: "mcp_access_groups",
header: "Access Groups",
cell: ({ row }) => {
const groups = row.original.mcp_access_groups;
if (Array.isArray(groups) && groups.length > 0) {
// If string array
if (typeof groups[0] === "string") {
return <span>{groups.join(", ")}</span>;
}
}
return <span className="text-gray-400 italic">None</span>;
},
},
{
header: "Created At",
accessorKey: "created_at",
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from "react";
import { Form, Select, Button as AntdButton, message } from "antd";
import { Form, Select, Button as AntdButton, message, Input, Space, Tooltip } from "antd";
import { Button, TextInput, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { MCPServer, MCPServerCostInfo } from "./types";
import { updateMCPServer, testMCPToolsListRequest } from "../networking";
import MCPServerCostConfig from "./mcp_server_cost_config";
import { MinusCircleOutlined, PlusOutlined, InfoCircleOutlined } from "@ant-design/icons";
interface MCPServerEditProps {
mcpServer: MCPServer;
@@ -25,6 +26,15 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
}
}, [mcpServer]);
// Transform string array to object array for initial form values
useEffect(() => {
if (mcpServer.mcp_access_groups) {
// If access groups are objects, extract the name property; if strings, use as is
const groupNames = mcpServer.mcp_access_groups.map((g: any) => typeof g === 'string' ? g : g.name || String(g));
form.setFieldValue('mcp_access_groups', groupNames);
}
}, [mcpServer]);
// Fetch tools when component mounts
useEffect(() => {
fetchTools();
@@ -68,6 +78,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
const handleSave = async (values: Record<string, any>) => {
if (!accessToken) return;
try {
// Ensure access groups is always a string array
const accessGroups = (values.mcp_access_groups || []).map((g: any) => typeof g === 'string' ? g : g.name || String(g));
// Prepare the payload with cost configuration
const payload = {
...values,
@@ -76,7 +89,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
server_name: values.alias || values.url,
description: values.description,
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null
}
},
mcp_access_groups: accessGroups
};
const updated = await updateMCPServer(accessToken, payload);
@@ -125,6 +139,29 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
<Select.Option value="2024-11-05">2024-11-05</Select.Option>
</Select>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
MCP Access Groups
<Tooltip title="Define access groups for this MCP server. Each group represents a set of permissions.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="mcp_access_groups"
getValueFromEvent={value => value}
>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="Add or select access groups"
tokenSeparators={[',']}
// Ensure value is always an array of strings
getPopupContainer={trigger => trigger.parentNode}
/>
</Form.Item>
<div className="flex justify-end gap-2">
<AntdButton onClick={onCancel}>Cancel</AntdButton>
<Button type="submit">Save Changes</Button>
@@ -153,50 +153,64 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
onSuccess={handleSuccess}
/>
) : (
<div className="space-y-6">
<div className="space-y-4">
<div>
<Text className="font-medium">Server Name</Text>
<div>{mcpServer.alias}</div>
<div className="space-y-4">
<div>
<Text className="font-medium">Server Name</Text>
<div>{mcpServer.alias}</div>
</div>
<div>
<Text className="font-medium">Description</Text>
<div>{mcpServer.description}</div>
</div>
<div>
<Text className="font-medium">URL</Text>
<div className="font-mono break-all overflow-wrap-anywhere max-w-full flex items-center gap-2">
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
{hasToken && (
<button
onClick={() => setShowFullUrl(!showFullUrl)}
className="p-1 hover:bg-gray-100 rounded"
>
<Icon
icon={showFullUrl ? EyeOffIcon : EyeIcon}
size="sm"
className="text-gray-500"
/>
</button>
)}
</div>
</div>
<div>
<Text className="font-medium">Transport</Text>
<div>{handleTransport(mcpServer.transport)}</div>
</div>
<div>
<Text className="font-medium">Auth Type</Text>
<div>{handleAuth(mcpServer.auth_type)}</div>
</div>
<div>
<Text className="font-medium">Spec Version</Text>
<div>{mcpServer.spec_version}</div>
</div>
<div>
<Text className="font-medium">Access Groups</Text>
<div>
<Text className="font-medium">Description</Text>
<div>{mcpServer.description}</div>
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
<div className="flex flex-wrap gap-2">
{mcpServer.mcp_access_groups.map((group: any, index: number) => (
<span
key={index}
className="px-2 py-1 bg-gray-100 rounded-md text-sm"
>
{typeof group === 'string' ? group : group?.name ?? ''}
</span>
))}
</div>
) : (
<Text className="text-gray-500">No access groups defined</Text>
)}
</div>
<div>
<Text className="font-medium">URL</Text>
<div className="font-mono break-all overflow-wrap-anywhere max-w-full flex items-center gap-2">
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
{hasToken && (
<button
onClick={() => setShowFullUrl(!showFullUrl)}
className="p-1 hover:bg-gray-100 rounded"
>
<Icon
icon={showFullUrl ? EyeOffIcon : EyeIcon}
size="sm"
className="text-gray-500"
/>
</button>
)}
</div>
</div>
<div>
<Text className="font-medium">Transport</Text>
<div>{handleTransport(mcpServer.transport)}</div>
</div>
<div>
<Text className="font-medium">Auth Type</Text>
<div>{handleAuth(mcpServer.auth_type)}</div>
</div>
<div>
<Text className="font-medium">Spec Version</Text>
<div>{mcpServer.spec_version}</div>
</div>
</div>
{/* Cost Configuration Section */}
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
</div>
</div>
)}
</Card>
@@ -1,13 +1,9 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Modal,
Tooltip,
message,
Select,
Space,
Typography
} from "antd";
import {
TabPanel,
@@ -16,8 +12,6 @@ import {
TabList,
Tab,
} from "@tremor/react";
import { LinkIcon } from "lucide-react";
import {
Grid,
Col,
@@ -26,7 +20,6 @@ import {
} from "@tremor/react";
import { DataTable } from "../view_logs/table";
import { mcpServerColumns } from "./mcp_server_columns";
import {
deleteMCPServer,
fetchMCPServers,
@@ -35,8 +28,6 @@ import {
MCPServer,
MCPServerProps,
Team,
handleAuth,
handleTransport,
} from "./types";
import { isAdminRole } from "@/utils/roles";
import { MCPServerView } from "./mcp_server_view";
@@ -45,21 +36,18 @@ import MCPConnect from "./mcp_connect";
const { Option } = Select;
interface DeleteModalProps {
const DeleteModal: React.FC<{
isModalOpen: boolean;
title: string;
confirmDelete: () => void;
cancelDelete: () => void;
}
const DeleteModal: React.FC<DeleteModalProps> = ({
}> = ({
isModalOpen,
title,
confirmDelete,
cancelDelete,
}) => {
if (!isModalOpen) return null;
return (
<Modal
open={isModalOpen}
@@ -82,7 +70,6 @@ const MCPServers: React.FC<MCPServerProps> = ({
userRole,
userID,
}) => {
// Query to fetch MCP tools
const {
data: mcpServers,
isLoading: isLoadingServers,
@@ -96,26 +83,21 @@ const MCPServers: React.FC<MCPServerProps> = ({
enabled: !!accessToken,
}) as { data: MCPServer[]; isLoading: boolean; refetch: () => void };
const createMCPServer = (newMcpServer: any) => {
refetch();
};
// state
const [serverIdToDelete, setServerToDelete] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [editServer, setEditServer] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<string>("all");
const [selectedMcpAccessGroup, setSelectedMcpAccessGroup] = useState<string>("all");
const [filteredServers, setFilteredServers] = useState<MCPServer[]>([]);
const [currentTeam, setCurrentTeam] = useState<string>("all");
const [modelViewMode, setModelViewMode] = useState<string>("all");
// Get unique teams from all servers
const uniqueTeams = React.useMemo(() => {
if (!mcpServers) return [];
const teamsSet = new Set<string>();
const uniqueTeamsArray: Team[] = [];
mcpServers.forEach((server: MCPServer) => {
if (server.teams) {
server.teams.forEach((team: Team) => {
@@ -130,48 +112,63 @@ const MCPServers: React.FC<MCPServerProps> = ({
return uniqueTeamsArray;
}, [mcpServers]);
// Get unique MCP access groups from all servers
const uniqueMcpAccessGroups = React.useMemo(() => {
if (!mcpServers) return [];
return Array.from(new Set(
mcpServers.flatMap(server => server.mcp_access_groups)
));
}, [mcpServers]);
// Handle team filter change
const handleTeamChange = (teamId: string) => {
setSelectedTeam(teamId);
setCurrentTeam(teamId);
if (!mcpServers) return;
if (teamId === "all") {
setFilteredServers(mcpServers);
} else if (teamId === "personal") {
// For now, show empty list for personal servers
setFilteredServers([]);
} else {
const filtered = mcpServers.filter(server =>
server.teams?.some(team => team.team_id === teamId)
);
setFilteredServers(filtered);
}
filterServers(teamId, selectedMcpAccessGroup);
};
React.useEffect(() => {
if (mcpServers) {
// Filter servers based on current team selection
if (currentTeam === "all") {
setFilteredServers(mcpServers);
} else if (currentTeam === "personal") {
// For now, show empty list for personal servers
setFilteredServers([]);
} else {
const filtered = mcpServers.filter(server =>
server.teams?.some(team => team.team_id === currentTeam)
);
setFilteredServers(filtered);
}
// Handle MCP access group filter change
const handleMcpAccessGroupChange = (group: string) => {
setSelectedMcpAccessGroup(group);
filterServers(selectedTeam, group);
};
// Filtering logic for both team and access group
const filterServers = (teamId: string, group: string) => {
if (!mcpServers) return setFilteredServers([]);
let filtered = mcpServers;
if (teamId === "personal") {
setFilteredServers([]);
return;
}
}, [mcpServers, currentTeam]);
if (teamId !== "all") {
filtered = filtered.filter(server =>
server.teams?.some(team => team.team_id === teamId)
);
}
if (group !== "all") {
filtered = filtered.filter(server =>
server.mcp_access_groups?.some((g: any) =>
typeof g === 'string' ? g === group : g && g.name === group
)
);
}
setFilteredServers(filtered);
};
// Initial and effect-based filtering
useEffect(() => {
filterServers(selectedTeam, selectedMcpAccessGroup);
// eslint-disable-next-line
}, [mcpServers]);
const columns = React.useMemo(
() =>
mcpServerColumns(
userRole ?? "",
(serverId: string) => setSelectedServerId(serverId),
(serverId: string) => {
setSelectedServerId(serverId);
setEditServer(false);
},
(serverId: string) => {
setSelectedServerId(serverId);
setEditServer(true);
@@ -182,7 +179,6 @@ const MCPServers: React.FC<MCPServerProps> = ({
);
function handleDelete(server_id: string) {
// Set the team to delete and open the confirmation modal
setServerToDelete(server_id);
setIsDeleteModalOpen(true);
}
@@ -191,31 +187,22 @@ const MCPServers: React.FC<MCPServerProps> = ({
if (serverIdToDelete == null || accessToken == null) {
return;
}
try {
await deleteMCPServer(accessToken, serverIdToDelete);
// Successfully completed the deletion. Update the state to trigger a rerender.
message.success("Deleted MCP Server successfully");
refetch();
} catch (error) {
console.error("Error deleting the mcp server:", error);
// Handle any error situations, such as displaying an error message to the user.
}
// Close the confirmation modal and reset the serverToDelete
setIsDeleteModalOpen(false);
setServerToDelete(null);
};
const cancelDelete = () => {
// Close the confirmation modal and reset the serverToDelete
setIsDeleteModalOpen(false);
setServerToDelete(null);
};
// Check if user is internal or admin
const canAccessMCPServers = isAdminRole(userRole!) || (userRole && userRole.includes('internal'));
if (!accessToken || !userRole || !userID) {
return (
<div className="p-6 text-center text-gray-500">
@@ -228,7 +215,7 @@ const MCPServers: React.FC<MCPServerProps> = ({
selectedServerId ? (
<MCPServerView
mcpServer={
mcpServers.find(
filteredServers.find(
(server: MCPServer) => server.server_id === selectedServerId
) || {
server_id: '',
@@ -267,7 +254,7 @@ const MCPServers: React.FC<MCPServerProps> = ({
<CreateMCPServer
userRole={userRole}
accessToken={accessToken}
onCreateSuccess={createMCPServer}
onCreateSuccess={refetch}
/>
)}
</div>
@@ -277,7 +264,7 @@ const MCPServers: React.FC<MCPServerProps> = ({
<div className="flex items-center gap-4">
<Text className="text-lg font-semibold text-gray-900">Current Team:</Text>
<Select
value={currentTeam}
value={selectedTeam}
onChange={handleTeamChange}
style={{ width: 300 }}
>
@@ -304,6 +291,27 @@ const MCPServers: React.FC<MCPServerProps> = ({
</Option>
))}
</Select>
<Text className="text-lg font-semibold text-gray-900 ml-6">Access Group:</Text>
<Select
value={selectedMcpAccessGroup}
onChange={handleMcpAccessGroupChange}
style={{ width: 300 }}
>
<Option value="all">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium">All Access Groups</span>
</div>
</Option>
{uniqueMcpAccessGroups.map((group) => (
<Option key={group} value={group}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">{group}</span>
</div>
</Option>
))}
</Select>
</div>
</div>
</div>
@@ -134,6 +134,7 @@ export interface MCPServer {
updated_at: string;
updated_by: string;
teams?: Team[];
mcp_access_groups?: string[];
}
export interface MCPServerProps {
@@ -4837,6 +4837,38 @@ export const fetchMCPServers = async (accessToken: string) => {
}
};
export const fetchMCPAccessGroups = async (accessToken: string) => {
try {
// Construct base URL
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/access_groups`
: `/v1/mcp/access_groups`;
console.log("Fetching MCP access groups from:", url);
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log("Fetched MCP access groups:", data);
return data.access_groups || [];
} catch (error) {
console.error("Failed to fetch MCP access groups:", error);
throw error;
}
};
export const createMCPServer = async (
accessToken: string,
formValues: Record<string, any> // Assuming formValues is an object
@@ -40,6 +40,7 @@ import PremiumVectorStoreSelector from "../common_components/PremiumVectorStoreS
import { formatNumberWithCommas } from "@/utils/dataUtils";
import EditLoggingSettings from "./EditLoggingSettings";
import LoggingSettingsView from "../logging_settings_view";
import { fetchMCPAccessGroups } from "../networking";
export interface TeamMembership {
user_id: string;
@@ -126,6 +127,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
console.log("userModels in team info", userModels);
@@ -149,6 +152,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
fetchTeamInfo();
}, [teamId, accessToken]);
const fetchMcpAccessGroups = async () => {
if (!accessToken) return;
if (mcpAccessGroupsLoaded) return;
try {
const groups = await fetchMCPAccessGroups(accessToken);
setMcpAccessGroups(groups);
setMcpAccessGroupsLoaded(true);
} catch (error) {
console.error("Failed to fetch MCP access groups:", error);
}
};
const handleMemberCreate = async (values: any) => {
try {
if (accessToken == null) return;
@@ -288,13 +303,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
// Handle object_permission updates
if (values.vector_stores !== undefined || values.mcp_servers !== undefined) {
updateData.object_permission = {
...teamData?.team_info.object_permission,
vector_stores: values.vector_stores || [],
mcp_servers: values.mcp_servers || []
};
}
const { servers, accessGroups } = values.mcp_servers_and_groups || { servers: [], accessGroups: [] };
if (!updateData.object_permission) updateData.object_permission = {};
updateData.object_permission.mcp_servers = servers;
updateData.object_permission.mcp_access_groups = accessGroups;
delete values.mcp_servers_and_groups;
const response = await teamUpdateCall(accessToken, updateData);
@@ -452,7 +465,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
logging_settings: info.metadata?.logging || [],
organization_id: info.organization_id,
vector_stores: info.object_permission?.vector_stores || [],
mcp_servers: info.object_permission?.mcp_servers || []
mcp_servers: info.object_permission?.mcp_servers || [],
mcp_access_groups: info.object_permission?.mcp_servers || [],
mcp_servers_and_groups: info.object_permission?.mcp_servers || []
}}
layout="vertical"
>
@@ -547,12 +562,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item label="MCP Servers" name="mcp_servers">
<Form.Item label="MCP Servers / Access Groups" name="mcp_servers_and_groups">
<MCPServerSelector
onChange={(values) => form.setFieldValue('mcp_servers', values)}
value={form.getFieldValue('mcp_servers')}
accessToken={accessToken || ""}
placeholder="Select MCP servers"
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>
<Form.Item label="Organization ID" name="organization_id">
+35 -8
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Typography } from "antd";
import { teamDeleteCall, teamUpdateCall, teamInfoCall, Organization, DEFAULT_ORGANIZATION } from "./networking";
import { teamDeleteCall, teamUpdateCall, teamInfoCall, Organization, DEFAULT_ORGANIZATION, fetchMCPAccessGroups } from "./networking";
import TeamMemberModal from "@/components/team/edit_membership";
import { fetchTeams } from "./common_components/fetch_teams";
import {
@@ -184,6 +184,8 @@ const Teams: React.FC<TeamProps> = ({
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
useEffect(() => {
console.log(`currentOrgForCreateTeam: ${currentOrgForCreateTeam}`);
@@ -214,6 +216,22 @@ const Teams: React.FC<TeamProps> = ({
fetchGuardrails();
}, [accessToken]);
const fetchMcpAccessGroups = async () => {
try {
if (accessToken == null) {
return;
}
const groups = await fetchMCPAccessGroups(accessToken);
setMcpAccessGroups(groups);
} catch (error) {
console.error("Failed to fetch MCP access groups:", error);
}
};
useEffect(() => {
fetchMcpAccessGroups();
}, [accessToken]);
useEffect(() => {
const fetchTeamInfo = () => {
if (!teams) return;
@@ -366,6 +384,15 @@ const Teams: React.FC<TeamProps> = ({
formValues.object_permission.mcp_servers = formValues.allowed_mcp_server_ids;
delete formValues.allowed_mcp_server_ids;
}
// Transform allowed_mcp_access_groups into object_permission
if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) {
if (!formValues.object_permission) {
formValues.object_permission = {};
}
formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups;
delete formValues.allowed_mcp_access_groups;
}
const response: any = await teamCreateCall(accessToken, formValues);
if (teams !== null) {
setTeams([...teams, response]);
@@ -1062,7 +1089,7 @@ const Teams: React.FC<TeamProps> = ({
<NumericalInput step={1} width={400} />
</Form.Item>
<Accordion className="mt-20 mb-8">
<Accordion className="mt-20 mb-8" onClick={() => { if (!mcpAccessGroupsLoaded) { fetchMcpAccessGroups(); setMcpAccessGroupsLoaded(true); } }}>
<AccordionHeader>
<b>Additional Settings</b>
</AccordionHeader>
@@ -1152,20 +1179,20 @@ const Teams: React.FC<TeamProps> = ({
label={
<span>
Allowed MCP Servers{' '}
<Tooltip title="Select which MCP servers this team can access by default. Leave empty for access to all MCP servers">
<Tooltip title="Select which MCP servers or access groups this team can access by default. Leave empty for access to all.">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="allowed_mcp_server_ids"
name="allowed_mcp_servers_and_groups"
className="mt-8"
help="Select MCP servers this team can access. Leave empty for access to all MCP servers"
help="Select MCP servers or access groups this team can access. Leave empty for access to all."
>
<PremiumMCPSelector
onChange={(values) => form.setFieldValue('allowed_mcp_server_ids', values)}
value={form.getFieldValue('allowed_mcp_server_ids')}
onChange={val => form.setFieldValue('allowed_mcp_servers_and_groups', val)}
value={form.getFieldValue('allowed_mcp_servers_and_groups')}
accessToken={accessToken || ''}
placeholder="Select MCP servers (optional)"
placeholder="Select MCP servers or access groups (optional)"
premiumUser={premiumUser}
/>
</Form.Item>