From a2aa5f8434b0ae3ce9fccd88a86e69ab3663a329 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Oct 2025 00:22:15 +0800 Subject: [PATCH] feat(mcp): add option to mirror MCP config to other app - Add syncOtherSide parameter to upsert_mcp_server_in_config command - Implement checkbox UI in McpFormModal for cross-app sync - Automatically sync enabled MCP servers to both Claude and Codex when option is checked - Add i18n support for sync option labels and hints --- src-tauri/src/commands.rs | 48 ++++++++++++++++++++++------- src/components/mcp/McpFormModal.tsx | 37 ++++++++++++++++++++-- src/components/mcp/McpPanel.tsx | 10 ++++-- src/i18n/locales/en.json | 4 ++- src/i18n/locales/zh.json | 4 ++- src/lib/tauri-api.ts | 9 ++++-- src/vite-env.d.ts | 1 + 7 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e0012bc..9772572 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -841,13 +841,46 @@ pub async fn upsert_mcp_server_in_config( app: Option, id: String, spec: serde_json::Value, + sync_other_side: Option, ) -> Result { let mut cfg = state .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); - let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec)?; + let mut sync_targets: Vec = Vec::new(); + + let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec.clone())?; + + let should_sync_current = cfg + .mcp_for(&app_ty) + .servers + .get(&id) + .and_then(|entry| entry.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if should_sync_current { + sync_targets.push(app_ty.clone()); + } + + if sync_other_side.unwrap_or(false) { + let other_app = match app_ty.clone() { + crate::app_config::AppType::Claude => crate::app_config::AppType::Codex, + crate::app_config::AppType::Codex => crate::app_config::AppType::Claude, + }; + crate::mcp::upsert_in_config_for(&mut cfg, &other_app, &id, spec)?; + + let should_sync_other = cfg + .mcp_for(&other_app) + .servers + .get(&id) + .and_then(|entry| entry.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if should_sync_other { + sync_targets.push(other_app.clone()); + } + } drop(cfg); state.save()?; @@ -855,18 +888,11 @@ pub async fn upsert_mcp_server_in_config( .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - let should_sync = cfg2 - .mcp_for(&app_ty) - .servers - .get(&id) - .and_then(|entry| entry.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if should_sync { - match app_ty { + for app_ty_to_sync in sync_targets { + match app_ty_to_sync { crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?, crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?, - } + }; } Ok(changed) } diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 63d865e..35868e9 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -24,7 +24,11 @@ interface McpFormModalProps { appType: AppType; editingId?: string; initialData?: McpServer; - onSave: (id: string, server: McpServer) => Promise; + onSave: ( + id: string, + server: McpServer, + options?: { syncOtherSide?: boolean }, + ) => Promise; onClose: () => void; existingIds?: string[]; onNotify?: ( @@ -113,9 +117,16 @@ const McpFormModal: React.FC = ({ const [saving, setSaving] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); const [idError, setIdError] = useState(""); + const [syncOtherSide, setSyncOtherSide] = useState(false); // 判断是否使用 TOML 格式 const useToml = appType === "codex"; + const syncTargetLabel = + appType === "claude" ? t("apps.codex") : t("apps.claude"); + const syncCheckboxId = useMemo( + () => `sync-other-side-${appType}`, + [appType], + ); const wizardInitialSpec = useMemo(() => { const fallback = initialData?.server; @@ -432,7 +443,7 @@ const McpFormModal: React.FC = ({ } // 显式等待父组件保存流程 - await onSave(trimmedId, entry); + await onSave(trimmedId, entry, { syncOtherSide }); } catch (error: any) { const detail = extractErrorMessage(error); const mapped = translateMcpBackendError(detail, t); @@ -655,6 +666,28 @@ const McpFormModal: React.FC = ({ )} + + {/* 双端同步选项 */} +
+ setSyncOtherSide(event.target.checked)} + /> + +
{/* Footer */} diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 4f0dfde..f4b7840 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -135,10 +135,16 @@ const McpPanel: React.FC = ({ onClose, onNotify, appType }) => { }); }; - const handleSave = async (id: string, server: McpServer) => { + const handleSave = async ( + id: string, + server: McpServer, + options?: { syncOtherSide?: boolean }, + ) => { try { const payload: McpServer = { ...server, id }; - await window.api.upsertMcpServerInConfig(appType, id, payload); + await window.api.upsertMcpServerInConfig(appType, id, payload, { + syncOtherSide: options?.syncOtherSide, + }); await reload(); setIsFormOpen(false); setEditingId(null); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b6d7507..a5c737f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -298,7 +298,9 @@ "jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}", "tomlConfig": "TOML Configuration", "tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]", - "useWizard": "Config Wizard" + "useWizard": "Config Wizard", + "syncOtherSide": "Mirror to {{target}}", + "syncOtherSideHint": "Apply the same settings to {{target}}; existing entries with the same id will be overwritten." }, "wizard": { "title": "MCP Configuration Wizard", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 27dee3e..0f855a4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -298,7 +298,9 @@ "jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}", "tomlConfig": "TOML 配置", "tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]", - "useWizard": "配置向导" + "useWizard": "配置向导", + "syncOtherSide": "同步到 {{target}}", + "syncOtherSideHint": "勾选后会把当前配置同时写入 {{target}},若存在同名配置将被覆盖" }, "wizard": { "title": "MCP 配置向导", diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 3a6332a..3df7cdd 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -354,13 +354,18 @@ export const tauriAPI = { app: AppType = "claude", id: string, spec: McpServer, + options?: { syncOtherSide?: boolean }, ): Promise => { try { - return await invoke("upsert_mcp_server_in_config", { + const payload = { app, id, spec, - }); + ...(options?.syncOtherSide !== undefined + ? { syncOtherSide: options.syncOtherSide } + : {}), + }; + return await invoke("upsert_mcp_server_in_config", payload); } catch (error) { console.error("写入 MCP(config.json)失败:", error); throw error; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index eee470a..17eed09 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -84,6 +84,7 @@ declare global { app: AppType | undefined, id: string, spec: McpServer, + options?: { syncOtherSide?: boolean }, ) => Promise; deleteMcpServerInConfig: ( app: AppType | undefined,