zhou zhou
14 小时以前 3d81df739dc45599c257d8cdefe0996f66ccdeae
#AI.MCP 管理增强
1个文件已添加
10个文件已修改
813 ■■■■ 已修改文件
rsf-admin/src/api/ai/mcpMount.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountForm.jsx 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountList.jsx 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiMcpMount/AiMcpMountToolsPanel.jsx 344 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiMcpMountController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpConnectivityTestDto.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpMount.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiMcpMountService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiMcpMountServiceImpl.java 108 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/ai_feature.sql 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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;
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>
);
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: "正常" };
    }
    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
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 解析失败: ${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">
                            当前挂载解析出的全部工具都显示在这里,可直接输入 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>
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;
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));
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiMcpConnectivityTestDto.java
New file
@@ -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;
}
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 "";
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);
}
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,
                    "工具解析成功,共 " + tools.size() + " 个工具", System.currentTimeMillis() - startedAt);
            return tools;
        } catch (CoolException e) {
            throw e;
        } catch (Exception e) {
            updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY,
                    "工具解析失败: " + 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 = "连通性测试成功,解析出 " + 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();
    }
}
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`