mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-22 08:24:12 +00:00
Merge pull request #15185 from BerriAI/litellm_dev_10_03_2025_p1
(MCP - feat) UI - show health status of MCP servers, allow setting extra headers on the UI, allow editing allowed tools on the UI
This commit is contained in:
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "allowed_tools" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "extra_headers" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
@@ -179,6 +179,7 @@ model LiteLLM_MCPServerTable {
|
||||
mcp_info Json? @default("{}")
|
||||
mcp_access_groups String[]
|
||||
allowed_tools String[] @default([])
|
||||
extra_headers String[] @default([])
|
||||
// Health check status
|
||||
status String? @default("unknown")
|
||||
last_health_check DateTime?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from litellm._uuid import uuid
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Union
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
@@ -30,7 +30,7 @@ def _prepare_mcp_server_data(
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
||||
# Convert model to dict
|
||||
data_dict = data.model_dump()
|
||||
data_dict = data.model_dump(exclude_none=True)
|
||||
# Ensure alias is always present in the dict (even if None)
|
||||
if "alias" not in data_dict:
|
||||
data_dict["alias"] = getattr(data, "alias", None)
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
from typing import Any, Dict, List, Optional, Set, Union, cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
from mcp.types import CallToolRequestParams as MCPCallToolRequestParams
|
||||
@@ -240,50 +240,64 @@ class MCPServerManager:
|
||||
)
|
||||
|
||||
def add_update_server(self, mcp_server: LiteLLM_MCPServerTable):
|
||||
if mcp_server.server_id not in self.get_registry():
|
||||
_mcp_info: MCPInfo = mcp_server.mcp_info or {}
|
||||
# Use helper to deserialize environment dictionary
|
||||
# Safely access env field which may not exist on Prisma model objects
|
||||
env_data = getattr(mcp_server, "env", None)
|
||||
env_dict = _deserialize_env_dict(env_data)
|
||||
# Use alias for name if present, else server_name
|
||||
name_for_prefix = (
|
||||
mcp_server.alias or mcp_server.server_name or mcp_server.server_id
|
||||
)
|
||||
# Preserve all custom fields from database while setting defaults for core fields
|
||||
mcp_info: MCPInfo = _mcp_info.copy()
|
||||
# Set default values for core fields if not present
|
||||
if "server_name" not in mcp_info:
|
||||
mcp_info["server_name"] = mcp_server.server_name or mcp_server.server_id
|
||||
if "description" not in mcp_info and mcp_server.description:
|
||||
mcp_info["description"] = mcp_server.description
|
||||
try:
|
||||
if mcp_server.server_id not in self.get_registry():
|
||||
_mcp_info: MCPInfo = mcp_server.mcp_info or {}
|
||||
# Use helper to deserialize environment dictionary
|
||||
# Safely access env field which may not exist on Prisma model objects
|
||||
env_data = getattr(mcp_server, "env", None)
|
||||
env_dict = _deserialize_env_dict(env_data)
|
||||
# Use alias for name if present, else server_name
|
||||
name_for_prefix = (
|
||||
mcp_server.alias or mcp_server.server_name or mcp_server.server_id
|
||||
)
|
||||
# Preserve all custom fields from database while setting defaults for core fields
|
||||
mcp_info: MCPInfo = _mcp_info.copy()
|
||||
# Set default values for core fields if not present
|
||||
if "server_name" not in mcp_info:
|
||||
mcp_info["server_name"] = (
|
||||
mcp_server.server_name or mcp_server.server_id
|
||||
)
|
||||
if "description" not in mcp_info and mcp_server.description:
|
||||
mcp_info["description"] = mcp_server.description
|
||||
|
||||
new_server = MCPServer(
|
||||
server_id=mcp_server.server_id,
|
||||
name=name_for_prefix,
|
||||
alias=getattr(mcp_server, "alias", None),
|
||||
server_name=getattr(mcp_server, "server_name", None),
|
||||
url=mcp_server.url,
|
||||
transport=cast(MCPTransportType, mcp_server.transport),
|
||||
auth_type=cast(MCPAuthType, mcp_server.auth_type),
|
||||
mcp_info=mcp_info,
|
||||
extra_headers=getattr(mcp_server, "extra_headers", None),
|
||||
# oauth specific fields
|
||||
client_id=getattr(mcp_server, "client_id", None),
|
||||
client_secret=getattr(mcp_server, "client_secret", None),
|
||||
scopes=getattr(mcp_server, "scopes", None),
|
||||
authorization_url=getattr(mcp_server, "authorization_url", None),
|
||||
token_url=getattr(mcp_server, "token_url", None),
|
||||
# Stdio-specific fields
|
||||
command=getattr(mcp_server, "command", None),
|
||||
args=getattr(mcp_server, "args", None) or [],
|
||||
env=env_dict,
|
||||
access_groups=getattr(mcp_server, "mcp_access_groups", None),
|
||||
allowed_tools=getattr(mcp_server, "allowed_tools", None),
|
||||
disallowed_tools=getattr(mcp_server, "disallowed_tools", None),
|
||||
)
|
||||
self.registry[mcp_server.server_id] = new_server
|
||||
verbose_logger.debug(f"Added MCP Server: {name_for_prefix}")
|
||||
new_server = MCPServer(
|
||||
server_id=mcp_server.server_id,
|
||||
name=name_for_prefix,
|
||||
alias=getattr(mcp_server, "alias", None),
|
||||
server_name=getattr(mcp_server, "server_name", None),
|
||||
url=mcp_server.url,
|
||||
transport=cast(MCPTransportType, mcp_server.transport),
|
||||
auth_type=cast(MCPAuthType, mcp_server.auth_type),
|
||||
mcp_info=mcp_info,
|
||||
extra_headers=getattr(mcp_server, "extra_headers", None),
|
||||
# oauth specific fields
|
||||
client_id=getattr(mcp_server, "client_id", None),
|
||||
client_secret=getattr(mcp_server, "client_secret", None),
|
||||
scopes=getattr(mcp_server, "scopes", None),
|
||||
authorization_url=getattr(mcp_server, "authorization_url", None),
|
||||
token_url=getattr(mcp_server, "token_url", None),
|
||||
# Stdio-specific fields
|
||||
command=getattr(mcp_server, "command", None),
|
||||
args=getattr(mcp_server, "args", None) or [],
|
||||
env=env_dict,
|
||||
access_groups=getattr(mcp_server, "mcp_access_groups", None),
|
||||
allowed_tools=getattr(mcp_server, "allowed_tools", None),
|
||||
disallowed_tools=getattr(mcp_server, "disallowed_tools", None),
|
||||
)
|
||||
self.registry[mcp_server.server_id] = new_server
|
||||
verbose_logger.debug(f"Added MCP Server: {name_for_prefix}")
|
||||
|
||||
except Exception as e:
|
||||
verbose_logger.debug(f"Failed to add MCP server: {str(e)}")
|
||||
raise e
|
||||
|
||||
def get_all_mcp_server_ids(self) -> Set[str]:
|
||||
"""
|
||||
Get all MCP server IDs
|
||||
"""
|
||||
all_servers = list(self.get_registry().values())
|
||||
return {server.server_id for server in all_servers}
|
||||
|
||||
async def get_allowed_mcp_servers(
|
||||
self, user_api_key_auth: Optional[UserAPIKeyAuth] = None
|
||||
@@ -1118,25 +1132,23 @@ class MCPServerManager:
|
||||
if _server_id in allowed_server_ids:
|
||||
list_mcp_servers.append(
|
||||
LiteLLM_MCPServerTable(
|
||||
server_id=_server_id,
|
||||
server_name=_server_config.name,
|
||||
alias=_server_config.alias,
|
||||
url=_server_config.url,
|
||||
transport=_server_config.transport,
|
||||
auth_type=_server_config.auth_type,
|
||||
created_at=datetime.datetime.now(),
|
||||
updated_at=datetime.datetime.now(),
|
||||
description=(
|
||||
_server_config.mcp_info.get("description")
|
||||
if _server_config.mcp_info
|
||||
else None
|
||||
),
|
||||
mcp_info=_server_config.mcp_info,
|
||||
mcp_access_groups=_server_config.access_groups or [],
|
||||
# Stdio-specific fields
|
||||
command=getattr(_server_config, "command", None),
|
||||
args=getattr(_server_config, "args", None) or [],
|
||||
env=getattr(_server_config, "env", None) or {},
|
||||
**{
|
||||
**_server_config.model_dump(),
|
||||
"created_at": datetime.datetime.now(),
|
||||
"updated_at": datetime.datetime.now(),
|
||||
"description": (
|
||||
_server_config.mcp_info.get("description")
|
||||
if _server_config.mcp_info
|
||||
else None
|
||||
),
|
||||
"allowed_tools": _server_config.allowed_tools or [],
|
||||
"mcp_info": _server_config.mcp_info,
|
||||
"mcp_access_groups": _server_config.access_groups or [],
|
||||
"extra_headers": _server_config.extra_headers or [],
|
||||
"command": getattr(_server_config, "command", None),
|
||||
"args": getattr(_server_config, "args", None) or [],
|
||||
"env": getattr(_server_config, "env", None) or {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1176,44 +1188,19 @@ class MCPServerManager:
|
||||
}
|
||||
)
|
||||
|
||||
# Map servers to their teams and return with health data
|
||||
from typing import cast
|
||||
## mark invalid servers w/ reason for being invalid
|
||||
valid_server_ids = self.get_all_mcp_server_ids()
|
||||
for server in list_mcp_servers:
|
||||
if server.server_id not in valid_server_ids:
|
||||
server.status = "unhealthy"
|
||||
## try adding server to registry to get error
|
||||
try:
|
||||
self.add_update_server(server)
|
||||
except Exception as e:
|
||||
server.health_check_error = str(e)
|
||||
server.health_check_error = "Server is not in in memory registry yet. This could be a temporary sync issue."
|
||||
|
||||
return [
|
||||
LiteLLM_MCPServerTable(
|
||||
server_id=server.server_id,
|
||||
server_name=server.server_name,
|
||||
alias=server.alias,
|
||||
description=server.description,
|
||||
url=server.url,
|
||||
transport=server.transport,
|
||||
auth_type=server.auth_type,
|
||||
created_at=server.created_at,
|
||||
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 []
|
||||
),
|
||||
allowed_tools=(
|
||||
server.allowed_tools
|
||||
if server.allowed_tools is not None
|
||||
else []
|
||||
),
|
||||
mcp_info=server.mcp_info,
|
||||
teams=cast(
|
||||
List[Dict[str, str | None]],
|
||||
server_to_teams_map.get(server.server_id, []),
|
||||
),
|
||||
# Stdio-specific fields
|
||||
command=getattr(server, "command", None),
|
||||
args=getattr(server, "args", None) or [],
|
||||
env=getattr(server, "env", None) or {},
|
||||
)
|
||||
for server in list_mcp_servers
|
||||
]
|
||||
return list_mcp_servers
|
||||
|
||||
async def reload_servers_from_database(self):
|
||||
"""
|
||||
|
||||
@@ -925,6 +925,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
|
||||
mcp_info: Optional[MCPInfo] = None
|
||||
mcp_access_groups: List[str] = Field(default_factory=list)
|
||||
allowed_tools: Optional[List[str]] = None
|
||||
extra_headers: Optional[List[str]] = None
|
||||
# Stdio-specific fields
|
||||
command: Optional[str] = None
|
||||
args: List[str] = Field(default_factory=list)
|
||||
@@ -994,9 +995,10 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
|
||||
teams: List[Dict[str, Optional[str]]] = Field(default_factory=list)
|
||||
mcp_access_groups: List[str] = Field(default_factory=list)
|
||||
allowed_tools: List[str] = Field(default_factory=list)
|
||||
extra_headers: List[str] = Field(default_factory=list)
|
||||
mcp_info: Optional[MCPInfo] = None
|
||||
# Health check status
|
||||
status: Optional[str] = Field(
|
||||
status: Optional[Literal["healthy", "unhealthy", "unknown"]] = Field(
|
||||
default="unknown",
|
||||
description="Health status: 'healthy', 'unhealthy', 'unknown'",
|
||||
)
|
||||
|
||||
@@ -179,6 +179,7 @@ model LiteLLM_MCPServerTable {
|
||||
mcp_info Json? @default("{}")
|
||||
mcp_access_groups String[]
|
||||
allowed_tools String[] @default([])
|
||||
extra_headers String[] @default([])
|
||||
// Health check status
|
||||
status String? @default("unknown")
|
||||
last_health_check DateTime?
|
||||
|
||||
@@ -179,6 +179,7 @@ model LiteLLM_MCPServerTable {
|
||||
mcp_info Json? @default("{}")
|
||||
mcp_access_groups String[]
|
||||
allowed_tools String[] @default([])
|
||||
extra_headers String[] @default([])
|
||||
// Health check status
|
||||
status String? @default("unknown")
|
||||
last_health_check DateTime?
|
||||
|
||||
@@ -654,6 +654,7 @@ class TestMCPServerManager:
|
||||
"Tool tool3 is not allowed for server test-server"
|
||||
in exc_info.value.detail["error"]
|
||||
)
|
||||
|
||||
async def test_get_tools_from_server_add_prefix(self):
|
||||
"""Verify _get_tools_from_server respects add_prefix True/False."""
|
||||
manager = MCPServerManager()
|
||||
@@ -909,6 +910,39 @@ class TestMCPServerManager:
|
||||
assert "tool_1" in tool_names
|
||||
assert "tool_2" in tool_names
|
||||
|
||||
def test_add_db_mcp_server_to_registry(self):
|
||||
"""Test that add_db_mcp_server_to_registry adds a MCP server to the registry"""
|
||||
manager = MCPServerManager()
|
||||
server = LiteLLM_MCPServerTable(
|
||||
**{
|
||||
"server_id": "4c679a81-acd9-4954-9f84-30b739362498",
|
||||
"server_name": "edc_mcp_server",
|
||||
"alias": "edc_mcp_server",
|
||||
"description": None,
|
||||
"url": "fake_mcp_url",
|
||||
"transport": "http",
|
||||
"auth_type": "none",
|
||||
"created_at": "2025-09-30T08:28:31.353000Z",
|
||||
"created_by": "a1248959",
|
||||
"updated_at": "2025-09-30T08:28:31.353000Z",
|
||||
"updated_by": "a1248959",
|
||||
"teams": [],
|
||||
"mcp_access_groups": [],
|
||||
"mcp_info": {
|
||||
"server_name": "edc_mcp_server",
|
||||
"mcp_server_cost_info": None,
|
||||
},
|
||||
"status": "unknown",
|
||||
"last_health_check": None,
|
||||
"health_check_error": None,
|
||||
"command": None,
|
||||
"args": [],
|
||||
"env": {},
|
||||
},
|
||||
)
|
||||
manager.add_update_server(server)
|
||||
assert server.server_id in manager.get_registry()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { Form, Select, Tooltip, Collapse } from "antd"
|
||||
import { InfoCircleOutlined } from "@ant-design/icons"
|
||||
import { MCPServer } from "./types"
|
||||
const { Panel } = Collapse
|
||||
|
||||
interface MCPPermissionManagementProps {
|
||||
availableAccessGroups: string[]
|
||||
mcpServer: MCPServer | null
|
||||
searchValue: string
|
||||
setSearchValue: (value: string) => void
|
||||
getAccessGroupOptions: () => Array<{
|
||||
value: string
|
||||
label: React.ReactNode
|
||||
}>
|
||||
}
|
||||
|
||||
const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
||||
availableAccessGroups,
|
||||
mcpServer,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
getAccessGroupOptions,
|
||||
}) => {
|
||||
const form = Form.useFormInstance()
|
||||
|
||||
// Set initial values when mcpServer changes
|
||||
useEffect(() => {
|
||||
if (mcpServer) {
|
||||
// Set extra_headers if they exist
|
||||
if (mcpServer.extra_headers) {
|
||||
form.setFieldValue('extra_headers', mcpServer.extra_headers)
|
||||
}
|
||||
|
||||
}
|
||||
}, [mcpServer, form])
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
className="bg-gray-50 border border-gray-200 rounded-lg"
|
||||
expandIconPosition="end"
|
||||
ghost={false}
|
||||
>
|
||||
<Panel
|
||||
header={
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Permission Management / Access Control</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 ml-4">
|
||||
Configure access permissions and security settings (Optional)
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
key="permissions"
|
||||
className="border-0"
|
||||
>
|
||||
<div className="space-y-6 pt-4">
|
||||
<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="value"
|
||||
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
onSearch={(value) => setSearchValue(value)}
|
||||
tokenSeparators={[","]}
|
||||
options={getAccessGroupOptions()}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Extra Headers
|
||||
<Tooltip title="Forward custom headers from incoming requests to this MCP server (e.g., Authorization, X-Custom-Header, User-Agent)">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
{mcpServer?.extra_headers && mcpServer.extra_headers.length > 0 && (
|
||||
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||||
{mcpServer.extra_headers.length} configured
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
name="extra_headers"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={
|
||||
mcpServer?.extra_headers && mcpServer.extra_headers.length > 0
|
||||
? `Currently: ${mcpServer.extra_headers.join(', ')}`
|
||||
: "Enter header names (e.g., Authorization, X-Custom-Header)"
|
||||
}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
tokenSeparators={[","]}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
|
||||
export default MCPPermissionManagement
|
||||
@@ -8,6 +8,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config"
|
||||
import MCPConnectionStatus from "./mcp_connection_status"
|
||||
import MCPToolConfiguration from "./mcp_tool_configuration"
|
||||
import StdioConfiguration from "./StdioConfiguration"
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement"
|
||||
import { isAdminRole } from "@/utils/roles"
|
||||
import { validateMCPServerUrl, validateMCPServerName } from "./utils"
|
||||
import NotificationsManager from "../molecules/notifications_manager"
|
||||
@@ -381,32 +382,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
|
||||
{/* Stdio Configuration - only show for stdio transport */}
|
||||
<StdioConfiguration isVisible={transportType === "stdio"} />
|
||||
</div>
|
||||
|
||||
<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="value"
|
||||
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
onSearch={(value) => setSearchValue(value)}
|
||||
tokenSeparators={[","]}
|
||||
options={getAccessGroupOptions()}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* Permission Management / Access Control Section */}
|
||||
<div className="mt-8">
|
||||
<MCPPermissionManagement
|
||||
availableAccessGroups={availableAccessGroups}
|
||||
mcpServer={null}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
getAccessGroupOptions={getAccessGroupOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connection Status Section */}
|
||||
@@ -420,6 +406,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
accessToken={accessToken}
|
||||
formValues={formValues}
|
||||
allowedTools={allowedTools}
|
||||
existingAllowedTools={null}
|
||||
onAllowedToolsChange={setAllowedTools}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,67 @@ export const mcpServerColumns = (
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "health_status",
|
||||
header: "Health Status",
|
||||
cell: ({ row }) => {
|
||||
const server = row.original;
|
||||
const status = server.status || "unknown";
|
||||
const lastCheck = server.last_health_check;
|
||||
const error = server.health_check_error;
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return "text-green-500 bg-green-50 hover:bg-green-100";
|
||||
case "unhealthy":
|
||||
return "text-red-500 bg-red-50 hover:bg-red-100";
|
||||
default:
|
||||
return "text-gray-500 bg-gray-50 hover:bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return "●";
|
||||
case "unhealthy":
|
||||
return "●";
|
||||
default:
|
||||
return "●";
|
||||
}
|
||||
};
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="max-w-xs">
|
||||
<div className="font-semibold mb-1">Health Status: {status}</div>
|
||||
{lastCheck && (
|
||||
<div className="text-xs mb-1">
|
||||
Last Check: {new Date(lastCheck).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-red-400 mb-1">Error:</div>
|
||||
<div className="break-words">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!lastCheck && !error && (
|
||||
<div className="text-xs text-gray-400">No health check data available</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<button className={`font-mono text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[10ch] ${getStatusColor(status)}`}>
|
||||
<span className="mr-1">{getStatusIcon(status)}</span>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mcp_access_groups",
|
||||
header: "Access Groups",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, TextInput, TabGroup, TabList, Tab, TabPanels, TabPanel } from "
|
||||
import { MCPServer, MCPServerCostInfo } from "./types";
|
||||
import { updateMCPServer, testMCPToolsListRequest } from "../networking";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
import MCPToolConfiguration from "./mcp_tool_configuration";
|
||||
import { MinusCircleOutlined, PlusOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
@@ -22,7 +24,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
|
||||
const [tools, setTools] = useState<any[]>([]);
|
||||
const [isLoadingTools, setIsLoadingTools] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false)
|
||||
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
|
||||
const [allowedTools, setAllowedTools] = useState<string[]>([]);
|
||||
|
||||
// Initialize cost config from existing server data
|
||||
useEffect(() => {
|
||||
@@ -31,6 +34,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
|
||||
}
|
||||
}, [mcpServer]);
|
||||
|
||||
// Initialize allowed tools from existing server data
|
||||
useEffect(() => {
|
||||
if (mcpServer.allowed_tools) {
|
||||
setAllowedTools(mcpServer.allowed_tools);
|
||||
}
|
||||
}, [mcpServer]);
|
||||
|
||||
// Transform string array to object array for initial form values
|
||||
useEffect(() => {
|
||||
if (mcpServer.mcp_access_groups) {
|
||||
@@ -114,7 +124,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
|
||||
// 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
|
||||
// Prepare the payload with cost configuration and permission fields
|
||||
const payload = {
|
||||
...values,
|
||||
server_id: mcpServer.server_id,
|
||||
@@ -125,6 +135,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
|
||||
},
|
||||
mcp_access_groups: accessGroups,
|
||||
alias: values.alias,
|
||||
// Include permission management fields
|
||||
extra_headers: values.extra_headers || [],
|
||||
allowed_tools: allowedTools.length > 0 ? allowedTools : null,
|
||||
disallowed_tools: values.disallowed_tools || [],
|
||||
};
|
||||
|
||||
const updated = await updateMCPServer(accessToken, payload);
|
||||
@@ -179,34 +193,34 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({ mcpServer, accessToken, o
|
||||
</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%' }}
|
||||
showSearch
|
||||
placeholder="Add or select access groups"
|
||||
tokenSeparators={[',']}
|
||||
optionFilterProp="value"
|
||||
filterOption={(input, option) =>
|
||||
(option?.value ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
onSearch={(value) => setSearchValue(value)}
|
||||
options={getAccessGroupOptions()}
|
||||
// Ensure value is always an array of strings
|
||||
getPopupContainer={trigger => trigger.parentNode}
|
||||
{/* Permission Management / Access Control Section */}
|
||||
<div className="mt-6">
|
||||
<MCPPermissionManagement
|
||||
availableAccessGroups={availableAccessGroups}
|
||||
mcpServer={mcpServer}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
getAccessGroupOptions={getAccessGroupOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Tool Configuration Section */}
|
||||
<div className="mt-6">
|
||||
<MCPToolConfiguration
|
||||
accessToken={accessToken}
|
||||
formValues={{
|
||||
server_id: mcpServer.server_id,
|
||||
server_name: mcpServer.server_name,
|
||||
url: mcpServer.url,
|
||||
transport: mcpServer.transport,
|
||||
auth_type: mcpServer.auth_type,
|
||||
mcp_info: mcpServer.mcp_info,
|
||||
}}
|
||||
allowedTools={allowedTools}
|
||||
existingAllowedTools={mcpServer.allowed_tools || null}
|
||||
onAllowedToolsChange={setAllowedTools}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<AntdButton onClick={onCancel}>Cancel</AntdButton>
|
||||
|
||||
@@ -221,6 +221,10 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
||||
<Text className="font-medium">Transport</Text>
|
||||
<div>{handleTransport(mcpServer.transport)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Extra Headers</Text>
|
||||
<div>{mcpServer.extra_headers?.join(", ")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Auth Type</Text>
|
||||
<div>{handleAuth(mcpServer.auth_type)}</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface MCPToolConfigurationProps {
|
||||
accessToken: string | null
|
||||
formValues: Record<string, any>
|
||||
allowedTools: string[]
|
||||
existingAllowedTools: string[] | null
|
||||
onAllowedToolsChange: (tools: string[]) => void
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
accessToken,
|
||||
formValues,
|
||||
allowedTools,
|
||||
existingAllowedTools,
|
||||
onAllowedToolsChange,
|
||||
}) => {
|
||||
const previousToolsLengthRef = useRef(0)
|
||||
@@ -25,19 +27,28 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// Auto-select all tools when tools are first loaded
|
||||
// Auto-select tools when tools are first loaded
|
||||
useEffect(() => {
|
||||
// Only auto-select if:
|
||||
// 1. We have tools
|
||||
// 2. Tools length changed (new tools loaded)
|
||||
// 3. No tools are currently selected (initial state)
|
||||
if (tools.length > 0 && tools.length !== previousToolsLengthRef.current && allowedTools.length === 0) {
|
||||
const allToolNames = tools.map((tool) => tool.name)
|
||||
onAllowedToolsChange(allToolNames)
|
||||
if (existingAllowedTools && existingAllowedTools.length > 0) {
|
||||
// If we have existing allowed tools, use those as the initial selection
|
||||
// Filter to only include tools that are actually available from the server
|
||||
const availableToolNames = tools.map((tool) => tool.name)
|
||||
const validExistingTools = existingAllowedTools.filter(toolName => availableToolNames.includes(toolName))
|
||||
onAllowedToolsChange(validExistingTools)
|
||||
} else {
|
||||
// If no existing allowed tools, auto-select all tools (create mode)
|
||||
const allToolNames = tools.map((tool) => tool.name)
|
||||
onAllowedToolsChange(allToolNames)
|
||||
}
|
||||
}
|
||||
// Update ref to track tools length (will be 0 when tools clear)
|
||||
previousToolsLengthRef.current = tools.length
|
||||
}, [tools, allowedTools.length, onAllowedToolsChange])
|
||||
}, [tools, allowedTools.length, existingAllowedTools, onAllowedToolsChange])
|
||||
|
||||
const handleToolToggle = (toolName: string) => {
|
||||
if (allowedTools.includes(toolName)) {
|
||||
@@ -78,6 +89,14 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-blue-800 text-sm">
|
||||
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to invoke.
|
||||
Unchecked tools will be blocked from execution.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoadingTools && (
|
||||
@@ -124,7 +143,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 flex-1">
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
<Text className="text-green-700 font-medium">
|
||||
{allowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} selected
|
||||
{allowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for user access
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-3">
|
||||
@@ -133,14 +152,14 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
onClick={handleSelectAll}
|
||||
className="px-3 py-1.5 text-sm text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-md transition-colors"
|
||||
>
|
||||
Select All
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeselectAll}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
Deselect All
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,8 +179,23 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox checked={allowedTools.includes(tool.name)} onChange={() => handleToolToggle(tool.name)} />
|
||||
<div className="flex-1">
|
||||
<Text className="font-medium text-gray-900">{tool.name}</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="font-medium text-gray-900">{tool.name}</Text>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
allowedTools.includes(tool.name)
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}>
|
||||
{allowedTools.includes(tool.name) ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
{tool.description && <Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>}
|
||||
<Text className="text-gray-400 text-xs block mt-1">
|
||||
{allowedTools.includes(tool.name)
|
||||
? "✓ Users can call this tool"
|
||||
: "✗ Users cannot call this tool"
|
||||
}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,6 +138,10 @@ export interface MCPServer {
|
||||
created_by: string
|
||||
updated_at: string
|
||||
updated_by: string
|
||||
extra_headers?: string[] | null
|
||||
status?: "healthy" | "unhealthy" | "unknown"
|
||||
last_health_check?: string | null
|
||||
health_check_error?: string | null
|
||||
teams?: Team[]
|
||||
mcp_access_groups?: string[]
|
||||
allowed_tools?: string[]
|
||||
|
||||
Reference in New Issue
Block a user