refactor(mcp): complete form refactoring for unified MCP management

Complete the v3.7.0 MCP refactoring by updating the form layer to match
the unified architecture already implemented in data/service/API layers.

**Breaking Changes:**
- Remove confusing `appId` parameter from McpFormModal
- Replace with `defaultFormat` (json/toml) and `defaultEnabledApps` (array)

**Form Enhancements:**
- Add app enablement checkboxes (Claude/Codex/Gemini) directly in the form
- Smart defaults: new servers default to Claude enabled, editing preserves state
- Support "draft" mode: servers can be created without enabling any apps

**Architecture Improvements:**
- Eliminate semantic confusion: format selection separate from app targeting
- One-step workflow: configure and enable apps in single form submission
- Consistent with unified backend: `apps: { claude, codex, gemini }`

**Testing:**
- Update test mocks to use `useUpsertMcpServer` hook
- Add test case for creating servers with no apps enabled
- Fix parameter references from `appId` to `defaultFormat`

**i18n:**
- Add `mcp.form.enabledApps` translation (zh/en)
- Add `mcp.form.noAppsWarning` translation (zh/en)

This completes the MCP management refactoring, ensuring all layers
(data, service, API, UI) follow the same unified architecture pattern.
This commit is contained in:
Jason
2025-11-15 23:47:35 +08:00
parent 154ff4c819
commit 685a1138e4
5 changed files with 210 additions and 94 deletions

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react"; import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -30,26 +31,28 @@ import { useMcpValidation } from "./useMcpValidation";
import { useUpsertMcpServer } from "@/hooks/useMcp"; import { useUpsertMcpServer } from "@/hooks/useMcp";
interface McpFormModalProps { interface McpFormModalProps {
appId: AppId;
editingId?: string; editingId?: string;
initialData?: McpServer; initialData?: McpServer;
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调 onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
onClose: () => void; onClose: () => void;
existingIds?: string[]; existingIds?: string[];
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为 Claude
} }
/** /**
* MCP 表单模态框组件(简化版) * MCP 表单模态框组件(v3.7.0 完整重构版)
* Claude: 使用 JSON 格式 * - 支持 JSON 和 TOML 两种格式
* Codex: 使用 TOML 格式 * - 统一管理,通过复选框选择启用到哪些应用
*/ */
const McpFormModal: React.FC<McpFormModalProps> = ({ const McpFormModal: React.FC<McpFormModalProps> = ({
appId,
editingId, editingId,
initialData, initialData,
onSave, onSave,
onClose, onClose,
existingIds = [], existingIds = [],
defaultFormat = "json",
defaultEnabledApps = ["claude"],
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } = const { formatTomlError, validateTomlConfig, validateJsonConfig } =
@@ -68,6 +71,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [formDocs, setFormDocs] = useState(initialData?.docs || ""); const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || ""); const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 启用状态:编辑模式使用现有值,新增模式使用默认值
const [enabledApps, setEnabledApps] = useState<{
claude: boolean;
codex: boolean;
gemini: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
}
// 新增模式:根据 defaultEnabledApps 设置初始值
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
};
});
// 编辑模式下禁止修改 ID // 编辑模式下禁止修改 ID
const isEditing = !!editingId; const isEditing = !!editingId;
@@ -84,11 +104,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
isEditing ? hasAdditionalInfo : false, isEditing ? hasAdditionalInfo : false,
); );
// 根据 appId 决定初始格式 // 配置格式:优先使用 defaultFormat编辑模式下可从现有数据推断
const useTomlFormat = useMemo(() => {
if (initialData?.server) {
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON
return defaultFormat === "toml";
}
return defaultFormat === "toml";
}, [defaultFormat, initialData]);
// 根据格式决定初始配置
const [formConfig, setFormConfig] = useState(() => { const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server; const spec = initialData?.server;
if (!spec) return ""; if (!spec) return "";
if (appId === "codex") { if (useTomlFormat) {
return mcpServerToToml(spec); return mcpServerToToml(spec);
} }
return JSON.stringify(spec, null, 2); return JSON.stringify(spec, null, 2);
@@ -99,8 +128,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState(""); const [idError, setIdError] = useState("");
// 判断是否使用 TOML 格式 // 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
const useToml = appId === "codex"; const useToml = useTomlFormat;
const wizardInitialSpec = useMemo(() => { const wizardInitialSpec = useMemo(() => {
const fallback = initialData?.server; const fallback = initialData?.server;
@@ -333,12 +362,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
id: trimmedId, id: trimmedId,
name: finalName, name: finalName,
server: serverSpec, server: serverSpec,
// 确保 apps 字段始终存在v3.7.0 新架构必需 // 使用表单中的启用状态v3.7.0 完整重构
apps: initialData?.apps || { apps: enabledApps,
claude: false,
codex: false,
gemini: false,
},
}; };
const descriptionTrimmed = formDescription.trim(); const descriptionTrimmed = formDescription.trim();
@@ -387,11 +412,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}; };
const getFormTitle = () => { const getFormTitle = () => {
if (appId === "claude") { return isEditing ? t("mcp.editServer") : t("mcp.addServer");
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
} else {
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
}
}; };
return ( return (
@@ -477,6 +498,62 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/> />
</div> </div>
{/* 启用到哪些应用v3.7.0 新增) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t("mcp.form.enabledApps")}
</label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="enable-claude"
checked={enabledApps.claude}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, claude: checked })
}
/>
<label
htmlFor="enable-claude"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-codex"
checked={enabledApps.codex}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, codex: checked })
}
/>
<label
htmlFor="enable-codex"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-gemini"
checked={enabledApps.gemini}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, gemini: checked })
}
/>
<label
htmlFor="enable-gemini"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
</div>
</div>
</div>
{/* 可折叠的附加信息按钮 */} {/* 可折叠的附加信息按钮 */}
<div> <div>
<button <button

View File

@@ -191,12 +191,13 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
{/* Form Modal */} {/* Form Modal */}
{isFormOpen && ( {isFormOpen && (
<McpFormModal <McpFormModal
appId="claude" // Default to claude for unified panel
editingId={editingId || undefined} editingId={editingId || undefined}
initialData={ initialData={
editingId && serversMap ? serversMap[editingId] : undefined editingId && serversMap ? serversMap[editingId] : undefined
} }
existingIds={serversMap ? Object.keys(serversMap) : []} existingIds={serversMap ? Object.keys(serversMap) : []}
defaultFormat="json"
defaultEnabledApps={["claude"]} // 默认启用 Claude
onSave={async () => { onSave={async () => {
setIsFormOpen(false); setIsFormOpen(false);
setEditingId(null); setEditingId(null);

View File

@@ -478,6 +478,8 @@
"titlePlaceholder": "my-mcp-server", "titlePlaceholder": "my-mcp-server",
"name": "Display Name", "name": "Display Name",
"namePlaceholder": "e.g. @modelcontextprotocol/server-time", "namePlaceholder": "e.g. @modelcontextprotocol/server-time",
"enabledApps": "Enable to Apps",
"noAppsWarning": "At least one app must be selected",
"description": "Description", "description": "Description",
"descriptionPlaceholder": "Optional description", "descriptionPlaceholder": "Optional description",
"tags": "Tags (comma separated)", "tags": "Tags (comma separated)",

View File

@@ -478,6 +478,8 @@
"titlePlaceholder": "my-mcp-server", "titlePlaceholder": "my-mcp-server",
"name": "显示名称", "name": "显示名称",
"namePlaceholder": "例如 @modelcontextprotocol/server-time", "namePlaceholder": "例如 @modelcontextprotocol/server-time",
"enabledApps": "启用到应用",
"noAppsWarning": "至少选择一个应用",
"description": "描述", "description": "描述",
"descriptionPlaceholder": "可选的描述信息", "descriptionPlaceholder": "可选的描述信息",
"tags": "标签(逗号分隔)", "tags": "标签(逗号分隔)",

View File

@@ -5,7 +5,11 @@ import McpFormModal from "@/components/mcp/McpFormModal";
const toastErrorMock = vi.hoisted(() => vi.fn()); const toastErrorMock = vi.hoisted(() => vi.fn());
const toastSuccessMock = vi.hoisted(() => vi.fn()); const toastSuccessMock = vi.hoisted(() => vi.fn());
const getConfigMock = vi.hoisted(() => vi.fn().mockResolvedValue({ servers: {} })); const upsertMock = vi.hoisted(() => {
const fn = vi.fn();
fn.mockResolvedValue(undefined);
return fn;
});
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
toast: { toast: {
@@ -65,6 +69,18 @@ vi.mock("@/components/ui/textarea", () => ({
), ),
})); }));
vi.mock("@/components/ui/checkbox", () => ({
Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => (
<input
type="checkbox"
id={id}
checked={checked ?? false}
onChange={(e) => onCheckedChange?.(e.target.checked)}
{...rest}
/>
),
}));
vi.mock("@/components/ui/dialog", () => ({ vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: any) => <div>{children}</div>, Dialog: ({ children }: any) => <div>{children}</div>,
DialogContent: ({ children }: any) => <div>{children}</div>, DialogContent: ({ children }: any) => <div>{children}</div>,
@@ -91,40 +107,44 @@ vi.mock("@/components/mcp/McpWizardModal", () => ({
) : null, ) : null,
})); }));
vi.mock("@/lib/api", async () => { vi.mock("@/hooks/useMcp", async () => {
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api"); const actual = await vi.importActual<typeof import("@/hooks/useMcp")>(
"@/hooks/useMcp",
);
return { return {
...actual, ...actual,
mcpApi: { useUpsertMcpServer: () => ({
...actual.mcpApi, mutateAsync: (...args: unknown[]) => upsertMock(...args),
getConfig: (...args: unknown[]) => getConfigMock(...args), }),
},
}; };
}); });
describe("McpFormModal", () => { describe("McpFormModal", () => {
beforeEach(() => { beforeEach(() => {
toastErrorMock.mockReset(); toastErrorMock.mockClear();
toastSuccessMock.mockReset(); toastSuccessMock.mockClear();
getConfigMock.mockReset(); upsertMock.mockClear();
getConfigMock.mockResolvedValue({ servers: {} });
}); });
const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>) => { const renderForm = (
const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } = props ?? {}; props?: Partial<React.ComponentProps<typeof McpFormModal>>,
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined); ) => {
const onClose = overrideOnClose ?? vi.fn(); const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } =
render( props ?? {};
<McpFormModal const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
appId="claude" const onClose = overrideOnClose ?? vi.fn();
onSave={onSave} render(
onClose={onClose} <McpFormModal
existingIds={[]} onSave={onSave}
onClose={onClose}
existingIds={[]}
defaultFormat="json"
defaultEnabledApps={["claude"]}
{...rest} {...rest}
/>, />,
); );
return { onSave, onClose }; return { onSave, onClose };
}; };
it("应用预设后填充 ID 与配置内容", async () => { it("应用预设后填充 ID 与配置内容", async () => {
renderForm(); renderForm();
@@ -145,30 +165,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
expect(configTextarea.value).toBe('{\n "type": "stdio",\n "command": "preset-cmd"\n}'); expect(configTextarea.value).toBe('{\n "type": "stdio",\n "command": "preset-cmd"\n}');
}); });
it("在同步另一侧存在冲突时展示警告", async () => { it("提交时清洗字段并调用 upsert 与 onSave", async () => {
getConfigMock.mockResolvedValue({ servers: { conflict: {} } });
renderForm();
const idInput = screen.getByPlaceholderText(
"mcp.form.titlePlaceholder",
) as HTMLInputElement;
fireEvent.change(idInput, { target: { value: "conflict" } });
await waitFor(() => expect(getConfigMock).toHaveBeenCalled());
const checkbox = screen.getByLabelText(
'mcp.form.syncOtherSide:{"target":"apps.codex"}',
) as HTMLInputElement;
fireEvent.click(checkbox);
await waitFor(() =>
expect(
screen.getByText('mcp.form.willOverwriteWarning:{"target":"apps.codex"}'),
).toBeInTheDocument(),
);
});
it("提交时清洗字段并调用 onSave", async () => {
const { onSave } = renderForm(); const { onSave } = renderForm();
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), { fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
@@ -197,17 +194,11 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
target: { value: '{"type":"stdio","command":"run"}' }, target: { value: '{"type":"stdio","command":"run"}' },
}); });
const syncCheckbox = screen.getByLabelText(
'mcp.form.syncOtherSide:{"target":"apps.codex"}',
) as HTMLInputElement;
fireEvent.click(syncCheckbox);
fireEvent.click(screen.getByText("common.add")); fireEvent.click(screen.getByText("common.add"));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
const [id, payload, options] = (onSave as any).mock.calls[0]; const [entry] = upsertMock.mock.calls.at(-1) ?? [];
expect(id).toBe("my-server"); expect(entry).toMatchObject({
expect(payload).toMatchObject({
id: "my-server", id: "my-server",
name: "Friendly", name: "Friendly",
description: "Description", description: "Description",
@@ -218,8 +209,14 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
type: "stdio", type: "stdio",
command: "run", command: "run",
}, },
apps: {
claude: true,
codex: false,
gemini: false,
},
}); });
expect(options).toEqual({ syncOtherSide: true }); expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith();
expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled();
}); });
@@ -236,7 +233,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
fireEvent.click(screen.getByText("common.add")); fireEvent.click(screen.getByText("common.add"));
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled()); await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
expect(onSave).not.toHaveBeenCalled(); expect(upsertMock).not.toHaveBeenCalled();
const [message] = toastErrorMock.mock.calls.at(-1) ?? []; const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
expect(message).toBe("mcp.error.jsonInvalid"); expect(message).toBe("mcp.error.jsonInvalid");
}); });
@@ -262,7 +259,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
}); });
it("TOML 模式下自动提取 ID 并成功保存", async () => { it("TOML 模式下自动提取 ID 并成功保存", async () => {
const { onSave } = renderForm({ appId: "codex" }); const { onSave } = renderForm({ defaultFormat: "toml" });
const configTextarea = screen.getByPlaceholderText( const configTextarea = screen.getByPlaceholderText(
"mcp.form.tomlPlaceholder", "mcp.form.tomlPlaceholder",
@@ -282,15 +279,17 @@ command = "run"
fireEvent.click(screen.getByText("common.add")); fireEvent.click(screen.getByText("common.add"));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
const [id, payload] = (onSave as any).mock.calls[0]; const [entry] = upsertMock.mock.calls.at(-1) ?? [];
expect(id).toBe("demo"); expect(entry.id).toBe("demo");
expect(payload.server).toEqual({ type: "stdio", command: "run" }); expect(entry.server).toEqual({ type: "stdio", command: "run" });
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith();
expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled();
}); });
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => { it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
const { onSave } = renderForm({ appId: "codex" }); const { onSave } = renderForm({ defaultFormat: "toml" });
const configTextarea = screen.getByPlaceholderText( const configTextarea = screen.getByPlaceholderText(
"mcp.form.tomlPlaceholder", "mcp.form.tomlPlaceholder",
@@ -304,11 +303,11 @@ type = "stdio"
fireEvent.click(screen.getByText("common.add")); fireEvent.click(screen.getByText("common.add"));
await waitFor(() => await waitFor(() =>
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.idRequired", { expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.tomlInvalid", {
duration: 3000, duration: 3000,
}), }),
); );
expect(onSave).not.toHaveBeenCalled(); expect(upsertMock).not.toHaveBeenCalled();
}); });
it("编辑模式下保持 ID 并更新配置", async () => { it("编辑模式下保持 ID 并更新配置", async () => {
@@ -321,7 +320,6 @@ type = "stdio"
} as McpServer; } as McpServer;
const { onSave } = renderForm({ const { onSave } = renderForm({
appId: "claude",
editingId: "existing", editingId: "existing",
initialData, initialData,
}); });
@@ -343,12 +341,48 @@ type = "stdio"
fireEvent.click(screen.getByText("common.save")); fireEvent.click(screen.getByText("common.save"));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
const [id, entry, options] = (onSave as any).mock.calls[0]; const [entry] = upsertMock.mock.calls.at(-1) ?? [];
expect(id).toBe("existing"); expect(entry.id).toBe("existing");
expect(entry.server.command).toBe("updated"); expect(entry.server.command).toBe("updated");
expect(entry.enabled).toBe(true); expect(entry.enabled).toBe(true);
expect(options).toEqual({ syncOtherSide: false }); expect(entry.apps).toEqual({
claude: true,
codex: false,
gemini: false,
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith();
});
it("允许未选择任何应用保存配置,并保持 apps 全 false", async () => {
const { onSave } = renderForm();
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
target: { value: "no-apps" },
});
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
target: { value: '{"type":"stdio","command":"run"}' },
});
const claudeCheckbox = screen.getByLabelText(
"mcp.unifiedPanel.apps.claude",
) as HTMLInputElement;
expect(claudeCheckbox.checked).toBe(true);
fireEvent.click(claudeCheckbox);
fireEvent.click(screen.getByText("common.add"));
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
expect(entry.id).toBe("no-apps");
expect(entry.apps).toEqual({
claude: false,
codex: false,
gemini: false,
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
}); });
it("保存失败时展示翻译后的错误并恢复按钮", async () => { it("保存失败时展示翻译后的错误并恢复按钮", async () => {