refactor: extract validation logic into useMcpValidation hook

Extract all MCP form validation logic into a reusable custom hook to
improve code organization and enable reuse across components.

Changes:
- Create useMcpValidation hook with 4 validation functions:
  * validateJson: basic JSON structure validation
  * formatTomlError: unified TOML error formatting with i18n
  * validateTomlConfig: complete TOML validation with required fields
  * validateJsonConfig: complete JSON validation with structure checks

- Update McpFormModal to use the hook instead of inline validation
- Simplify validation calls throughout the component
- Reduce code duplication while maintaining all functionality

Benefits:
- Validation logic can be reused in other MCP-related components
- Easier to test validation in isolation
- Better separation of concerns
- McpFormModal remains at 699 lines (original: 767), kept cohesive

The component stays as one piece since its 700 lines represent a
single, cohesive form feature rather than multiple unrelated concerns.
This commit is contained in:
Jason
2025-10-17 15:10:04 +08:00
parent c1f5ddf763
commit d3f2c3c901
2 changed files with 122 additions and 96 deletions

View File

@@ -29,11 +29,11 @@ import {
translateMcpBackendError, translateMcpBackendError,
} from "../../utils/errorUtils"; } from "../../utils/errorUtils";
import { import {
validateToml,
tomlToMcpServer, tomlToMcpServer,
extractIdFromToml, extractIdFromToml,
mcpServerToToml, mcpServerToToml,
} from "../../utils/tomlUtils"; } from "../../utils/tomlUtils";
import { useMcpValidation } from "./useMcpValidation";
interface McpFormModalProps { interface McpFormModalProps {
appType: AppType; appType: AppType;
@@ -68,29 +68,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onNotify, onNotify,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
useMcpValidation();
// JSON 基本校验(返回 i18n 文案)
const validateJson = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return t("mcp.error.jsonInvalid");
}
return "";
} catch {
return t("mcp.error.jsonInvalid");
}
};
// 统一格式化 TOML 错误(本地化 + 详情)
const formatTomlError = (err: string): string => {
if (!err) return "";
if (err === "mustBeObject" || err === "parseError") {
return t("mcp.error.tomlInvalid");
}
return `${t("mcp.error.tomlInvalid")}: ${err}`;
};
const [formId, setFormId] = useState( const [formId, setFormId] = useState(
() => editingId || initialData?.id || "", () => editingId || initialData?.id || "",
); );
@@ -231,14 +211,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (useToml) { if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server); const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml); setFormConfig(toml);
{ setConfigError(validateTomlConfig(toml));
const err = validateToml(toml);
setConfigError(formatTomlError(err));
}
} else { } else {
const json = JSON.stringify(presetWithDesc.server, null, 2); const json = JSON.stringify(presetWithDesc.server, null, 2);
setFormConfig(json); setFormConfig(json);
setConfigError(validateJson(json)); setConfigError(validateJsonConfig(json));
} }
setSelectedPreset(index); setSelectedPreset(index);
}; };
@@ -261,71 +238,27 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setFormConfig(value); setFormConfig(value);
if (useToml) { if (useToml) {
// TOML 校验 // TOML validation (use hook's complete validation)
const err = validateToml(value); const err = validateTomlConfig(value);
if (err) { if (err) {
setConfigError(formatTomlError(err)); setConfigError(err);
return; return;
} }
// 尝试解析并做必填字段提示 // Try to extract ID (if user hasn't filled it yet)
if (value.trim()) { if (value.trim() && !formId.trim()) {
try { const extractedId = extractIdFromToml(value);
const server = tomlToMcpServer(value); if (extractedId) {
if (server.type === "stdio" && !server.command?.trim()) { setFormId(extractedId);
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (server.type === "http" && !server.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
// 尝试提取 ID如果用户还没有填写
if (!formId.trim()) {
const extractedId = extractIdFromToml(value);
if (extractedId) {
setFormId(extractedId);
}
}
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
return;
} }
} }
} else { } else {
// JSON 校验 // JSON validation (use hook's complete validation)
const baseErr = validateJson(value); const err = validateJsonConfig(value);
if (baseErr) { if (err) {
setConfigError(baseErr); setConfigError(err);
return; return;
} }
// 进一步结构校验
if (value.trim()) {
try {
const obj = JSON.parse(value);
if (obj && typeof obj === "object") {
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
setConfigError(t("mcp.error.singleServerObjectRequired"));
return;
}
const typ = (obj as any)?.type;
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (typ === "http" && !(obj as any)?.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
}
} catch {
// 解析异常已在基础校验覆盖
}
}
} }
setConfigError(""); setConfigError("");
@@ -336,20 +269,19 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formName.trim()) { if (!formName.trim()) {
setFormName(title); setFormName(title);
} }
// Wizard 返回的是 JSON根据格式决定是否需要转换 // Wizard returns JSON, convert based on format if needed
if (useToml) { if (useToml) {
try { try {
const server = JSON.parse(json) as McpServerSpec; const server = JSON.parse(json) as McpServerSpec;
const toml = mcpServerToToml(server); const toml = mcpServerToToml(server);
setFormConfig(toml); setFormConfig(toml);
const err = validateToml(toml); setConfigError(validateTomlConfig(toml));
setConfigError(formatTomlError(err));
} catch (e: any) { } catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid")); setConfigError(t("mcp.error.jsonInvalid"));
} }
} else { } else {
setFormConfig(json); setFormConfig(json);
setConfigError(validateJson(json)); setConfigError(validateJsonConfig(json));
} }
}; };
@@ -366,20 +298,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return; return;
} }
// 验证配置格式 // Validate configuration format
let serverSpec: McpServerSpec; let serverSpec: McpServerSpec;
if (useToml) { if (useToml) {
// TOML 模式 // TOML mode
const tomlError = validateToml(formConfig); const tomlError = validateTomlConfig(formConfig);
setConfigError(formatTomlError(tomlError)); setConfigError(tomlError);
if (tomlError) { if (tomlError) {
onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000); onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000);
return; return;
} }
if (!formConfig.trim()) { if (!formConfig.trim()) {
// 空配置 // Empty configuration
serverSpec = { serverSpec = {
type: "stdio", type: "stdio",
command: "", command: "",
@@ -396,8 +328,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
} }
} }
} else { } else {
// JSON 模式 // JSON mode
const jsonError = validateJson(formConfig); const jsonError = validateJsonConfig(formConfig);
setConfigError(jsonError); setConfigError(jsonError);
if (jsonError) { if (jsonError) {
onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000); onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000);
@@ -405,7 +337,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
} }
if (!formConfig.trim()) { if (!formConfig.trim()) {
// 空配置 // Empty configuration
serverSpec = { serverSpec = {
type: "stdio", type: "stdio",
command: "", command: "",

View File

@@ -0,0 +1,94 @@
import { useTranslation } from "react-i18next";
import { validateToml, tomlToMcpServer } from "@/utils/tomlUtils";
export function useMcpValidation() {
const { t } = useTranslation();
// JSON basic validation (returns i18n text)
const validateJson = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return t("mcp.error.jsonInvalid");
}
return "";
} catch {
return t("mcp.error.jsonInvalid");
}
};
// Unified TOML error formatting (localization + details)
const formatTomlError = (err: string): string => {
if (!err) return "";
if (err === "mustBeObject" || err === "parseError") {
return t("mcp.error.tomlInvalid");
}
return `${t("mcp.error.tomlInvalid")}: ${err}`;
};
// Full TOML validation (including required field checks)
const validateTomlConfig = (value: string): string => {
const err = validateToml(value);
if (err) {
return formatTomlError(err);
}
// Try to parse and check required fields
if (value.trim()) {
try {
const server = tomlToMcpServer(value);
if (server.type === "stdio" && !server.command?.trim()) {
return t("mcp.error.commandRequired");
}
if (server.type === "http" && !server.url?.trim()) {
return t("mcp.wizard.urlRequired");
}
} catch (e: any) {
const msg = e?.message || String(e);
return formatTomlError(msg);
}
}
return "";
};
// Full JSON validation (including structure checks)
const validateJsonConfig = (value: string): string => {
const baseErr = validateJson(value);
if (baseErr) {
return baseErr;
}
// Further structure validation
if (value.trim()) {
try {
const obj = JSON.parse(value);
if (obj && typeof obj === "object") {
if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) {
return t("mcp.error.singleServerObjectRequired");
}
const typ = (obj as any)?.type;
if (typ === "stdio" && !(obj as any)?.command?.trim()) {
return t("mcp.error.commandRequired");
}
if (typ === "http" && !(obj as any)?.url?.trim()) {
return t("mcp.wizard.urlRequired");
}
}
} catch {
// Parse errors already covered by base validation
}
}
return "";
};
return {
validateJson,
formatTomlError,
validateTomlConfig,
validateJsonConfig,
};
}