From 3d81df739dc45599c257d8cdefe0996f66ccdeae Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 19 三月 2026 12:18:14 +0800
Subject: [PATCH] #AI.MCP 管理增强
---
rsf-admin/src/api/ai/mcpMount.js | 9
rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx | 344 +++++++++++++++++++----
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java | 108 ++++++
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java | 6
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx | 205 ++++++++++----
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpConnectivityTestDto.java | 23 +
version/db/ai_feature.sql | 72 +++++
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java | 3
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java | 3
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java | 25 +
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx | 15 +
11 files changed, 668 insertions(+), 145 deletions(-)
diff --git a/rsf-admin/src/api/ai/mcpMount.js b/rsf-admin/src/api/ai/mcpMount.js
index 399d0c9..bd8a831 100644
--- a/rsf-admin/src/api/ai/mcpMount.js
+++ b/rsf-admin/src/api/ai/mcpMount.js
@@ -9,6 +9,15 @@
throw new Error(msg || "鑾峰彇宸ュ叿鍒楄〃澶辫触");
};
+export const testMcpConnectivity = async (mountId) => {
+ const res = await request.post(`aiMcpMount/${mountId}/connectivity/test`);
+ const { code, msg, data } = res.data;
+ if (code === 200) {
+ return data;
+ }
+ throw new Error(msg || "杩為�氭�ф祴璇曞け璐�");
+};
+
export const testMcpTool = async (mountId, payload) => {
const res = await request.post(`aiMcpMount/${mountId}/tool/test`, payload);
const { code, msg, data } = res.data;
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
index b88807a..b05ac16 100644
--- a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx
@@ -87,6 +87,21 @@
<Grid item xs={12}>
<TextInput source="memo" label="澶囨敞" fullWidth multiline minRows={3} disabled={readOnly} />
</Grid>
+ <Grid item xs={12}>
+ <Typography variant="h6">杩愯鎬佷俊鎭�</Typography>
+ </Grid>
+ <Grid item xs={12} md={4}>
+ <TextInput source="healthStatus" label="鍋ュ悍鐘舵��" fullWidth disabled />
+ </Grid>
+ <Grid item xs={12} md={4}>
+ <TextInput source="lastInitElapsedMs" label="鏈�杩戝垵濮嬪寲鑰楁椂(ms)" fullWidth disabled />
+ </Grid>
+ <Grid item xs={12} md={4}>
+ <TextInput source="lastTestTime$" label="鏈�杩戞祴璇曟椂闂�" fullWidth disabled />
+ </Grid>
+ <Grid item xs={12}>
+ <TextInput source="lastTestMessage" label="鏈�杩戞祴璇曠粨鏋�" fullWidth multiline minRows={3} disabled />
+ </Grid>
</Grid>
);
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
index a543ed8..9601238 100644
--- a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx
@@ -27,10 +27,12 @@
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
+import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined";
import MyExportButton from "@/page/components/MyExportButton";
import AiMcpMountForm from "./AiMcpMountForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
import AiMcpMountToolsPanel from "./AiMcpMountToolsPanel";
+import { testMcpConnectivity } from "@/api/ai/mcpMount";
const filters = [
<SearchInput source="condition" alwaysOn />,
@@ -59,6 +61,7 @@
requestTimeoutMs: 60000,
sort: 0,
status: 1,
+ healthStatus: "NOT_TESTED",
};
const truncateText = (value, max = 96) => {
@@ -78,9 +81,31 @@
return record.serverUrl || "--";
};
-const AiMcpMountCards = ({ onView, onEdit, onDelete, deleting }) => {
+const transportGroups = [
+ { key: "BUILTIN", title: "鍐呯疆 MCP", description: "绯荤粺鍐呯疆宸ュ叿鎸傝浇锛岄�傚悎鐩存帴鏆撮湶骞冲彴鑳藉姏銆�" },
+ { key: "SSE_HTTP", title: "杩滅▼ SSE MCP", description: "閫氳繃杩滅▼ MCP Server 鎸傝浇澶栭儴宸ュ叿銆�" },
+ { key: "STDIO", title: "鏈湴 STDIO MCP", description: "閫氳繃鏈湴鍛戒护杩涚▼鎸傝浇澶栭儴 MCP銆�" },
+];
+
+const resolveHealthMeta = (record) => {
+ if (record.healthStatus === "HEALTHY") {
+ return { color: "success", label: "姝e父" };
+ }
+ if (record.healthStatus === "UNHEALTHY") {
+ return { color: "error", label: "澶辫触" };
+ }
+ return { color: "default", label: "鏈祴璇�" };
+};
+
+const AiMcpMountCards = ({ onView, onEdit, onDelete, onConnectivityTest, deleting, testingConnectivityId }) => {
const { data, isLoading } = useListContext();
const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
+ const groupedRecords = useMemo(() => {
+ return transportGroups.map((group) => ({
+ ...group,
+ records: records.filter((item) => item.transportType === group.key),
+ })).filter((group) => group.records.length > 0);
+ }, [records]);
if (isLoading) {
return (
@@ -105,70 +130,103 @@
return (
<Box px={2} py={2}>
- <Grid container spacing={2}>
- {records.map((record) => (
- <Grid item xs={12} md={6} xl={4} key={record.id}>
- <Card
- variant="outlined"
- sx={{
- height: "100%",
- borderRadius: 3,
- boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
- }}
- >
- <CardContent sx={{ pb: 1.5 }}>
- <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
- <Box>
- <Typography variant="h6" sx={{ mb: 0.5 }}>
- {record.name}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {record.transportType$ || record.transportType || "--"}
- </Typography>
- </Box>
- <Chip
- size="small"
- color={record.statusBool ? "success" : "default"}
- label={record.statusBool ? "鍚敤" : "鍋滅敤"}
- />
- </Stack>
- <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
- <Chip size="small" variant="outlined" label={`鎺掑簭 ${record.sort ?? 0}`} />
- <Chip size="small" variant="outlined" label={`${record.requestTimeoutMs ?? "--"} ms`} />
- </Stack>
- <Divider sx={{ my: 1.5 }} />
- <Typography variant="caption" color="text.secondary">鐩爣</Typography>
- <Typography variant="body2" sx={{ mt: 0.5, wordBreak: "break-all" }}>
- {truncateText(resolveTargetLabel(record), 120)}
- </Typography>
- <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
- 澶囨敞
- </Typography>
- <Typography variant="body2">{truncateText(record.memo)}</Typography>
- </CardContent>
- <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between" }}>
- <Stack direction="row" spacing={1}>
- <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
- 璇︽儏
- </Button>
- <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
- 缂栬緫
- </Button>
- </Stack>
- <Button
- size="small"
- color="error"
- startIcon={<DeleteOutlineOutlinedIcon />}
- onClick={() => onDelete(record)}
- disabled={deleting}
- >
- 鍒犻櫎
- </Button>
- </CardActions>
- </Card>
- </Grid>
+ <Stack spacing={3}>
+ {groupedRecords.map((group) => (
+ <Box key={group.key}>
+ <Box mb={1.5}>
+ <Typography variant="h6">{group.title}</Typography>
+ <Typography variant="body2" color="text.secondary">
+ {group.description}
+ </Typography>
+ </Box>
+ <Grid container spacing={2}>
+ {group.records.map((record) => {
+ const healthMeta = resolveHealthMeta(record);
+ return (
+ <Grid item xs={12} md={6} xl={4} key={record.id}>
+ <Card
+ variant="outlined"
+ sx={{
+ height: "100%",
+ borderRadius: 3,
+ boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
+ }}
+ >
+ <CardContent sx={{ pb: 1.5 }}>
+ <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
+ <Box>
+ <Typography variant="h6" sx={{ mb: 0.5 }}>
+ {record.name}
+ </Typography>
+ <Typography variant="body2" color="text.secondary">
+ {record.transportType$ || record.transportType || "--"}
+ </Typography>
+ </Box>
+ <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap justifyContent="flex-end">
+ <Chip
+ size="small"
+ color={record.statusBool ? "success" : "default"}
+ label={record.statusBool ? "鍚敤" : "鍋滅敤"}
+ />
+ <Chip size="small" color={healthMeta.color} label={healthMeta.label} />
+ </Stack>
+ </Stack>
+ <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
+ <Chip size="small" variant="outlined" label={`鎺掑簭 ${record.sort ?? 0}`} />
+ <Chip size="small" variant="outlined" label={`${record.requestTimeoutMs ?? "--"} ms`} />
+ <Chip size="small" variant="outlined" label={`Init ${record.lastInitElapsedMs ?? "--"} ms`} />
+ </Stack>
+ <Divider sx={{ my: 1.5 }} />
+ <Typography variant="caption" color="text.secondary">鐩爣</Typography>
+ <Typography variant="body2" sx={{ mt: 0.5, wordBreak: "break-all" }}>
+ {truncateText(resolveTargetLabel(record), 120)}
+ </Typography>
+ <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
+ 鏈�杩戞祴璇�
+ </Typography>
+ <Typography variant="body2">
+ {record.lastTestTime$ ? `${record.lastTestTime$} 路 ${truncateText(record.lastTestMessage, 72)}` : "灏氭湭鎵ц杩為�氭�ф祴璇�"}
+ </Typography>
+ <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
+ 澶囨敞
+ </Typography>
+ <Typography variant="body2">{truncateText(record.memo)}</Typography>
+ </CardContent>
+ <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between", alignItems: "flex-start" }}>
+ <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
+ <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
+ 璇︽儏
+ </Button>
+ <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
+ 缂栬緫
+ </Button>
+ <Button
+ size="small"
+ startIcon={<PlayCircleOutlineOutlinedIcon />}
+ onClick={() => onConnectivityTest(record)}
+ disabled={testingConnectivityId === record.id}
+ >
+ {testingConnectivityId === record.id ? "娴嬭瘯涓�..." : "杩為�氭祴璇�"}
+ </Button>
+ </Stack>
+ <Button
+ size="small"
+ color="error"
+ startIcon={<DeleteOutlineOutlinedIcon />}
+ onClick={() => onDelete(record)}
+ disabled={deleting}
+ >
+ 鍒犻櫎
+ </Button>
+ </CardActions>
+ </Card>
+ </Grid>
+ );
+ })}
+ </Grid>
+ </Box>
))}
- </Grid>
+ </Stack>
</Box>
);
};
@@ -178,6 +236,7 @@
const refresh = useRefresh();
const [deleteOne, { isPending: deleting }] = useDelete();
const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null });
+ const [testingConnectivityId, setTestingConnectivityId] = useState(null);
const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId });
const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null });
@@ -199,6 +258,22 @@
},
}
);
+ };
+
+ const handleConnectivityTest = async (record) => {
+ if (!record?.id) {
+ return;
+ }
+ setTestingConnectivityId(record.id);
+ try {
+ const result = await testMcpConnectivity(record.id);
+ notify(result?.message || "杩為�氭�ф祴璇曞畬鎴�");
+ refresh();
+ } catch (error) {
+ notify(error?.message || "杩為�氭�ф祴璇曞け璐�", { type: "error" });
+ } finally {
+ setTestingConnectivityId(null);
+ }
};
const dialogTitle = {
@@ -227,7 +302,9 @@
onView={(id) => openDialog("show", id)}
onEdit={(id) => openDialog("edit", id)}
onDelete={handleDelete}
+ onConnectivityTest={handleConnectivityTest}
deleting={deleting}
+ testingConnectivityId={testingConnectivityId}
/>
</List>
<AiConfigDialog
diff --git a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx
index 1901478..2a333e9 100644
--- a/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx
+++ b/rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionDetails,
@@ -9,8 +9,8 @@
Card,
CardContent,
CircularProgress,
- Divider,
Grid,
+ MenuItem,
Stack,
TextField,
Typography,
@@ -19,7 +19,102 @@
import PreviewOutlinedIcon from "@mui/icons-material/PreviewOutlined";
import ExpandMoreOutlinedIcon from "@mui/icons-material/ExpandMoreOutlined";
import { useNotify } from "react-admin";
-import { previewMcpTools, testMcpTool } from "@/api/ai/mcpMount";
+import { previewMcpTools, testMcpConnectivity, testMcpTool } from "@/api/ai/mcpMount";
+
+const parseInputSchema = (inputSchema) => {
+ if (!inputSchema) {
+ return { pretty: "", fields: [], required: [], error: "" };
+ }
+ try {
+ const schema = JSON.parse(inputSchema);
+ const properties = schema?.properties || {};
+ const required = Array.isArray(schema?.required) ? schema.required : [];
+ return {
+ pretty: JSON.stringify(schema, null, 2),
+ required,
+ error: "",
+ fields: Object.entries(properties).map(([name, definition]) => ({
+ name,
+ title: definition?.title || name,
+ description: definition?.description || "",
+ type: definition?.type || "string",
+ enumValues: Array.isArray(definition?.enum) ? definition.enum : [],
+ })),
+ };
+ } catch (error) {
+ return {
+ pretty: inputSchema,
+ fields: [],
+ required: [],
+ error: `Input Schema 瑙f瀽澶辫触: ${error.message}`,
+ };
+ }
+};
+
+const normalizeFieldValue = (field, rawValue) => {
+ if (rawValue === "" || rawValue == null) {
+ return undefined;
+ }
+ if (field.type === "integer") {
+ const parsed = Number.parseInt(rawValue, 10);
+ return Number.isNaN(parsed) ? rawValue : parsed;
+ }
+ if (field.type === "number") {
+ const parsed = Number(rawValue);
+ return Number.isNaN(parsed) ? rawValue : parsed;
+ }
+ if (field.type === "boolean") {
+ return rawValue === true || rawValue === "true";
+ }
+ return rawValue;
+};
+
+const buildInputJson = (schemaInfo, fieldValues) => {
+ if (!schemaInfo?.fields?.length) {
+ return "";
+ }
+ const payload = {};
+ schemaInfo.fields.forEach((field) => {
+ const normalized = normalizeFieldValue(field, fieldValues?.[field.name]);
+ if (normalized !== undefined) {
+ payload[field.name] = normalized;
+ }
+ });
+ return JSON.stringify(payload, null, 2);
+};
+
+const readStructuredValues = (schemaInfo, inputJson) => {
+ if (!schemaInfo?.fields?.length || !inputJson || !inputJson.trim()) {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(inputJson);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+ const values = {};
+ schemaInfo.fields.forEach((field) => {
+ const value = parsed[field.name];
+ if (value === undefined || value === null) {
+ return;
+ }
+ values[field.name] = typeof value === "boolean" ? String(value) : String(value);
+ });
+ return values;
+ } catch (error) {
+ return {};
+ }
+};
+
+const resolveConnectivitySeverity = (healthStatus) => {
+ if (healthStatus === "HEALTHY") {
+ return "success";
+ }
+ if (healthStatus === "UNHEALTHY") {
+ return "error";
+ }
+ return "info";
+};
const AiMcpMountToolsPanel = ({ mountId }) => {
const notify = useNotify();
@@ -27,14 +122,26 @@
const [tools, setTools] = useState([]);
const [error, setError] = useState("");
const [inputs, setInputs] = useState({});
+ const [structuredInputs, setStructuredInputs] = useState({});
const [outputs, setOutputs] = useState({});
const [testingToolName, setTestingToolName] = useState("");
+ const [testingConnectivity, setTestingConnectivity] = useState(false);
+ const [connectivity, setConnectivity] = useState(null);
+
+ const schemaInfoMap = useMemo(() => {
+ return tools.reduce((result, tool) => {
+ result[tool.name] = parseInputSchema(tool.inputSchema);
+ return result;
+ }, {});
+ }, [tools]);
useEffect(() => {
if (!mountId) {
setTools([]);
setInputs({});
+ setStructuredInputs({});
setOutputs({});
+ setConnectivity(null);
setError("");
return;
}
@@ -48,10 +155,26 @@
const data = await previewMcpTools(mountId);
setTools(data);
setOutputs({});
+ setInputs({});
+ setStructuredInputs({});
} catch (requestError) {
setError(requestError.message || "鑾峰彇宸ュ叿鍒楄〃澶辫触");
} finally {
setLoading(false);
+ }
+ };
+
+ const handleConnectivityTest = async () => {
+ setTestingConnectivity(true);
+ try {
+ const result = await testMcpConnectivity(mountId);
+ setConnectivity(result);
+ notify(result?.message || "杩為�氭�ф祴璇曞畬鎴�");
+ } catch (requestError) {
+ const message = requestError.message || "杩為�氭�ф祴璇曞け璐�";
+ notify(message, { type: "error" });
+ } finally {
+ setTestingConnectivity(false);
}
};
@@ -60,6 +183,28 @@
...prev,
[toolName]: value,
}));
+ setStructuredInputs((prev) => ({
+ ...prev,
+ [toolName]: readStructuredValues(schemaInfoMap[toolName], value),
+ }));
+ };
+
+ const handleStructuredFieldChange = (toolName, fieldName, value) => {
+ const schemaInfo = schemaInfoMap[toolName];
+ setStructuredInputs((prev) => {
+ const nextToolValues = {
+ ...(prev[toolName] || {}),
+ [fieldName]: value,
+ };
+ setInputs((prevInputs) => ({
+ ...prevInputs,
+ [toolName]: buildInputJson(schemaInfo, nextToolValues),
+ }));
+ return {
+ ...prev,
+ [toolName]: nextToolValues,
+ };
+ });
};
const handleTest = async (toolName) => {
@@ -106,16 +251,33 @@
<Box flex={1}>
<Typography variant="h6">宸ュ叿棰勮涓庢祴璇�</Typography>
<Typography variant="body2" color="text.secondary">
- 褰撳墠鎸傝浇瑙f瀽鍑虹殑鍏ㄩ儴宸ュ叿閮芥樉绀哄湪杩欓噷锛屽彲鐩存帴杈撳叆 JSON 鍋氭祴璇曘��
+ 鏀寔杩為�氭�ф祴璇曘�佺粨鏋勫寲 Schema 棰勮鍜屾寜杈撳叆鍙傛暟鑷姩鐢熸垚娴嬭瘯琛ㄥ崟銆�
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
- <Stack direction="row" justifyContent="flex-end" alignItems="center" mb={1.5}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center" mb={1.5} flexWrap="wrap" useFlexGap>
<Button size="small" startIcon={<PreviewOutlinedIcon />} onClick={loadTools} disabled={loading}>
鍒锋柊宸ュ叿
</Button>
+ <Button
+ size="small"
+ variant="outlined"
+ startIcon={<PlayCircleOutlineOutlinedIcon />}
+ onClick={handleConnectivityTest}
+ disabled={testingConnectivity}
+ >
+ {testingConnectivity ? "娴嬭瘯涓�..." : "杩為�氭�ф祴璇�"}
+ </Button>
</Stack>
+ {!!connectivity && (
+ <Alert severity={resolveConnectivitySeverity(connectivity.healthStatus)} sx={{ mb: 2 }}>
+ {connectivity.message}
+ {connectivity.initElapsedMs != null && ` 路 Init ${connectivity.initElapsedMs} ms`}
+ {connectivity.toolCount != null && ` 路 Tools ${connectivity.toolCount}`}
+ {connectivity.testedAt && ` 路 ${connectivity.testedAt}`}
+ </Alert>
+ )}
{loading && (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress size={28} />
@@ -130,71 +292,115 @@
<Alert severity="info">褰撳墠鎸傝浇鏈В鏋愬嚭浠讳綍宸ュ叿銆�</Alert>
)}
<Grid container spacing={2}>
- {tools.map((tool) => (
- <Grid item xs={12} key={tool.name}>
- <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}>
- <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}>
- <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} width="100%" pr={1}>
- <Box>
- <Typography variant="subtitle1">{tool.name}</Typography>
- <Typography variant="body2" color="text.secondary">
- {tool.description || "鏆傛棤鎻忚堪"}
- </Typography>
- </Box>
- <Typography variant="caption" color="text.secondary">
- {tool.returnDirect ? "returnDirect" : "normal"}
- </Typography>
- </Stack>
- </AccordionSummary>
- <AccordionDetails>
- <Card variant="outlined" sx={{ borderRadius: 3 }}>
- <CardContent>
- <TextField
- label="Input Schema"
- value={tool.inputSchema || ""}
- fullWidth
- multiline
- minRows={5}
- maxRows={12}
- InputProps={{ readOnly: true }}
- />
- <TextField
- label="娴嬭瘯杈撳叆 JSON"
- value={inputs[tool.name] || ""}
- onChange={(event) => handleInputChange(tool.name, event.target.value)}
- fullWidth
- multiline
- minRows={5}
- maxRows={12}
- sx={{ mt: 2 }}
- placeholder='渚嬪锛歿"code":"A01"}'
- />
- <Stack direction="row" justifyContent="flex-end" mt={1.5}>
- <Button
- variant="contained"
- startIcon={<PlayCircleOutlineOutlinedIcon />}
- onClick={() => handleTest(tool.name)}
- disabled={testingToolName === tool.name}
- >
- {testingToolName === tool.name ? "娴嬭瘯涓�..." : "鎵ц娴嬭瘯"}
- </Button>
+ {tools.map((tool) => {
+ const schemaInfo = schemaInfoMap[tool.name] || { pretty: "", fields: [], required: [], error: "" };
+ const structuredValues = structuredInputs[tool.name] || {};
+ return (
+ <Grid item xs={12} key={tool.name}>
+ <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}>
+ <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}>
+ <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} width="100%" pr={1}>
+ <Box>
+ <Typography variant="subtitle1">{tool.name}</Typography>
+ <Typography variant="body2" color="text.secondary">
+ {tool.description || "鏆傛棤鎻忚堪"}
+ </Typography>
+ </Box>
+ <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
+ <Typography variant="caption" color="text.secondary">
+ {schemaInfo.fields.length} 涓弬鏁�
+ </Typography>
+ <Typography variant="caption" color="text.secondary">
+ {tool.returnDirect ? "returnDirect" : "normal"}
+ </Typography>
</Stack>
- <TextField
- label="娴嬭瘯缁撴灉"
- value={outputs[tool.name] || ""}
- fullWidth
- multiline
- minRows={5}
- maxRows={16}
- sx={{ mt: 2 }}
- InputProps={{ readOnly: true }}
- />
- </CardContent>
- </Card>
- </AccordionDetails>
- </Accordion>
- </Grid>
- ))}
+ </Stack>
+ </AccordionSummary>
+ <AccordionDetails>
+ <Card variant="outlined" sx={{ borderRadius: 3 }}>
+ <CardContent>
+ {!!schemaInfo.error && (
+ <Alert severity="warning" sx={{ mb: 2 }}>
+ {schemaInfo.error}
+ </Alert>
+ )}
+ <TextField
+ label="鏍煎紡鍖� Input Schema"
+ value={schemaInfo.pretty || tool.inputSchema || ""}
+ fullWidth
+ multiline
+ minRows={6}
+ maxRows={16}
+ InputProps={{ readOnly: true }}
+ />
+ {!!schemaInfo.fields.length && (
+ <Grid container spacing={2} sx={{ mt: 0.5 }}>
+ {schemaInfo.fields.map((field) => (
+ <Grid item xs={12} md={field.type === "boolean" ? 6 : 12} key={`${tool.name}-${field.name}`}>
+ <TextField
+ select={field.type === "boolean" || field.enumValues.length > 0}
+ type={field.type === "integer" || field.type === "number" ? "number" : "text"}
+ label={`${field.title}${schemaInfo.required.includes(field.name) ? " *" : ""}`}
+ value={structuredValues[field.name] ?? ""}
+ onChange={(event) => handleStructuredFieldChange(tool.name, field.name, event.target.value)}
+ fullWidth
+ helperText={field.description || field.type}
+ sx={{ mt: 2 }}
+ >
+ {field.type === "boolean" && (
+ [
+ <MenuItem key="true" value="true">true</MenuItem>,
+ <MenuItem key="false" value="false">false</MenuItem>,
+ ]
+ )}
+ {field.type !== "boolean" && field.enumValues.map((value) => (
+ <MenuItem key={value} value={String(value)}>
+ {String(value)}
+ </MenuItem>
+ ))}
+ </TextField>
+ </Grid>
+ ))}
+ </Grid>
+ )}
+ <TextField
+ label="娴嬭瘯杈撳叆 JSON"
+ value={inputs[tool.name] || ""}
+ onChange={(event) => handleInputChange(tool.name, event.target.value)}
+ fullWidth
+ multiline
+ minRows={5}
+ maxRows={12}
+ sx={{ mt: 2 }}
+ placeholder='渚嬪锛歿"code":"A01"}'
+ />
+ <Stack direction="row" justifyContent="flex-end" mt={1.5}>
+ <Button
+ variant="contained"
+ startIcon={<PlayCircleOutlineOutlinedIcon />}
+ onClick={() => handleTest(tool.name)}
+ disabled={testingToolName === tool.name}
+ >
+ {testingToolName === tool.name ? "娴嬭瘯涓�..." : "鎵ц娴嬭瘯"}
+ </Button>
+ </Stack>
+ <TextField
+ label="娴嬭瘯缁撴灉"
+ value={outputs[tool.name] || ""}
+ fullWidth
+ multiline
+ minRows={5}
+ maxRows={16}
+ sx={{ mt: 2 }}
+ InputProps={{ readOnly: true }}
+ />
+ </CardContent>
+ </Card>
+ </AccordionDetails>
+ </Accordion>
+ </Grid>
+ );
+ })}
</Grid>
</AccordionDetails>
</Accordion>
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
index 7cc00eb..fe09412 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
@@ -14,6 +14,9 @@
public static final String MCP_BUILTIN_RSF_WMS_STOCK = "RSF_WMS_STOCK";
public static final String MCP_BUILTIN_RSF_WMS_TASK = "RSF_WMS_TASK";
public static final String MCP_BUILTIN_RSF_WMS_BASE = "RSF_WMS_BASE";
+ public static final String MCP_HEALTH_NOT_TESTED = "NOT_TESTED";
+ public static final String MCP_HEALTH_HEALTHY = "HEALTHY";
+ public static final String MCP_HEALTH_UNHEALTHY = "UNHEALTHY";
public static final long SSE_TIMEOUT_MS = 0L;
public static final int DEFAULT_TIMEOUT_MS = 60000;
public static final double DEFAULT_TEMPERATURE = 0.7D;
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
index 3525e6e..90ba38f 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java
@@ -71,6 +71,12 @@
}
@PreAuthorize("hasAuthority('system:aiMcpMount:update')")
+ @PostMapping("/aiMcpMount/{id}/connectivity/test")
+ public R testConnectivity(@PathVariable("id") Long id) {
+ return R.ok().add(aiMcpMountService.testConnectivity(id, getLoginUserId(), getTenantId()));
+ }
+
+ @PreAuthorize("hasAuthority('system:aiMcpMount:update')")
@PostMapping("/aiMcpMount/{id}/tool/test")
public R testTool(@PathVariable("id") Long id, @RequestBody AiMcpToolTestRequest request) {
return R.ok().add(aiMcpMountService.testTool(id, getLoginUserId(), getTenantId(), request));
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpConnectivityTestDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpConnectivityTestDto.java
new file mode 100644
index 0000000..2bd80a6
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpConnectivityTestDto.java
@@ -0,0 +1,23 @@
+package com.vincent.rsf.server.ai.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class AiMcpConnectivityTestDto {
+
+ private Long mountId;
+
+ private String mountName;
+
+ private String healthStatus;
+
+ private String message;
+
+ private Long initElapsedMs;
+
+ private Integer toolCount;
+
+ private String testedAt;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java
index ffaec3c..04a7796 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java
@@ -54,6 +54,20 @@
@ApiModelProperty(value = "瓒呮椂鏃堕棿")
private Integer requestTimeoutMs;
+ @ApiModelProperty(value = "鍋ュ悍鐘舵��")
+ private String healthStatus;
+
+ @ApiModelProperty(value = "鏈�杩戞祴璇曟椂闂�")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+ private Date lastTestTime;
+
+ @ApiModelProperty(value = "鏈�杩戞祴璇曚俊鎭�")
+ private String lastTestMessage;
+
+ @ApiModelProperty(value = "鏈�杩戝垵濮嬪寲鑰楁椂")
+ private Long lastInitElapsedMs;
+
@ApiModelProperty(value = "鎺掑簭")
private Integer sort;
@@ -96,6 +110,17 @@
return this.status == 1;
}
+ public String getHealthStatus$() {
+ return this.healthStatus;
+ }
+
+ public String getLastTestTime$() {
+ if (this.lastTestTime == null) {
+ return "";
+ }
+ return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.lastTestTime);
+ }
+
public String getCreateTime$() {
if (this.createTime == null) {
return "";
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
index efac400..1f9f5bb 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java
@@ -1,6 +1,7 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
+import com.vincent.rsf.server.ai.dto.AiMcpConnectivityTestDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolPreviewDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest;
@@ -18,5 +19,7 @@
List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId);
+ AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId);
+
AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request);
}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
index 7221714..5bde192 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java
@@ -1,10 +1,12 @@
package com.vincent.rsf.server.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.ai.config.AiDefaults;
+import com.vincent.rsf.server.ai.dto.AiMcpConnectivityTestDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolPreviewDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestDto;
import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest;
@@ -22,6 +24,7 @@
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -67,20 +70,50 @@
@Override
public List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId) {
AiMcpMount mount = requireMount(mountId, tenantId);
+ long startedAt = System.currentTimeMillis();
try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) {
- List<AiMcpToolPreviewDto> tools = new ArrayList<>();
- for (ToolCallback callback : runtime.getToolCallbacks()) {
- if (callback == null || callback.getToolDefinition() == null) {
- continue;
- }
- tools.add(AiMcpToolPreviewDto.builder()
- .name(callback.getToolDefinition().name())
- .description(callback.getToolDefinition().description())
- .inputSchema(callback.getToolDefinition().inputSchema())
- .returnDirect(callback.getToolMetadata() == null ? null : callback.getToolMetadata().returnDirect())
- .build());
+ List<AiMcpToolPreviewDto> tools = buildToolPreviewDtos(runtime.getToolCallbacks());
+ if (!runtime.getErrors().isEmpty()) {
+ String message = String.join("锛�", runtime.getErrors());
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, message, System.currentTimeMillis() - startedAt);
+ throw new CoolException(message);
}
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_HEALTHY,
+ "宸ュ叿瑙f瀽鎴愬姛锛屽叡 " + tools.size() + " 涓伐鍏�", System.currentTimeMillis() - startedAt);
return tools;
+ } catch (CoolException e) {
+ throw e;
+ } catch (Exception e) {
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY,
+ "宸ュ叿瑙f瀽澶辫触: " + e.getMessage(), System.currentTimeMillis() - startedAt);
+ throw new CoolException("鑾峰彇宸ュ叿鍒楄〃澶辫触: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId) {
+ AiMcpMount mount = requireMount(mountId, tenantId);
+ long startedAt = System.currentTimeMillis();
+ try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) {
+ long elapsedMs = System.currentTimeMillis() - startedAt;
+ if (!runtime.getErrors().isEmpty()) {
+ String message = String.join("锛�", runtime.getErrors());
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, message, elapsedMs);
+ AiMcpMount latest = requireMount(mount.getId(), tenantId);
+ return buildConnectivityDto(latest, message, elapsedMs, runtime.getToolCallbacks().length);
+ }
+ String message = "杩為�氭�ф祴璇曟垚鍔燂紝瑙f瀽鍑� " + runtime.getToolCallbacks().length + " 涓伐鍏�";
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_HEALTHY, message, elapsedMs);
+ AiMcpMount latest = requireMount(mount.getId(), tenantId);
+ return buildConnectivityDto(latest, message, elapsedMs, runtime.getToolCallbacks().length);
+ } catch (CoolException e) {
+ throw e;
+ } catch (Exception e) {
+ long elapsedMs = System.currentTimeMillis() - startedAt;
+ String message = "杩為�氭�ф祴璇曞け璐�: " + e.getMessage();
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, message, elapsedMs);
+ AiMcpMount latest = requireMount(mount.getId(), tenantId);
+ return buildConnectivityDto(latest, message, elapsedMs, 0);
}
}
@@ -107,6 +140,7 @@
throw new CoolException("宸ュ叿杈撳叆 JSON 鏍煎紡閿欒: " + e.getMessage());
}
AiMcpMount mount = requireMount(mountId, tenantId);
+ long startedAt = System.currentTimeMillis();
try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) {
ToolCallback callback = Arrays.stream(runtime.getToolCallbacks())
.filter(item -> item != null && item.getToolDefinition() != null)
@@ -117,11 +151,21 @@
request.getInputJson(),
new ToolContext(Map.of("userId", userId, "tenantId", tenantId, "mountId", mountId))
);
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_HEALTHY,
+ "宸ュ叿娴嬭瘯鎴愬姛: " + request.getToolName(), System.currentTimeMillis() - startedAt);
return AiMcpToolTestDto.builder()
.toolName(request.getToolName())
.inputJson(request.getInputJson())
.output(output)
.build();
+ } catch (CoolException e) {
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY,
+ "宸ュ叿娴嬭瘯澶辫触: " + e.getMessage(), System.currentTimeMillis() - startedAt);
+ throw e;
+ } catch (Exception e) {
+ updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY,
+ "宸ュ叿娴嬭瘯澶辫触: " + e.getMessage(), System.currentTimeMillis() - startedAt);
+ throw new CoolException("宸ュ叿娴嬭瘯澶辫触: " + e.getMessage());
}
}
@@ -137,6 +181,9 @@
}
if (aiMcpMount.getStatus() == null) {
aiMcpMount.setStatus(StatusType.ENABLE.val);
+ }
+ if (!StringUtils.hasText(aiMcpMount.getHealthStatus())) {
+ aiMcpMount.setHealthStatus(AiDefaults.MCP_HEALTH_NOT_TESTED);
}
}
@@ -226,4 +273,41 @@
throw new CoolException("褰撳墠绉熸埛涓嶅瓨鍦�");
}
}
+
+ private List<AiMcpToolPreviewDto> buildToolPreviewDtos(ToolCallback[] callbacks) {
+ List<AiMcpToolPreviewDto> tools = new ArrayList<>();
+ for (ToolCallback callback : callbacks) {
+ if (callback == null || callback.getToolDefinition() == null) {
+ continue;
+ }
+ tools.add(AiMcpToolPreviewDto.builder()
+ .name(callback.getToolDefinition().name())
+ .description(callback.getToolDefinition().description())
+ .inputSchema(callback.getToolDefinition().inputSchema())
+ .returnDirect(callback.getToolMetadata() == null ? null : callback.getToolMetadata().returnDirect())
+ .build());
+ }
+ return tools;
+ }
+
+ private void updateHealthStatus(Long mountId, String healthStatus, String message, Long initElapsedMs) {
+ this.update(new LambdaUpdateWrapper<AiMcpMount>()
+ .eq(AiMcpMount::getId, mountId)
+ .set(AiMcpMount::getHealthStatus, healthStatus)
+ .set(AiMcpMount::getLastTestTime, new Date())
+ .set(AiMcpMount::getLastTestMessage, message)
+ .set(AiMcpMount::getLastInitElapsedMs, initElapsedMs));
+ }
+
+ private AiMcpConnectivityTestDto buildConnectivityDto(AiMcpMount mount, String message, Long initElapsedMs, Integer toolCount) {
+ return AiMcpConnectivityTestDto.builder()
+ .mountId(mount.getId())
+ .mountName(mount.getName())
+ .healthStatus(mount.getHealthStatus())
+ .message(message)
+ .initElapsedMs(initElapsedMs)
+ .toolCount(toolCount)
+ .testedAt(mount.getLastTestTime$())
+ .build();
+ }
}
diff --git a/version/db/ai_feature.sql b/version/db/ai_feature.sql
index 898732a..35b3112 100644
--- a/version/db/ai_feature.sql
+++ b/version/db/ai_feature.sql
@@ -56,6 +56,10 @@
`env_json` text COMMENT '鐜鍙橀噺 JSON',
`headers_json` text COMMENT '璇锋眰澶� JSON',
`request_timeout_ms` int(11) DEFAULT NULL COMMENT '璇锋眰瓒呮椂鏃堕棿',
+ `health_status` varchar(32) DEFAULT 'NOT_TESTED' COMMENT '鍋ュ悍鐘舵��',
+ `last_test_time` datetime DEFAULT NULL COMMENT '鏈�杩戞祴璇曟椂闂�',
+ `last_test_message` varchar(500) DEFAULT NULL COMMENT '鏈�杩戞祴璇曚俊鎭�',
+ `last_init_elapsed_ms` bigint(20) DEFAULT NULL COMMENT '鏈�杩戝垵濮嬪寲鑰楁椂',
`sort` int(11) DEFAULT '0' COMMENT '鎺掑簭',
`tenant_id` bigint(20) DEFAULT NULL COMMENT '绉熸埛',
`status` int(11) DEFAULT '1' COMMENT '鐘舵��',
@@ -121,6 +125,74 @@
EXECUTE builtin_code_stmt;
DEALLOCATE PREPARE builtin_code_stmt;
+SET @mcp_health_status_exists := (
+ SELECT COUNT(1)
+ FROM `information_schema`.`COLUMNS`
+ WHERE `TABLE_SCHEMA` = DATABASE()
+ AND `TABLE_NAME` = 'sys_ai_mcp_mount'
+ AND `COLUMN_NAME` = 'health_status'
+);
+SET @mcp_health_status_sql := IF(
+ @mcp_health_status_exists = 0,
+ 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `health_status` varchar(32) DEFAULT ''NOT_TESTED'' COMMENT ''鍋ュ悍鐘舵��'' AFTER `request_timeout_ms`',
+ 'SELECT 1'
+);
+PREPARE mcp_health_status_stmt FROM @mcp_health_status_sql;
+EXECUTE mcp_health_status_stmt;
+DEALLOCATE PREPARE mcp_health_status_stmt;
+
+SET @mcp_last_test_time_exists := (
+ SELECT COUNT(1)
+ FROM `information_schema`.`COLUMNS`
+ WHERE `TABLE_SCHEMA` = DATABASE()
+ AND `TABLE_NAME` = 'sys_ai_mcp_mount'
+ AND `COLUMN_NAME` = 'last_test_time'
+);
+SET @mcp_last_test_time_sql := IF(
+ @mcp_last_test_time_exists = 0,
+ 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `last_test_time` datetime DEFAULT NULL COMMENT ''鏈�杩戞祴璇曟椂闂�'' AFTER `health_status`',
+ 'SELECT 1'
+);
+PREPARE mcp_last_test_time_stmt FROM @mcp_last_test_time_sql;
+EXECUTE mcp_last_test_time_stmt;
+DEALLOCATE PREPARE mcp_last_test_time_stmt;
+
+SET @mcp_last_test_message_exists := (
+ SELECT COUNT(1)
+ FROM `information_schema`.`COLUMNS`
+ WHERE `TABLE_SCHEMA` = DATABASE()
+ AND `TABLE_NAME` = 'sys_ai_mcp_mount'
+ AND `COLUMN_NAME` = 'last_test_message'
+);
+SET @mcp_last_test_message_sql := IF(
+ @mcp_last_test_message_exists = 0,
+ 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `last_test_message` varchar(500) DEFAULT NULL COMMENT ''鏈�杩戞祴璇曚俊鎭�'' AFTER `last_test_time`',
+ 'SELECT 1'
+);
+PREPARE mcp_last_test_message_stmt FROM @mcp_last_test_message_sql;
+EXECUTE mcp_last_test_message_stmt;
+DEALLOCATE PREPARE mcp_last_test_message_stmt;
+
+SET @mcp_last_init_elapsed_exists := (
+ SELECT COUNT(1)
+ FROM `information_schema`.`COLUMNS`
+ WHERE `TABLE_SCHEMA` = DATABASE()
+ AND `TABLE_NAME` = 'sys_ai_mcp_mount'
+ AND `COLUMN_NAME` = 'last_init_elapsed_ms'
+);
+SET @mcp_last_init_elapsed_sql := IF(
+ @mcp_last_init_elapsed_exists = 0,
+ 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `last_init_elapsed_ms` bigint(20) DEFAULT NULL COMMENT ''鏈�杩戝垵濮嬪寲鑰楁椂'' AFTER `last_test_message`',
+ 'SELECT 1'
+);
+PREPARE mcp_last_init_elapsed_stmt FROM @mcp_last_init_elapsed_sql;
+EXECUTE mcp_last_init_elapsed_stmt;
+DEALLOCATE PREPARE mcp_last_init_elapsed_stmt;
+
+UPDATE `sys_ai_mcp_mount`
+SET `health_status` = 'NOT_TESTED'
+WHERE `health_status` IS NULL OR `health_status` = '';
+
SET @chat_session_pinned_exists := (
SELECT COUNT(1)
FROM `information_schema`.`COLUMNS`
--
Gitblit v1.9.1