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,
} from "../../utils/errorUtils";
import {
validateToml,
tomlToMcpServer,
extractIdFromToml,
mcpServerToToml,
} from "../../utils/tomlUtils";
import { useMcpValidation } from "./useMcpValidation";
interface McpFormModalProps {
appType: AppType;
@@ -68,29 +68,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onNotify,
}) => {
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(
() => editingId || initialData?.id || "",
);
@@ -231,14 +211,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
{
const err = validateToml(toml);
setConfigError(formatTomlError(err));
}
setConfigError(validateTomlConfig(toml));
} else {
const json = JSON.stringify(presetWithDesc.server, null, 2);
setFormConfig(json);
setConfigError(validateJson(json));
setConfigError(validateJsonConfig(json));
}
setSelectedPreset(index);
};
@@ -261,71 +238,27 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setFormConfig(value);
if (useToml) {
// TOML 校验
const err = validateToml(value);
// TOML validation (use hook's complete validation)
const err = validateTomlConfig(value);
if (err) {
setConfigError(formatTomlError(err));
setConfigError(err);
return;
}
// 尝试解析并做必填字段提示
if (value.trim()) {
try {
const server = tomlToMcpServer(value);
if (server.type === "stdio" && !server.command?.trim()) {
setConfigError(t("mcp.error.commandRequired"));
return;
}
if (server.type === "http" && !server.url?.trim()) {
setConfigError(t("mcp.wizard.urlRequired"));
return;
}
// 尝试提取 ID如果用户还没有填写
if (!formId.trim()) {
// Try to extract ID (if user hasn't filled it yet)
if (value.trim() && !formId.trim()) {
const extractedId = extractIdFromToml(value);
if (extractedId) {
setFormId(extractedId);
}
}
} catch (e: any) {
const msg = e?.message || String(e);
setConfigError(formatTomlError(msg));
return;
}
}
} else {
// JSON 校验
const baseErr = validateJson(value);
if (baseErr) {
setConfigError(baseErr);
// JSON validation (use hook's complete validation)
const err = validateJsonConfig(value);
if (err) {
setConfigError(err);
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("");
@@ -336,20 +269,19 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (!formName.trim()) {
setFormName(title);
}
// Wizard 返回的是 JSON根据格式决定是否需要转换
// Wizard returns JSON, convert based on format if needed
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
const toml = mcpServerToToml(server);
setFormConfig(toml);
const err = validateToml(toml);
setConfigError(formatTomlError(err));
setConfigError(validateTomlConfig(toml));
} catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid"));
}
} else {
setFormConfig(json);
setConfigError(validateJson(json));
setConfigError(validateJsonConfig(json));
}
};
@@ -366,20 +298,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return;
}
// 验证配置格式
// Validate configuration format
let serverSpec: McpServerSpec;
if (useToml) {
// TOML 模式
const tomlError = validateToml(formConfig);
setConfigError(formatTomlError(tomlError));
// TOML mode
const tomlError = validateTomlConfig(formConfig);
setConfigError(tomlError);
if (tomlError) {
onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000);
return;
}
if (!formConfig.trim()) {
// 空配置
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -396,8 +328,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON 模式
const jsonError = validateJson(formConfig);
// JSON mode
const jsonError = validateJsonConfig(formConfig);
setConfigError(jsonError);
if (jsonError) {
onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000);
@@ -405,7 +337,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
if (!formConfig.trim()) {
// 空配置
// Empty configuration
serverSpec = {
type: "stdio",
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,
};
}