diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 864eb1a..c7207c5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -314,16 +314,20 @@ pub async fn switch_provider( .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - let manager = config - .get_manager_mut(&app_type) - .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + // 为避免长期可变借用,尽快获取必要数据并缩小借用范围 + let provider = { + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - // 检查供应商是否存在 - let provider = manager - .providers - .get(&id) - .ok_or_else(|| format!("供应商不存在: {}", id))? - .clone(); + // 检查供应商是否存在 + let provider = manager + .providers + .get(&id) + .ok_or_else(|| format!("供应商不存在: {}", id))? + .clone(); + provider + }; // SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置 match app_type { @@ -331,7 +335,12 @@ pub async fn switch_provider( use serde_json::Value; // 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config - if !manager.current.is_empty() { + if !{ + let cur = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + cur.current.is_empty() + } { let auth_path = codex_config::get_codex_auth_path(); let config_path = codex_config::get_codex_config_path(); if auth_path.exists() { @@ -353,7 +362,16 @@ pub async fn switch_provider( "config": config_str, }); - if let Some(cur) = manager.providers.get_mut(&manager.current) { + let cur_id2 = { + let m = config + .get_manager(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + m.current.clone() + }; + let m = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + if let Some(cur) = m.providers.get_mut(&cur_id2) { cur.settings_config = live; } } @@ -376,10 +394,21 @@ pub async fn switch_provider( let settings_path = get_claude_settings_path(); // 回填:读取 live settings.json 写回当前供应商 settings_config - if settings_path.exists() && !manager.current.is_empty() { - if let Ok(live) = read_json_file::(&settings_path) { - if let Some(cur) = manager.providers.get_mut(&manager.current) { - cur.settings_config = live; + if settings_path.exists() { + let cur_id = { + let m = config + .get_manager(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + m.current.clone() + }; + if !cur_id.is_empty() { + if let Ok(live) = read_json_file::(&settings_path) { + let m = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + if let Some(cur) = m.providers.get_mut(&cur_id) { + cur.settings_config = live; + } } } } @@ -394,8 +423,18 @@ pub async fn switch_provider( } } - // 更新当前供应商 - manager.current = id; + // 更新当前供应商(短借用范围) + { + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + manager.current = id; + } + + // 对 Codex:切换完成且释放可变借用后,再依据 SSOT 同步 MCP 到 config.toml + if let AppType::Codex = app_type { + crate::mcp::sync_enabled_to_codex(&config)?; + } log::info!("成功切换到供应商: {}", provider.name); @@ -752,13 +791,14 @@ pub async fn delete_mcp_server_in_config( let existed = crate::mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?; drop(cfg); state.save()?; - // 若删除的是 Claude 客户端的条目,则同步一次,确保启用项从 ~/.claude.json 中移除 - if matches!(app_ty, crate::app_config::AppType::Claude) { - let cfg2 = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - crate::mcp::sync_enabled_to_claude(&cfg2)?; + // 若删除的是 Claude/Codex 客户端的条目,则同步一次,确保启用项从对应 live 配置中移除 + let cfg2 = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + match app_ty { + 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(existed) } @@ -793,6 +833,17 @@ pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result) -> Result { + let cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + crate::mcp::sync_enabled_to_codex(&cfg)?; + Ok(true) +} + /// 从 ~/.claude.json 导入 MCP 定义到 config.json,返回变更数量 #[tauri::command] pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76afbc2..9173a55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -435,6 +435,7 @@ pub fn run() { commands::delete_mcp_server_in_config, commands::set_mcp_enabled, commands::sync_enabled_mcp_to_claude, + commands::sync_enabled_mcp_to_codex, commands::import_mcp_from_claude, // ours: endpoint speed test + custom endpoint management commands::test_api_endpoints, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index db34d68..8160b34 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -109,7 +109,8 @@ pub fn set_enabled_and_sync_for( sync_enabled_to_claude(config)?; } AppType::Codex => { - // Codex 的 MCP 写入尚未实现(TOML 结构未定),此处先跳过 + // 将启用项投影到 ~/.codex/config.toml + sync_enabled_to_codex(config)?; } } Ok(true) @@ -160,3 +161,122 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result } Ok(changed) } + +/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers] +/// 策略: +/// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖 +/// - 重写根下的 `mcp` 节点(整体替换),其他节点保持不变 +/// - 仅写入启用项;无启用项时移除 `mcp` 节点 +pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> { + use toml::{value::Value as TomlValue, Table as TomlTable}; + + // 1) 收集启用项(Codex 维度) + let enabled = collect_enabled_servers(&config.mcp.codex); + + // 2) 读取现有 config.toml 并解析为 Table(允许空文件) + let base_text = crate::codex_config::read_and_validate_codex_config_text()?; + let mut root: TomlTable = if base_text.trim().is_empty() { + TomlTable::new() + } else { + toml::from_str::(&base_text) + .map_err(|e| format!("解析 config.toml 失败: {}", e))? + }; + + // 3) 构建 mcp.servers 表 + if enabled.is_empty() { + // 无启用项:清理 mcp 节点 + root.remove("mcp"); + } else { + let mut servers_tbl = TomlTable::new(); + + for (id, spec) in enabled.iter() { + let mut s = TomlTable::new(); + + // 类型(缺省视为 stdio) + let typ = spec + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("stdio"); + s.insert("type".into(), TomlValue::String(typ.to_string())); + + match typ { + "stdio" => { + let cmd = spec + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + s.insert("command".into(), TomlValue::String(cmd)); + + if let Some(args) = spec.get("args").and_then(|v| v.as_array()) { + let arr = args + .iter() + .filter_map(|x| x.as_str()) + .map(|x| TomlValue::String(x.to_string())) + .collect::>(); + if !arr.is_empty() { + s.insert("args".into(), TomlValue::Array(arr)); + } + } + + if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) { + if !cwd.trim().is_empty() { + s.insert("cwd".into(), TomlValue::String(cwd.to_string())); + } + } + + if let Some(env) = spec.get("env").and_then(|v| v.as_object()) { + let mut env_tbl = TomlTable::new(); + for (k, v) in env.iter() { + if let Some(sv) = v.as_str() { + env_tbl.insert(k.clone(), TomlValue::String(sv.to_string())); + } + } + if !env_tbl.is_empty() { + s.insert("env".into(), TomlValue::Table(env_tbl)); + } + } + } + "http" => { + let url = spec + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + s.insert("url".into(), TomlValue::String(url)); + + if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) { + let mut h_tbl = TomlTable::new(); + for (k, v) in headers.iter() { + if let Some(sv) = v.as_str() { + h_tbl.insert(k.clone(), TomlValue::String(sv.to_string())); + } + } + if !h_tbl.is_empty() { + s.insert("headers".into(), TomlValue::Table(h_tbl)); + } + } + } + _ => { + // 已在 validate_mcp_spec 保障,这里忽略 + } + } + + servers_tbl.insert(id.clone(), TomlValue::Table(s)); + } + + let mut mcp_tbl = TomlTable::new(); + mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl)); + + // 覆盖写入 mcp 节点 + root.insert("mcp".into(), TomlValue::Table(mcp_tbl)); + } + + // 4) 序列化并写回 config.toml(仅改 TOML,不触碰 auth.json) + let new_text = toml::to_string(&TomlValue::Table(root)) + .map_err(|e| format!("序列化 config.toml 失败: {}", e))?; + let path = crate::codex_config::get_codex_config_path(); + crate::config::write_text_file(&path, &new_text)?; + + Ok(()) +} diff --git a/src/App.tsx b/src/App.tsx index d71c732..d112e42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -392,6 +392,7 @@ function App() { {isMcpOpen && ( setIsMcpOpen(false)} onNotify={showNotification} /> diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 106d2e8..3c90cfb 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -9,6 +9,7 @@ import { extractErrorMessage } from "../../utils/errorUtils"; import { mcpPresets } from "../../config/mcpPresets"; import McpToggle from "./McpToggle"; import { buttonStyles, cardStyles, cn } from "../../lib/styles"; +import { AppType } from "../../lib/tauri-api"; interface McpPanelProps { onClose: () => void; @@ -17,13 +18,14 @@ interface McpPanelProps { type: "success" | "error", duration?: number, ) => void; + appType: AppType; } /** * MCP 管理面板 * 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行 */ -const McpPanel: React.FC = ({ onClose, onNotify }) => { +const McpPanel: React.FC = ({ onClose, onNotify, appType }) => { const { t } = useTranslation(); const [servers, setServers] = useState>({}); const [loading, setLoading] = useState(true); @@ -39,7 +41,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { const reload = async () => { setLoading(true); try { - const cfg = await window.api.getMcpConfig("claude"); + const cfg = await window.api.getMcpConfig(appType); setServers(cfg.servers || {}); } finally { setLoading(false); @@ -49,11 +51,13 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { useEffect(() => { const setup = async () => { try { - // 先从 ~/.claude.json 导入已存在的 MCP(设为 enabled=true) - await window.api.importMcpFromClaude(); + // Claude:从 ~/.claude.json 导入已存在的 MCP(设为 enabled=true) + if (appType === "claude") { + await window.api.importMcpFromClaude(); + } // 读取现有 config.json 内容 - const cfg = await window.api.getMcpConfig("claude"); + const cfg = await window.api.getMcpConfig(appType); const existing = cfg.servers || {}; // 将预设落库为禁用(若缺失) @@ -64,7 +68,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { enabled: false, source: "preset", } as unknown as McpServer; - await window.api.upsertMcpServerInConfig("claude", p.id, seed); + await window.api.upsertMcpServerInConfig(appType, p.id, seed); } } catch (e) { console.warn("MCP 初始化导入/落库失败(忽略继续)", e); @@ -73,7 +77,8 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { } }; setup(); - }, []); + // appType 改变时重新初始化 + }, [appType]); const handleToggle = async (id: string, enabled: boolean) => { try { @@ -81,9 +86,9 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { if (!server) { const preset = mcpPresets.find((p) => p.id === id); if (!preset) return; - await window.api.upsertMcpServerInConfig("claude", id, preset.server as McpServer); + await window.api.upsertMcpServerInConfig(appType, id, preset.server as McpServer); } - await window.api.setMcpEnabled("claude", id, enabled); + await window.api.setMcpEnabled(appType, id, enabled); await reload(); onNotify?.( enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), @@ -117,7 +122,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { message: t("mcp.confirm.deleteMessage", { id }), onConfirm: async () => { try { - await window.api.deleteMcpServerInConfig("claude", id); + await window.api.deleteMcpServerInConfig(appType, id); await reload(); setConfirmDialog(null); onNotify?.(t("mcp.msg.deleted"), "success", 1500); @@ -135,7 +140,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { const handleSave = async (id: string, server: McpServer) => { try { - await window.api.upsertMcpServerInConfig("claude", id, server); + await window.api.upsertMcpServerInConfig(appType, id, server); await reload(); setIsFormOpen(false); setEditingId(null); @@ -172,7 +177,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { {/* Header */}

- {t("mcp.title")} + {t("mcp.title")} · {t(appType === "claude" ? "apps.claude" : "apps.codex")}

diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index f93cc5f..a0c4fd0 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -396,6 +396,16 @@ export const tauriAPI = { } }, + // 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml + syncEnabledMcpToCodex: async (): Promise => { + try { + return await invoke("sync_enabled_mcp_to_codex"); + } catch (error) { + console.error("同步启用 MCP 到 config.toml 失败:", error); + throw error; + } + }, + importMcpFromClaude: async (): Promise => { try { return await invoke("import_mcp_from_claude"); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d87083c..0e2143f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -84,6 +84,7 @@ declare global { enabled: boolean, ) => Promise; syncEnabledMcpToClaude: () => Promise; + syncEnabledMcpToCodex: () => Promise; importMcpFromClaude: () => Promise; testApiEndpoints: ( urls: string[],