From 6cf7dacd0e43936881a642005f823aa0ac8551ff Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Oct 2025 14:59:02 +0800 Subject: [PATCH] feat(mcp): import Codex MCP from ~/.codex/config.toml - Support both TOML schemas: [mcp.servers.] and [mcp_servers.] - Non-destructive merge of imported servers (enabled=true only) - Preserve existing TOML schema when syncing (prefer mcp_servers) - Remove both mcp and mcp_servers when no enabled items feat(ui): auto-import Codex MCP on panel init (app=codex) chore(tauri): add import_mcp_from_codex command and register chore(types): expose window.api.importMcpFromCodex and typings fix(ui): remove unused variable for typecheck --- src-tauri/src/commands.rs | 15 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/mcp.rs | 165 ++++++++++++++++++++++++++-- src/App.tsx | 8 +- src/components/mcp/McpFormModal.tsx | 2 +- src/components/mcp/McpPanel.tsx | 17 +-- src/lib/styles.ts | 3 +- src/lib/tauri-api.ts | 16 ++- src/vite-env.d.ts | 14 ++- 9 files changed, 214 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c7207c5..001e5c0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -859,6 +859,21 @@ pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result) -> Result { + let mut cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let changed = crate::mcp::import_from_codex(&mut cfg)?; + drop(cfg); + if changed > 0 { + state.save()?; + } + Ok(changed) +} + /// 获取设置 #[tauri::command] pub async fn get_settings() -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9173a55..e593f42 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -437,6 +437,7 @@ pub fn run() { commands::sync_enabled_mcp_to_claude, commands::sync_enabled_mcp_to_codex, commands::import_mcp_from_claude, + commands::import_mcp_from_codex, // ours: endpoint speed test + custom endpoint management commands::test_api_endpoints, commands::get_custom_endpoints, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 8160b34..a5252ef 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -162,6 +162,147 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Ok(changed) } +/// 从 ~/.codex/config.toml 导入 MCP 到 config.json(Codex 作用域),并将导入项设为 enabled=true。 +/// 支持两种 schema:[mcp.servers.] 与 [mcp_servers.]。 +/// 已存在的项仅强制 enabled=true,不覆盖其他字段。 +pub fn import_from_codex(config: &mut MultiAppConfig) -> Result { + let text = crate::codex_config::read_and_validate_codex_config_text()?; + if text.trim().is_empty() { + return Ok(0); + } + + let root: toml::Table = toml::from_str(&text) + .map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?; + + let mut changed_total = 0usize; + + // helper:处理一组 servers 表 + let mut import_servers_tbl = |servers_tbl: &toml::value::Table| { + let mut changed = 0usize; + for (id, entry_val) in servers_tbl.iter() { + let Some(entry_tbl) = entry_val.as_table() else { continue }; + + // type 缺省为 stdio + let typ = entry_tbl + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("stdio"); + + // 构建 JSON 规范 + let mut spec = serde_json::Map::new(); + spec.insert("type".into(), json!(typ)); + + match typ { + "stdio" => { + if let Some(cmd) = entry_tbl.get("command").and_then(|v| v.as_str()) { + spec.insert("command".into(), json!(cmd)); + } + if let Some(args) = entry_tbl.get("args").and_then(|v| v.as_array()) { + let arr = args + .iter() + .filter_map(|x| x.as_str()) + .map(|s| json!(s)) + .collect::>(); + if !arr.is_empty() { + spec.insert("args".into(), serde_json::Value::Array(arr)); + } + } + if let Some(cwd) = entry_tbl.get("cwd").and_then(|v| v.as_str()) { + if !cwd.trim().is_empty() { + spec.insert("cwd".into(), json!(cwd)); + } + } + if let Some(env_tbl) = entry_tbl.get("env").and_then(|v| v.as_table()) { + let mut env_json = serde_json::Map::new(); + for (k, v) in env_tbl.iter() { + if let Some(sv) = v.as_str() { + env_json.insert(k.clone(), json!(sv)); + } + } + if !env_json.is_empty() { + spec.insert("env".into(), serde_json::Value::Object(env_json)); + } + } + } + "http" => { + if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) { + spec.insert("url".into(), json!(url)); + } + if let Some(headers_tbl) = entry_tbl.get("headers").and_then(|v| v.as_table()) { + let mut headers_json = serde_json::Map::new(); + for (k, v) in headers_tbl.iter() { + if let Some(sv) = v.as_str() { + headers_json.insert(k.clone(), json!(sv)); + } + } + if !headers_json.is_empty() { + spec.insert("headers".into(), serde_json::Value::Object(headers_json)); + } + } + } + _ => {} + } + + let spec_v = serde_json::Value::Object(spec); + + // 校验 + if let Err(e) = validate_mcp_spec(&spec_v) { + log::warn!("跳过无效 Codex MCP 项 '{}': {}", id, e); + continue; + } + + // 合并:仅强制 enabled=true + use std::collections::hash_map::Entry; + let entry = config + .mcp_for_mut(&AppType::Codex) + .servers + .entry(id.clone()); + match entry { + Entry::Vacant(vac) => { + let mut obj = spec_v.as_object().cloned().unwrap_or_default(); + obj.insert("enabled".into(), json!(true)); + vac.insert(serde_json::Value::Object(obj)); + changed += 1; + } + Entry::Occupied(mut occ) => { + if let Some(mut existing) = occ.get().as_object().cloned() { + let prev = existing + .get("enabled") + .and_then(|b| b.as_bool()) + .unwrap_or(false); + if !prev { + existing.insert("enabled".into(), json!(true)); + occ.insert(serde_json::Value::Object(existing)); + changed += 1; + } + } + } + } + } + changed + }; + + // 1) 处理 mcp.servers + if let Some(mcp_val) = root.get("mcp") { + if let Some(mcp_tbl) = mcp_val.as_table() { + if let Some(servers_val) = mcp_tbl.get("servers") { + if let Some(servers_tbl) = servers_val.as_table() { + changed_total += import_servers_tbl(servers_tbl); + } + } + } + } + + // 2) 处理 mcp_servers + if let Some(servers_val) = root.get("mcp_servers") { + if let Some(servers_tbl) = servers_val.as_table() { + changed_total += import_servers_tbl(servers_tbl); + } + } + + Ok(changed_total) +} + /// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers] /// 策略: /// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖 @@ -182,10 +323,12 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> { .map_err(|e| format!("解析 config.toml 失败: {}", e))? }; - // 3) 构建 mcp.servers 表 + // 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers) + let prefer_mcp_servers = root.contains_key("mcp_servers") || !root.contains_key("mcp"); if enabled.is_empty() { - // 无启用项:清理 mcp 节点 + // 无启用项:移除两种节点 root.remove("mcp"); + root.remove("mcp_servers"); } else { let mut servers_tbl = TomlTable::new(); @@ -257,19 +400,21 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> { } } } - _ => { - // 已在 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)); + if prefer_mcp_servers { + root.insert("mcp_servers".into(), TomlValue::Table(servers_tbl)); + root.remove("mcp"); + } else { + let mut mcp_tbl = TomlTable::new(); + mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl)); + root.insert("mcp".into(), TomlValue::Table(mcp_tbl)); + root.remove("mcp_servers"); + } } // 4) 序列化并写回 config.toml(仅改 TOML,不触碰 auth.json) diff --git a/src/App.tsx b/src/App.tsx index d112e42..8ded4db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ function App() { const [currentProviderId, setCurrentProviderId] = useState(""); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [editingProviderId, setEditingProviderId] = useState( - null + null, ); const [notification, setNotification] = useState<{ message: string; @@ -44,7 +44,7 @@ function App() { const showNotification = ( message: string, type: "success" | "error", - duration = 3000 + duration = 3000, ) => { // 清除之前的定时器 if (timeoutRef.current) { @@ -196,7 +196,7 @@ function App() { ? t("notifications.removedFromClaudePlugin") : t("notifications.appliedToClaudePlugin"), "success", - 2000 + 2000, ); } } catch (error: any) { @@ -219,7 +219,7 @@ function App() { showNotification( t("notifications.switchSuccess", { appName }), "success", - 2000 + 2000, ); // 更新托盘菜单 await window.api.updateTrayMenu(); diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 89260f2..ec5965d 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -44,7 +44,7 @@ const McpFormModal: React.FC = ({ const { t } = useTranslation(); const [formId, setFormId] = useState(editingId || ""); const [formDescription, setFormDescription] = useState( - (initialData as any)?.description || "" + (initialData as any)?.description || "", ); const [formJson, setFormJson] = useState( initialData ? JSON.stringify(initialData, null, 2) : "", diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 3c90cfb..5fd21b1 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -51,9 +51,11 @@ const McpPanel: React.FC = ({ onClose, onNotify, appType }) => { useEffect(() => { const setup = async () => { try { - // Claude:从 ~/.claude.json 导入已存在的 MCP(设为 enabled=true) + // 初始化导入:按应用类型从对应客户端导入已有 MCP(设为 enabled=true) if (appType === "claude") { await window.api.importMcpFromClaude(); + } else if (appType === "codex") { + await window.api.importMcpFromCodex(); } // 读取现有 config.json 内容 @@ -86,7 +88,11 @@ const McpPanel: React.FC = ({ onClose, onNotify, appType }) => { if (!server) { const preset = mcpPresets.find((p) => p.id === id); if (!preset) return; - await window.api.upsertMcpServerInConfig(appType, id, preset.server as McpServer); + await window.api.upsertMcpServerInConfig( + appType, + id, + preset.server as McpServer, + ); } await window.api.setMcpEnabled(appType, id, enabled); await reload(); @@ -177,7 +183,8 @@ const McpPanel: React.FC = ({ onClose, onNotify, appType }) => { {/* Header */}

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

@@ -252,10 +259,6 @@ const McpPanel: React.FC = ({ onClose, onNotify, appType }) => { {/* 预设(未安装) */} {notInstalledPresets.map((p) => { - const s = { - ...(p.server as McpServer), - enabled: false, - } as McpServer; return (
, ): Promise => { try { - return await invoke("upsert_mcp_server_in_config", { app, id, spec }); + return await invoke("upsert_mcp_server_in_config", { + app, + id, + spec, + }); } catch (error) { console.error("写入 MCP(config.json)失败:", error); throw error; @@ -415,6 +419,16 @@ export const tauriAPI = { } }, + // 从 ~/.codex/config.toml 导入 MCP(Codex 作用域) + importMcpFromCodex: async (): Promise => { + try { + return await invoke("import_mcp_from_codex"); + } catch (error) { + console.error("从 ~/.codex/config.toml 导入 MCP 失败:", error); + throw error; + } + }, + // ours: 第三方/自定义供应商——测速与端点管理 // 第三方/自定义供应商:批量测试端点延迟 testApiEndpoints: async ( diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 0e2143f..b34d517 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,6 +1,12 @@ /// -import { Provider, Settings, CustomEndpoint, McpStatus, McpConfigResponse } from "./types"; +import { + Provider, + Settings, + CustomEndpoint, + McpStatus, + McpConfigResponse, +} from "./types"; import { AppType } from "./lib/tauri-api"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -77,7 +83,10 @@ declare global { id: string, spec: Record, ) => Promise; - deleteMcpServerInConfig: (app: AppType | undefined, id: string) => Promise; + deleteMcpServerInConfig: ( + app: AppType | undefined, + id: string, + ) => Promise; setMcpEnabled: ( app: AppType | undefined, id: string, @@ -86,6 +95,7 @@ declare global { syncEnabledMcpToClaude: () => Promise; syncEnabledMcpToCodex: () => Promise; importMcpFromClaude: () => Promise; + importMcpFromCodex: () => Promise; testApiEndpoints: ( urls: string[], options?: { timeoutSecs?: number },