feat(mcp): implement mcp server management with CRUD operations

This commit is contained in:
2025-10-23 16:28:23 +08:00
parent a1ac879c6c
commit b513565f30
11 changed files with 1373 additions and 23 deletions

13
mcp/Filesystem.json Normal file
View File

@@ -0,0 +1,13 @@
{
"mcpServers": {
"Filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/path/to/allowed/directory"
],
"env": {}
}
}
}

53
mcp/Mock.json Normal file
View File

@@ -0,0 +1,53 @@
{
"mcpServers": {
"Time": {
"type": "mcp_server_time",
"url": "http://localhost:8080",
"name": "Time Server",
"authorization_token": "",
"tool_configuration": {}
},
"Mock": {
"type": "mcp_server_mock",
"url": "http://localhost:8080",
"name": "Mock Server",
"authorization_token": "",
"tool_configuration": {}
},
"Filesystem": {
"type": "mcp_server_filesystem",
"url": "http://localhost:8080",
"name": "Filesystem Server",
"authorization_token": "",
"tool_configuration": {}
},
"MockFilesystem": {
"type": "mcp_server_mock_filesystem",
"url": "http://localhost:8080",
"name": "Mock Filesystem Server",
"authorization_token": "",
"tool_configuration": {}
},
"MockTime": {
"type": "mcp_server_mock_time",
"url": "http://localhost:8080",
"name": "Mock Time Server",
"authorization_token": "",
"tool_configuration": {}
},
"MockMock": {
"type": "mcp_server_mock_mock",
"url": "http://localhost:8080",
"name": "Mock Mock Server",
"authorization_token": "",
"tool_configuration": {}
},
"MockMockFilesystem": {
"type": "mcp_server_mock_mock_filesystem",
"url": "http://localhost:8080",
"name": "Mock Mock Filesystem Server",
"authorization_token": "",
"tool_configuration": {}
}
}
}

View File

@@ -6,6 +6,8 @@ import userService from "@/services/user_service";
import { rsaPsw } from "../utils/encryption";
import type { IFactory, IMyLlmModel } from "@/interfaces/database/llm";
import type { LLMFactory } from "@/constants/llm";
import type { IMcpServer, IMcpServerListResponse } from "@/interfaces/database/mcp";
import type { IImportMcpServersRequestBody, ITestMcpRequestBody, ICreateMcpServerRequestBody } from "@/interfaces/request/mcp";
/**
* 个人中心设置
@@ -249,3 +251,239 @@ export function useTeamSetting() {
};
}
/**
* MCP 设置
*/
export function useMcpSetting() {
const [mcpServers, setMcpServers] = useState<IMcpServer[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
// 获取 MCP 服务器列表
const fetchMcpServers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await userService.listMcpServer({
page: 1,
size: 10,
});
if (res.data.code === 0) {
const data: IMcpServerListResponse = res.data.data;
setMcpServers(data.mcp_servers || []);
setTotal(data.total || 0);
} else {
throw new Error(res.data.message || '获取 MCP 服务器列表失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '获取 MCP 服务器列表失败';
setError(errorMessage);
logger.error('获取 MCP 服务器列表失败:', error);
} finally {
setLoading(false);
}
}, []);
// 导入 MCP 服务器
const importMcpServers = useCallback(async (data: IImportMcpServersRequestBody) => {
try {
setLoading(true);
setError(null);
const res = await userService.importMcpServer(data);
if (res.data.code === 0) {
await fetchMcpServers(); // 重新获取列表
return { success: true };
} else {
throw new Error(res.data.message || '导入 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '导入 MCP 服务器失败';
setError(errorMessage);
logger.error('导入 MCP 服务器失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, [fetchMcpServers]);
// 导出 MCP 服务器
const exportMcpServers = useCallback(async (mcpIds: string[]) => {
try {
setLoading(true);
setError(null);
const res = await userService.exportMcpServer(mcpIds);
if (res.data.code === 0) {
// 处理导出数据,创建下载
const exportData = res.data.data;
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mcp-servers.json';
a.click();
URL.revokeObjectURL(url);
return { success: true };
} else {
throw new Error(res.data.message || '导出 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '导出 MCP 服务器失败';
setError(errorMessage);
logger.error('导出 MCP 服务器失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
// 测试 MCP 服务器
const testMcpServer = useCallback(async (data: ITestMcpRequestBody) => {
try {
setLoading(true);
setError(null);
const res = await userService.testMcpServer(data);
if (res.data.code === 0) {
return { success: true, data: res.data.data };
} else {
throw new Error(res.data.message || '测试 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '测试 MCP 服务器失败';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
// 获取 MCP 服务器详情
const getMcpServerDetail = useCallback(async (mcpId: string) => {
try {
setLoading(true);
setError(null);
const res = await userService.mcpDetail(mcpId);
if (res.data.code === 0) {
const detail: IMcpServer = res.data.data;
return { success: true, data: detail };
} else {
throw new Error(res.data.message || '获取 MCP 服务器详情失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '获取 MCP 服务器详情失败';
setError(errorMessage);
logger.error('获取 MCP 服务器详情失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
// 创建 MCP 服务器
const createMcpServer = useCallback(async (data: ICreateMcpServerRequestBody) => {
try {
setLoading(true);
setError(null);
const res = await userService.createMcpServer(data);
if (res.data.code === 0) {
await fetchMcpServers(); // 重新获取列表
return { success: true, data: res.data.data };
} else {
throw new Error(res.data.message || '创建 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '创建 MCP 服务器失败';
setError(errorMessage);
logger.error('创建 MCP 服务器失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, [fetchMcpServers]);
// 更新 MCP 服务器
const updateMcpServer = useCallback(async (data: ICreateMcpServerRequestBody & { mcp_id: string }) => {
try {
setLoading(true);
setError(null);
const res = await userService.updateMcpServer(data);
if (res.data.code === 0) {
await fetchMcpServers(); // 重新获取列表
return { success: true, data: res.data.data };
} else {
throw new Error(res.data.message || '更新 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '更新 MCP 服务器失败';
setError(errorMessage);
logger.error('更新 MCP 服务器失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, [fetchMcpServers]);
// 删除 MCP 服务器
const deleteMcpServer = useCallback(async (mcpId: string) => {
try {
setLoading(true);
setError(null);
const res = await userService.removeMcpServer([mcpId]);
if (res.data.code === 0) {
await fetchMcpServers(); // 重新获取列表
return { success: true };
} else {
throw new Error(res.data.message || '删除 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '删除 MCP 服务器失败';
setError(errorMessage);
logger.error('删除 MCP 服务器失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, [fetchMcpServers]);
// 批量删除 MCP 服务器
const deleteMcpServers = useCallback(async (mcpIds: string[]) => {
try {
setLoading(true);
setError(null);
const res = await userService.removeMcpServer(mcpIds);
if (res.data.code === 0) {
await fetchMcpServers(); // 重新获取列表
return { success: true };
} else {
throw new Error(res.data.message || '批量删除 MCP 服务器失败');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || '批量删除 MCP 服务器失败';
setError(errorMessage);
logger.error('批量删除 MCP 服务器失败:', error);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, [fetchMcpServers]);
return {
// 状态
mcpServers,
loading,
error,
total,
// 方法
fetchMcpServers,
importMcpServers,
exportMcpServers,
testMcpServer,
getMcpServerDetail,
createMcpServer,
updateMcpServer,
deleteMcpServer,
deleteMcpServers,
};
}

View File

@@ -1,7 +1,8 @@
// export enum McpServerType {
// Sse = 'sse',
// StreamableHttp = 'streamable-http',
// }
const MCP_SERVER_TYPE = {
Sse: 'sse',
StreamableHttp: 'streamable-http',
}
export type McpServerType = (typeof MCP_SERVER_TYPE)[keyof typeof MCP_SERVER_TYPE];
export interface IMcpServerVariable {
key: string;
@@ -12,7 +13,7 @@ export interface IMcpServerInfo {
id: string;
name: string;
url: string;
// server_type: McpServerType;
server_type: McpServerType;
description?: string;
variables?: IMcpServerVariable[];
headers: Map<string, string>;

View File

@@ -1,15 +1,16 @@
export interface IMcpServer {
create_date: string;
description: null;
description?: string;
id: string;
name: string;
server_type: string;
update_date: string;
url: string;
variables: Record<string, any> & { tools?: IMCPToolObject };
headers: Record<string, any>;
}
export type IMCPToolObject = Record<string, Omit<IMCPTool, 'name'>>;
export type IMCPToolObject = Record<string, Partial<IMCPTool>>;
export type IMCPToolRecord = Record<string, IMCPTool>;

View File

@@ -1,4 +1,4 @@
// import { IExportedMcpServer } from '@/interfaces/database/mcp';
import { type IExportedMcpServer } from '@/interfaces/database/mcp';
export interface ITestMcpRequestBody {
server_type: string;
@@ -6,11 +6,19 @@ export interface ITestMcpRequestBody {
headers?: Record<string, any>;
variables?: Record<string, any>;
timeout?: number;
name?: string
}
export interface IImportMcpServersRequestBody {
// mcpServers: Record<
// string,
// Pick<IExportedMcpServer, 'type' | 'url' | 'authorization_token'>
// >;
mcpServers: Record<
string,
Pick<IExportedMcpServer, 'type' | 'url' | 'authorization_token'>
>;
}
export interface ICreateMcpServerRequestBody {
name?: string;
server_type: string;
url: string;
headers?: Record<string, any>;
}

View File

@@ -0,0 +1,157 @@
import React, { useState, useCallback } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
IconButton,
Typography,
Box,
Alert,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close';
import McpForm from './McpForm';
import type { IMcpServer } from '@/interfaces/database/mcp';
import type { ICreateMcpServerRequestBody, ITestMcpRequestBody } from '@/interfaces/request/mcp';
import logger from '@/utils/logger';
const StyledDialogTitle = styled(DialogTitle)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2, 3),
borderBottom: `1px solid ${theme.palette.divider}`,
}));
const StyledDialogContent = styled(DialogContent)(({ theme }) => ({
padding: 0,
minHeight: '400px',
maxHeight: '80vh',
overflow: 'auto',
}));
interface McpDialogProps {
open: boolean;
onClose: () => void;
onSave: (data: ICreateMcpServerRequestBody) => Promise<{ success: boolean; error?: string }>;
onTest?: (data: ITestMcpRequestBody) => Promise<{ success: boolean; data?: any; error?: string }>;
initialData?: Partial<IMcpServer>;
mode?: 'create' | 'edit';
loading?: boolean;
}
const McpDialog: React.FC<McpDialogProps> = ({
open,
onClose,
onSave,
onTest,
initialData,
mode = 'create',
loading = false,
}) => {
const [submitLoading, setSubmitLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState(false);
logger.debug('McpDialog 组件加载', initialData);
const handleSubmit = useCallback(async (data: ICreateMcpServerRequestBody) => {
setSubmitLoading(true);
setSubmitError(null);
setSubmitSuccess(false);
try {
const result = await onSave(data);
if (result.success) {
setSubmitSuccess(true);
// 延迟关闭对话框,让用户看到成功消息
setTimeout(() => {
onClose();
setSubmitSuccess(false);
}, 1500);
} else {
setSubmitError(result.error || '保存失败');
}
} catch (error: any) {
setSubmitError(error.message || '保存时发生错误');
} finally {
setSubmitLoading(false);
}
return { success: submitSuccess, error: submitError || undefined };
}, [onSave, onClose, submitSuccess, submitError]);
const handleClose = () => {
if (submitLoading) return; // 防止在提交过程中关闭
setSubmitError(null);
setSubmitSuccess(false);
onClose();
};
const getDialogTitle = () => {
switch (mode) {
case 'edit':
return '编辑 MCP 服务器';
case 'create':
default:
return '添加 MCP 服务器';
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
disableEscapeKeyDown={submitLoading}
>
<StyledDialogTitle>
<Typography variant="h6" component="div">
{getDialogTitle()}
</Typography>
<IconButton
aria-label="close"
onClick={handleClose}
disabled={submitLoading}
size="small"
>
<CloseIcon />
</IconButton>
</StyledDialogTitle>
<StyledDialogContent>
{/* 显示提交状态消息 */}
{submitError && (
<Box p={2}>
<Alert severity="error" onClose={() => setSubmitError(null)}>
{submitError}
</Alert>
</Box>
)}
{submitSuccess && (
<Box p={2}>
<Alert severity="success">
{mode === 'create' ? 'MCP 服务器创建成功!' : 'MCP 服务器更新成功!'}
</Alert>
</Box>
)}
<McpForm
initialData={initialData}
onSubmit={handleSubmit}
onTest={onTest}
loading={submitLoading || loading}
disabled={submitLoading || loading}
/>
</StyledDialogContent>
</Dialog>
);
};
export default McpDialog;

View File

@@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Typography,
CircularProgress,
Alert,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemText,
Divider,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RefreshIcon from '@mui/icons-material/Refresh';
import type { IMcpServer } from '@/interfaces/database/mcp';
import type { ICreateMcpServerRequestBody, ITestMcpRequestBody } from '@/interfaces/request/mcp';
import { Mode } from '@mui/icons-material';
const FormContainer = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
padding: theme.spacing(2),
}));
const TestSection = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(2),
padding: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
}));
const ToolsContainer = styled(Box)(({ theme }) => ({
maxHeight: '300px',
overflowY: 'auto',
marginTop: theme.spacing(1),
}));
interface McpFormData {
name?: string;
url: string;
server_type: string;
authorization_token?: string;
}
interface McpTool {
name: string;
description: string;
}
interface McpFormProps {
initialData?: Partial<IMcpServer>;
onSubmit: (data: ICreateMcpServerRequestBody) => Promise<{ success: boolean; error?: string }>;
onTest?: (data: ITestMcpRequestBody) => Promise<{ success: boolean; data?: any; error?: string }>;
loading?: boolean;
disabled?: boolean;
}
const McpForm: React.FC<McpFormProps> = ({
initialData,
onSubmit,
onTest,
loading = false,
disabled = false,
}) => {
const [formData, setFormData] = useState<McpFormData>({
name: initialData?.name || '',
url: initialData?.url || '',
server_type: initialData?.server_type || 'sse',
authorization_token: initialData?.variables?.authorization_token || '',
});
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
tools?: McpTool[];
error?: string;
} | null>(null);
const [formErrors, setFormErrors] = useState<Partial<McpFormData>>({});
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (!isInitialized) {
// 只在首次初始化时设置默认值
setFormData({
name: '',
url: '',
server_type: 'sse',
authorization_token: '',
});
setIsInitialized(true);
}
if (initialData) {
setFormData({
name: initialData.name || '',
url: initialData.url || '',
server_type: initialData.server_type || 'sse',
authorization_token: initialData.variables?.authorization_token || '',
});
if (initialData.variables?.tools) {
const tools = Object.entries(initialData.variables.tools).map(([name, tool]) => {
const t: McpTool = {
name: name,
description: tool.description || '',
}
return t
});
setTestResult({
success: true,
tools,
});
}
setIsInitialized(true);
}
}, [isInitialized]);
const validateForm = (): boolean => {
const errors: Partial<McpFormData> = {};
if (!formData.name?.trim()) {
errors.name = '名称不能为空';
}
if (!formData.url.trim()) {
errors.url = 'URL不能为空';
} else if (!/^https?:\/\/.+/.test(formData.url)) {
errors.url = 'URL格式不正确';
}
if (!formData.server_type) {
errors.server_type = '请选择服务器类型';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (field: keyof McpFormData) => (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | { target: { value: string } }
) => {
const value = event.target.value;
setFormData(prev => ({ ...prev, [field]: value }));
// 清除对应字段的错误
if (formErrors[field]) {
setFormErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const handleTest = async () => {
if (!onTest) return;
if (!formData.url.trim()) {
setTestResult({
success: false,
error: '请先填写 URL',
});
return;
}
setTestLoading(true);
setTestResult(null);
try {
const testData: ITestMcpRequestBody = {
server_type: formData.server_type,
url: formData.url,
headers: formData.authorization_token ? { authorization_token: formData.authorization_token } : undefined,
name: formData.name?.trim(),
};
const result = await onTest(testData);
if (result.success && result.data) {
// 解析工具数据
const tools: McpTool[] = [];
if (result.data && Array.isArray(result.data)) {
result.data.forEach((tool: any) => {
if (tool.name && tool.description) {
tools.push({
name: tool.name,
description: tool.description,
});
}
});
}
setTestResult({
success: true,
tools,
});
} else {
setTestResult({
success: false,
error: result.error || '测试失败',
});
}
} catch (error: any) {
setTestResult({
success: false,
error: error.message || '测试连接时发生错误',
});
} finally {
setTestLoading(false);
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
const submitData: ICreateMcpServerRequestBody = {
name: formData.name?.trim(),
server_type: formData.server_type,
url: formData.url.trim(),
headers: formData.authorization_token ? { authorization_token: formData.authorization_token } : undefined,
};
await onSubmit(submitData);
};
return (
<FormContainer>
<TextField
label="名称"
value={formData.name || ''}
onChange={handleInputChange('name')}
error={!!formErrors.name}
helperText={formErrors.name}
disabled={disabled}
fullWidth
required
/>
<TextField
label="URL"
value={formData.url}
onChange={handleInputChange('url')}
error={!!formErrors.url}
helperText={formErrors.url}
disabled={disabled}
fullWidth
required
placeholder="https://mcp.****.com/sse?key=..."
/>
<FormControl fullWidth required error={!!formErrors.server_type}>
<InputLabel>Server Type</InputLabel>
<Select
value={formData.server_type}
onChange={handleInputChange('server_type')}
disabled={disabled}
label="Server Type"
>
<MenuItem value="sse">SSE</MenuItem>
<MenuItem value="streamable-http">Streamable HTTP</MenuItem>
</Select>
</FormControl>
<TextField
label="Authorization Token"
value={formData.authorization_token}
onChange={handleInputChange('authorization_token')}
disabled={disabled}
fullWidth
placeholder="e.g. eyJhbGciOiJIUzI1Ni..."
helperText="可选:用于身份验证的令牌"
/>
{/* 测试连接部分 */}
{onTest && (
<TestSection>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6"></Typography>
<Button
variant="outlined"
onClick={handleTest}
disabled={testLoading || disabled}
startIcon={testLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
>
{testLoading ? '测试中...' : '测试连接'}
</Button>
</Box>
{testResult && (
<Box>
{testResult.success ? (
<Box>
<Alert severity="success" sx={{ mb: 2 }}>
{testResult.tools?.length || 0}
</Alert>
{testResult.tools && testResult.tools.length > 0 && (
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="subtitle1"></Typography>
<Chip
label={`${testResult.tools.length} tools available`}
size="small"
color="primary"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<ToolsContainer>
<List dense>
{testResult.tools.map((tool, index) => (
<React.Fragment key={tool.name}>
<ListItem>
<ListItemText
primary={
<Typography variant="subtitle2" fontWeight="bold">
{tool.name}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{tool.description}
</Typography>
}
/>
</ListItem>
{index < (testResult.tools?.length || 0) - 1 && <Divider />}
</React.Fragment>
))}
</List>
</ToolsContainer>
</AccordionDetails>
</Accordion>
)}
</Box>
) : (
<Alert severity="error">
{testResult.error || '连接失败'}
</Alert>
)}
</Box>
)}
</TestSection>
)}
{/* 提交按钮 */}
<Box display="flex" justifyContent="flex-end" mt={2}>
<Button
variant="contained"
onClick={handleSubmit}
disabled={loading || disabled}
startIcon={loading ? <CircularProgress size={16} /> : undefined}
>
{loading ? '保存中...' : '保存'}
</Button>
</Box>
</FormContainer>
);
};
export default McpForm;

View File

@@ -1,8 +1,470 @@
function MCPSetting() {
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Grid,
IconButton,
Menu,
MenuItem,
Checkbox,
Pagination,
CircularProgress,
Snackbar,
Alert,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions
} from '@mui/material';
import {
Add as AddIcon,
MoreVert as MoreVertIcon,
Delete as DeleteIcon,
FileDownload as ExportIcon,
FileUpload as ImportIcon,
Search as SearchIcon,
Edit as EditIcon
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import { useMcpSetting } from '@/hooks/setting-hooks';
import McpDialog from '@/pages/setting/components/McpDialog';
import type { IMcpServer } from '@/interfaces/database/mcp';
import type { IImportMcpServersRequestBody, ICreateMcpServerRequestBody, ITestMcpRequestBody } from '@/interfaces/request/mcp';
import { useMessage } from '@/hooks/useSnackbar';
import dayjs from 'dayjs';
const PageContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(3),
maxWidth: '1200px',
margin: '0 auto',
backgroundColor: '#f5f5f5',
minHeight: '100vh'
}));
const PageHeader = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[1],
}));
const SearchContainer = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[1],
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
}));
export default function McpSettingPage() {
const {
mcpServers,
loading,
total,
fetchMcpServers,
importMcpServers,
exportMcpServers,
testMcpServer,
getMcpServerDetail,
createMcpServer,
updateMcpServer,
deleteMcpServer,
deleteMcpServers,
} = useMcpSetting();
// 本地状态
const [searchString, setSearchString] = useState('');
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [editingServer, setEditingServer] = useState<Partial<IMcpServer> | undefined | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(6);
const [importJson, setImportJson] = useState('');
const showMessage = useMessage()
// 初始化加载数据
useEffect(() => {
fetchMcpServers();
}, [fetchMcpServers]);
// 过滤和分页逻辑
const filteredServers = mcpServers.filter(server =>
server.name.toLowerCase().includes(searchString.toLowerCase()) ||
server.url.toLowerCase().includes(searchString.toLowerCase())
);
const totalPages = Math.ceil(filteredServers.length / pageSize);
const paginatedServers = filteredServers.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchString(event.target.value);
setCurrentPage(1);
};
const handleSelectServer = (serverId: string) => {
setSelectedServers(prev =>
prev.includes(serverId)
? prev.filter(id => id !== serverId)
: [...prev, serverId]
);
};
const handleSelectAll = () => {
if (selectedServers.length === paginatedServers.length) {
setSelectedServers([]);
} else {
setSelectedServers(paginatedServers.map(server => server.id));
}
};
const handleMenuClick = (event: React.MouseEvent<HTMLElement>, serverId: string) => {
setAnchorEl(event.currentTarget);
setSelectedServerId(serverId);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedServerId(null);
};
const handleEdit = async () => {
// const server = mcpServers.find(s => s.id === selectedServerId);
const result = await getMcpServerDetail(selectedServerId || '');
if (result.success) {
const server = result.data;
if (server != null && server != undefined) {
const headers = server?.headers || {};
const authorization_token = headers['authorization_token'] || '';
if (authorization_token && authorization_token.length > 0) {
server!.variables = {
...server?.variables,
authorization_token,
}
}
}
setEditingServer(server);
setMcpDialogOpen(true);
}
handleMenuClose();
};
const handleAdd = () => {
setEditingServer(null);
setMcpDialogOpen(true);
};
const handleDelete = async () => {
if (selectedServerId) {
const result = await deleteMcpServer(selectedServerId);
if (result.success) {
showMessage.success('删除成功');
} else {
showMessage.error(result.error || '删除失败');
}
}
handleMenuClose();
};
const handleBulkDelete = async () => {
const result = await deleteMcpServers(selectedServers);
if (result.success) {
showMessage.success('批量删除成功');
setSelectedServers([]);
} else {
showMessage.error(result.error || '批量删除失败');
}
};
const handleExport = async () => {
const result = await exportMcpServers(selectedServers);
if (result.success) {
showMessage.success('导出成功');
} else {
showMessage.error(result.error || '导出失败');
}
};
const handleMcpSave = async (data: ICreateMcpServerRequestBody) => {
try {
if (editingServer) {
if (!editingServer.id) {
showMessage.error('server id 不能为空');
return { success: false, error: 'server id 不能为空' };
}
const result = await updateMcpServer({ ...data, mcp_id: editingServer.id ?? '' });
if (result.success) {
showMessage.success('MCP 服务器更新成功');
setMcpDialogOpen(false);
setEditingServer(null);
return result;
} else {
return result;
}
} else {
const result = await createMcpServer(data);
if (result.success) {
showMessage.success('MCP 服务器创建成功');
setMcpDialogOpen(false);
setEditingServer(null);
return result;
} else {
return result;
}
}
} catch (error: any) {
const errorMessage = error.message || '操作失败';
return { success: false, error: errorMessage };
}
};
const handleTestMcpServer = async (data: ITestMcpRequestBody) => {
try {
const result = await testMcpServer(data);
if (result.success) {
showMessage.success('测试成功');
return result;
} else {
return result;
}
} catch (error: any) {
const errorMessage = error.message || '操作失败';
return { success: false, error: errorMessage };
}
};
const handleImport = async () => {
try {
const importData = JSON.parse(importJson);
if (importData.mcpServers) {
const requestData: IImportMcpServersRequestBody = {
mcpServers: Object.entries(importData.mcpServers).reduce((acc, [key, value]: [string, any]) => {
acc[key] = {
type: value.type,
url: value.url,
authorization_token: value.authorization_token || ''
};
return acc;
}, {} as any)
};
const result = await importMcpServers(requestData);
if (result.success) {
setImportDialogOpen(false);
setImportJson('');
}
} else {
showMessage.error('JSON 格式错误');
}
} catch (error) {
showMessage.error('JSON 格式错误');
}
};
return (
<div>
<h1>MCP Setting</h1>
</div>
<PageContainer>
<PageHeader>
<Box>
<Typography variant="h4" fontWeight={600} color="#333">
MCP Servers
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Customize the list of MCP servers
</Typography>
</Box>
<Box display="flex" gap={2}>
<Button
variant="outlined"
startIcon={<ImportIcon />}
onClick={() => setImportDialogOpen(true)}
>
Import
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
>
Add MCP
</Button>
</Box>
</PageHeader>
<SearchContainer>
<TextField
size="small"
placeholder="Search MCP servers..."
value={searchString}
onChange={handleSearchChange}
InputProps={{
startAdornment: <SearchIcon sx={{ mr: 1, color: 'text.secondary' }} />
}}
sx={{ width: 300 }}
/>
{selectedServers.length > 0 && (
<Box display="flex" gap={1}>
<Button
size="small"
startIcon={<DeleteIcon />}
onClick={handleBulkDelete}
color="error"
>
Delete ({selectedServers.length})
</Button>
<Button
size="small"
startIcon={<ExportIcon />}
onClick={handleExport}
>
Export ({selectedServers.length})
</Button>
</Box>
)}
</SearchContainer>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
) : (
<>
<Grid container spacing={3}>
{paginatedServers.map((server) => (
<Grid size={{ xs: 6, sm: 4, md: 3 }} key={server.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: selectedServers.includes(server.id) ? '2px solid #1976d2' : '1px solid #e0e0e0'
}}
>
<CardContent sx={{ flexGrow: 1 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Checkbox
checked={selectedServers.includes(server.id)}
onChange={() => handleSelectServer(server.id)}
size="small"
/>
<IconButton
size="small"
onClick={(e) => handleMenuClick(e, server.id)}
>
<MoreVertIcon />
</IconButton>
</Box>
<Typography variant="h6" component="h3" gutterBottom>
{server.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
: {server.server_type}
</Typography>
<Typography variant="body2" color="text.secondary">
: {dayjs(server.update_date).format('YYYY-MM-DD HH:mm:ss')}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* 分页 */}
<Box display="flex" justifyContent="center" alignItems="center" mt={3}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
color="primary"
/>
<Typography variant="body2" sx={{ ml: 2 }}>
{total}
</Typography>
</Box>
</>
)}
{/* 操作菜单 */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleEdit}>
<EditIcon sx={{ mr: 1 }} fontSize="small" />
Edit
</MenuItem>
<MenuItem onClick={handleDelete} sx={{ color: 'error.main' }}>
<DeleteIcon sx={{ mr: 1 }} fontSize="small" />
Delete
</MenuItem>
</Menu>
{/* MCP 对话框 */}
<McpDialog
open={mcpDialogOpen}
onClose={() => {
setMcpDialogOpen(false);
setEditingServer(null);
}}
onSave={handleMcpSave}
onTest={handleTestMcpServer}
initialData={editingServer || {}}
mode={editingServer ? 'edit' : 'create'}
/>
{/* 导入对话框 */}
<Dialog open={importDialogOpen} onClose={() => setImportDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Import MCP Servers</DialogTitle>
<DialogContent>
<Alert severity="info" sx={{ mb: 2 }}>
Paste your MCP servers JSON configuration below. The format should match the Mock.json structure.
</Alert>
<TextField
fullWidth
multiline
rows={10}
label="JSON Configuration"
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder={`{
"mcpServers": {
"Time": {
"type": "sse",
"url": "http://localhost:8080",
"name": "Time Server",
"authorization_token": "",
"tool_configuration": {}
}
}
}`}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setImportDialogOpen(false)}>Cancel</Button>
<Button onClick={handleImport} variant="contained">
Import
</Button>
</DialogActions>
</Dialog>
</PageContainer>
);
}
export default MCPSetting;

View File

@@ -4,6 +4,9 @@ import type { ITenantInfo } from '@/interfaces/database/knowledge';
import type { IUserInfo, ITenant } from '@/interfaces/database/user-setting';
import type { LlmModelType } from '@/constants/knowledge';
import type { IAddLlmRequestBody, IDeleteLlmRequestBody, ISetApiKeyRequestBody } from '@/interfaces/request/llm';
import type { IMcpServer } from '@/interfaces/database/mcp';
import type { ICreateMcpServerRequestBody, IImportMcpServersRequestBody, ITestMcpRequestBody } from '@/interfaces/request/mcp';
import type { IPaginationBody, IPaginationRequestBody } from '@/interfaces/request/base';
// 用户相关API服务
const userService = {
@@ -121,6 +124,48 @@ const userService = {
system_status: () => {
return request.get(api.getSystemStatus);
},
/** mcp server 相关接口 */
// list mcp server
listMcpServer: (params: IPaginationBody & { keyword?: string }) => {
return request.post(api.listMcpServer, {}, { params });
},
// create mcp server
createMcpServer: (data: ICreateMcpServerRequestBody) => {
return request.post(api.createMcpServer, data);
},
// update mcp server
updateMcpServer: (data: ICreateMcpServerRequestBody & { mcp_id: string }) => {
return request.post(api.updateMcpServer, data);
},
// remove mcp server
removeMcpServer: (mcp_ids: string[]) => {
return request.post(api.deleteMcpServer, { mcp_ids });
},
// import mcp server
importMcpServer: (data: IImportMcpServersRequestBody) => {
return request.post(api.importMcpServer, data);
},
// export mcp server
exportMcpServer: (mcp_ids: string[]) => {
return request.post(api.exportMcpServer, { mcp_ids });
},
// mcp detail
mcpDetail: (mcpId: string) => {
return request.get(api.getMcpServer, { params: { mcp_id: mcpId } });
},
// test mcp server
testMcpServer: (data: ITestMcpRequestBody) => {
return request.post(api.testMcpServer, data);
},
};
export default userService;

View File

@@ -92,12 +92,12 @@ class CustomError extends Error {
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 转换数据格式 - 跳过FormData对象
if (config.data && !(config.data instanceof FormData)) {
config.data = convertTheKeysOfTheObjectToSnake(config.data);
}
if (config.params) {
config.params = convertTheKeysOfTheObjectToSnake(config.params);
}
// if (config.data && !(config.data instanceof FormData)) {
// config.data = convertTheKeysOfTheObjectToSnake(config.data);
// }
// if (config.params) {
// config.params = convertTheKeysOfTheObjectToSnake(config.params);
// }
// 对于FormData删除默认的Content-Type让浏览器自动设置
if (config.data instanceof FormData) {