import React, { useEffect, useMemo, useState } from "react";
|
import { useTranslate, useNotify } from "react-admin";
|
import {
|
Accordion,
|
AccordionDetails,
|
AccordionSummary,
|
Alert,
|
Box,
|
Button,
|
Card,
|
CardContent,
|
CircularProgress,
|
Grid,
|
MenuItem,
|
Stack,
|
TextField,
|
Typography,
|
} from "@mui/material";
|
import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined";
|
import PreviewOutlinedIcon from "@mui/icons-material/PreviewOutlined";
|
import ExpandMoreOutlinedIcon from "@mui/icons-material/ExpandMoreOutlined";
|
import { previewMcpTools, testMcpConnectivity, testMcpTool } from "@/api/ai/mcpMount";
|
|
const parseInputSchema = (inputSchema, translate) => {
|
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: translate("ai.mcp.tools.schemaParseFailed", { message: 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();
|
const translate = useTranslate();
|
const [loading, setLoading] = useState(false);
|
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, translate);
|
return result;
|
}, {});
|
}, [tools, translate]);
|
|
useEffect(() => {
|
if (!mountId) {
|
setTools([]);
|
setInputs({});
|
setStructuredInputs({});
|
setOutputs({});
|
setConnectivity(null);
|
setError("");
|
return;
|
}
|
loadTools();
|
}, [mountId]);
|
|
const loadTools = async () => {
|
setLoading(true);
|
setError("");
|
try {
|
const data = await previewMcpTools(mountId);
|
setTools(data);
|
setOutputs({});
|
setInputs({});
|
setStructuredInputs({});
|
} catch (requestError) {
|
setError(requestError.message || translate("ai.mcp.tools.loadFailed"));
|
} finally {
|
setLoading(false);
|
}
|
};
|
|
const handleConnectivityTest = async () => {
|
setTestingConnectivity(true);
|
try {
|
const result = await testMcpConnectivity(mountId);
|
setConnectivity(result);
|
notify(result?.message || translate("ai.mcp.connectivity.success"));
|
} catch (requestError) {
|
const message = requestError.message || translate("ai.mcp.connectivity.failed");
|
notify(message, { type: "error" });
|
} finally {
|
setTestingConnectivity(false);
|
}
|
};
|
|
const handleInputChange = (toolName, value) => {
|
setInputs((prev) => ({
|
...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) => {
|
const inputJson = inputs[toolName];
|
if (!inputJson || !inputJson.trim()) {
|
notify(translate("ai.mcp.tools.inputRequired"), { type: "warning" });
|
return;
|
}
|
setTestingToolName(toolName);
|
try {
|
const result = await testMcpTool(mountId, {
|
toolName,
|
inputJson,
|
});
|
setOutputs((prev) => ({
|
...prev,
|
[toolName]: result?.output || "",
|
}));
|
notify(translate("ai.mcp.tools.testSuccess", { name: toolName }));
|
} catch (requestError) {
|
const message = requestError.message || translate("ai.mcp.tools.testFailed");
|
setOutputs((prev) => ({
|
...prev,
|
[toolName]: message,
|
}));
|
notify(message, { type: "error" });
|
} finally {
|
setTestingToolName("");
|
}
|
};
|
|
if (!mountId) {
|
return (
|
<Alert severity="info" sx={{ mt: 2 }}>
|
{translate("ai.mcp.tools.saveBeforePreview")}
|
</Alert>
|
);
|
}
|
|
return (
|
<Box mt={3}>
|
<Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}>
|
<AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}>
|
<Box flex={1}>
|
<Typography variant="h6">{translate("ai.mcp.tools.title")}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.mcp.tools.description")}
|
</Typography>
|
</Box>
|
</AccordionSummary>
|
<AccordionDetails>
|
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1.5} flexWrap="wrap" useFlexGap>
|
<Button size="small" startIcon={<PreviewOutlinedIcon />} onClick={loadTools} disabled={loading}>
|
{translate("ai.mcp.tools.refresh")}
|
</Button>
|
<Button
|
size="small"
|
variant="outlined"
|
startIcon={<PlayCircleOutlineOutlinedIcon />}
|
onClick={handleConnectivityTest}
|
disabled={testingConnectivity}
|
>
|
{testingConnectivity ? translate("ai.common.testing") : translate("ai.mcp.list.connectivityTest")}
|
</Button>
|
</Stack>
|
{!!connectivity && (
|
<Alert severity={resolveConnectivitySeverity(connectivity.healthStatus)} sx={{ mb: 2 }}>
|
{connectivity.message}
|
{connectivity.initElapsedMs != null && ` · ${translate("ai.mcp.tools.connectivityInit", { value: connectivity.initElapsedMs })}`}
|
{connectivity.toolCount != null && ` · ${translate("ai.mcp.tools.connectivityToolCount", { count: connectivity.toolCount })}`}
|
{connectivity.testedAt && ` · ${connectivity.testedAt}`}
|
</Alert>
|
)}
|
{loading && (
|
<Box display="flex" justifyContent="center" py={4}>
|
<CircularProgress size={28} />
|
</Box>
|
)}
|
{!!error && !loading && (
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
{error}
|
</Alert>
|
)}
|
{!loading && !error && !tools.length && (
|
<Alert severity="info">{translate("ai.mcp.tools.noTools")}</Alert>
|
)}
|
<Grid container spacing={2}>
|
{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 || translate("ai.common.none")}
|
</Typography>
|
{!!tool.toolPurpose && (
|
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
|
{translate("ai.mcp.tools.purpose", { value: tool.toolPurpose })}
|
</Typography>
|
)}
|
</Box>
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
{!!tool.toolGroup && (
|
<Typography variant="caption" color="text.secondary">
|
{tool.toolGroup}
|
</Typography>
|
)}
|
<Typography variant="caption" color="text.secondary">
|
{translate("ai.mcp.tools.fieldCount", { count: schemaInfo.fields.length })}
|
</Typography>
|
<Typography variant="caption" color="text.secondary">
|
{translate(tool.returnDirect ? "ai.mcp.tools.returnDirect" : "ai.mcp.tools.normal")}
|
</Typography>
|
</Stack>
|
</Stack>
|
</AccordionSummary>
|
<AccordionDetails>
|
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
<CardContent>
|
{!!tool.queryBoundary && (
|
<Alert severity="info" sx={{ mb: 2 }}>
|
{translate("ai.mcp.tools.queryBoundary", { value: tool.queryBoundary })}
|
</Alert>
|
)}
|
{!!tool.exampleQuestions?.length && (
|
<Alert severity="success" sx={{ mb: 2 }}>
|
<Typography variant="body2" fontWeight={700} mb={0.5}>
|
{translate("ai.mcp.tools.exampleQuestions")}
|
</Typography>
|
{tool.exampleQuestions.map((question) => (
|
<Typography key={question} variant="body2">
|
{`- ${question}`}
|
</Typography>
|
))}
|
</Alert>
|
)}
|
{!!schemaInfo.error && (
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
{schemaInfo.error}
|
</Alert>
|
)}
|
<TextField
|
label={translate("ai.mcp.tools.formattedSchema")}
|
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={translate("ai.mcp.tools.testInput")}
|
value={inputs[tool.name] || ""}
|
onChange={(event) => handleInputChange(tool.name, event.target.value)}
|
fullWidth
|
multiline
|
minRows={5}
|
maxRows={12}
|
sx={{ mt: 2 }}
|
placeholder={translate("ai.mcp.tools.testInputPlaceholder")}
|
/>
|
<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 ? translate("ai.common.testing") : translate("ai.mcp.tools.executeTest")}
|
</Button>
|
</Stack>
|
<TextField
|
label={translate("ai.mcp.tools.testResult")}
|
value={outputs[tool.name] || ""}
|
fullWidth
|
multiline
|
minRows={5}
|
maxRows={16}
|
sx={{ mt: 2 }}
|
InputProps={{ readOnly: true }}
|
/>
|
</CardContent>
|
</Card>
|
</AccordionDetails>
|
</Accordion>
|
</Grid>
|
);
|
})}
|
</Grid>
|
</AccordionDetails>
|
</Accordion>
|
</Box>
|
);
|
};
|
|
export default AiMcpMountToolsPanel;
|