From 023726c59d21058cfc48806bb98c4769c6a5d7b1 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 18 Nov 2025 16:08:31 +0800 Subject: [PATCH] docs: add Codex MCP raw TOML refactor plan Add comprehensive implementation plan for v3.7.0 refactor that separates Codex MCP configuration from unified MCP structure. Key design decisions: - Codex MCP stored as raw TOML string in codexMcp.rawToml - Unified MCP (mcp.servers) only supports Claude/Gemini - Complete isolation: no apps.codex in unified structure - Migration clears mcp.codex.servers to prevent pollution Architecture improvements: - Single responsibility: each data source has one purpose - No priority conflicts: completely independent data paths - Simplified switching logic: no conditional branches - UI constraints: Tab1 limited to claude/gemini only Implementation phases: - Phase 0: Setup (0.5d) - Phase 1: Backend foundation (1.5d) - Phase 2: Command layer (1d) - Phase 3: Switching logic (1d) - Phase 4: Frontend API (0.5d) - Phase 5: UI implementation (2d) - Phase 6: Enhancements (1d, optional) - Phase 7: Testing & docs (1d) Total: 8.5 days (MVP: 7 days) Addresses TOML-JSON conversion data loss issue by preserving raw TOML format for Codex while maintaining structured approach for Claude/Gemini. --- docs/CODEX_MCP_RAW_TOML_PLAN.md | 1309 +++++++++++++++++++++++++++++++ 1 file changed, 1309 insertions(+) create mode 100644 docs/CODEX_MCP_RAW_TOML_PLAN.md diff --git a/docs/CODEX_MCP_RAW_TOML_PLAN.md b/docs/CODEX_MCP_RAW_TOML_PLAN.md new file mode 100644 index 0000000..0e99efa --- /dev/null +++ b/docs/CODEX_MCP_RAW_TOML_PLAN.md @@ -0,0 +1,1309 @@ +# Codex MCP Raw TOML 重构方案 + +## 📋 目录 + +- [背景与目标](#背景与目标) +- [核心设计](#核心设计) +- [技术架构](#技术架构) +- [实施计划](#实施计划) +- [风险控制](#风险控制) +- [测试验证](#测试验证) + +--- + +## 背景与目标 + +### 当前问题 + +1. **数据丢失**:Codex MCP 配置在 TOML ↔ JSON 转换时丢失注释、格式、特殊值类型 +2. **配置复杂**:Codex TOML 支持复杂嵌套结构,强制结构化存储限制灵活性 +3. **用户体验差**:无法保留用户手写的注释和格式偏好 + +### 设计目标 + +1. **保真存储**:Codex MCP 使用 raw TOML 字符串存储,完全避免序列化损失 +2. **架构分离**:Claude/Gemini 继续用结构化 JSON,Codex 用原始文本 +3. **UI 解耦**:MCP 管理面板与当前 app 切换彻底分离 +4. **增量实施**:零改动现有 Claude/Gemini 逻辑,风险可控 + +--- + +## 核心设计 + +### 数据结构设计 + +#### config.json 顶层结构 + +```json +{ + "providers": [ + // 现有 provider 列表,不改 + ], + "mcp": { + // 统一 MCP 结构,仅用于 Claude & Gemini + // ✅ 完全移除 Codex 相关逻辑,apps 字段仅包含 claude/gemini + "servers": { + "fetch": { + "id": "fetch", + "name": "Fetch MCP", + "server": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-fetch"] + }, + "apps": { + "claude": true, + "gemini": false + }, + "description": null, + "homepage": null, + "docs": null, + "tags": [] + } + } + }, + "codexMcp": { + "rawToml": "[mcp]\n# Codex 专用 MCP TOML 片段\n..." + } +} +``` + +#### Rust 数据结构 + +```rust +// src-tauri/src/app_config.rs +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexMcpConfig { + /// 完整的 MCP TOML 片段(包含 [mcp] 等) + pub raw_toml: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiAppConfig { + /// 版本号(v2 起) + #[serde(default = "default_version")] + pub version: u32, + + /// 应用管理器(claude/codex/gemini) + #[serde(flatten)] + pub apps: HashMap, + + /// MCP 配置(统一结构 + 旧结构,用于迁移) + #[serde(default)] + pub mcp: McpRoot, + + /// Prompt 配置(按客户端分治) + #[serde(default)] + pub prompts: PromptRoot, + + /// 通用配置片段(按应用分治) + #[serde(default)] + pub common_config_snippets: CommonConfigSnippets, + + /// Claude 通用配置片段(旧字段,用于向后兼容迁移) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude_common_config_snippet: Option, + + /// Codex MCP raw TOML(新字段,仅 Codex 使用) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_mcp: Option, +} +``` + +### 分层架构 + +``` +┌─────────────────────────────────────────────────┐ +│ UI 层 │ +│ ┌─────────────────────────────────────────┐ │ +│ │ MCP 面板(与 app 切换完全解耦) │ │ +│ │ ├─ Tab1: Claude & Gemini (结构化 JSON) │ │ +│ │ │ - 仅管理 mcp.servers │ │ +│ │ │ - apps 字段仅含 claude/gemini │ │ +│ │ └─ Tab2: Codex (raw TOML 编辑器) │ │ +│ │ - 独立管理 codexMcp.rawToml │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────┐ +│ 应用层 │ +│ switch_app() 根据 app 类型选择数据源: │ +│ - Claude/Gemini → mcp.servers (过滤 apps) │ +│ - Codex → codexMcp.rawToml (完全独立) │ +│ │ +│ ✅ 无优先级冲突:两者完全隔离 │ +└─────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────┐ +│ 数据层 │ +│ config.json: │ +│ - mcp.servers: 仅 Claude & Gemini │ +│ - codexMcp.rawToml: 仅 Codex │ +│ │ +│ ✅ 单一职责:互不干扰 │ +└─────────────────────────────────────────────────┘ +``` + +### MCP 配置职责划分 + +| 配置源 | 职责 | 数据格式 | 管理方式 | +|--------|------|----------|----------| +| `mcp.servers` | Claude & Gemini MCP | 结构化 JSON | UI 表单(Tab1) | +| `codexMcp.rawToml` | Codex MCP | 原始 TOML 字符串 | 代码编辑器(Tab2) | + +**关键原则**: +- ✅ `mcp.servers` 中的 `apps` 字段**永不包含 `codex`** +- ✅ Codex MCP **仅存储**在 `codexMcp.rawToml` +- ✅ 切换逻辑**完全独立**,无优先级判断 + +--- + +## 技术架构 + +### 后端架构(Rust) + +#### 1. 配置管理 + +**文件**:`src-tauri/src/app_config.rs` + +```rust +impl MultiAppConfig { + pub fn load() -> Result { + // 1. 按 v2 结构加载 MultiAppConfig + let mut config = /* ... 现有 load 实现 ... */; + + let mut updated = false; + + // 2. 执行 Codex MCP → raw TOML 迁移 + // - 仅迁移 v3.6.2 的 mcp.codex.servers → codexMcp.rawToml + // - 迁移后清空 mcp.codex.servers,避免被后续 unified 迁移处理 + if migration::migrate_codex_mcp_to_raw_toml(&mut config)? { + updated = true; + } + + // 3. 执行 unified MCP 迁移(mcp.claude/gemini → mcp.servers) + // - ✅ 此时 mcp.codex 已清空,不会被迁移到 unified + // - unified 结构中 apps 字段仅包含 claude/gemini + if config.migrate_mcp_to_unified()? { + updated = true; + } + + // 4. 其他迁移(Prompt、通用片段等) + // ... + + if updated { + config.save()?; + } + + Ok(config) + } + + pub fn save(&self) -> Result<(), AppError> { + // 序列化时包含 codexMcp 字段 + // ... + } +} +``` + +#### 2. 数据迁移 + +**文件**:`src-tauri/src/migration.rs` + +```rust +/// 将 v3.6.2 的 mcp.codex.servers 迁移为 codexMcp.rawToml +/// +/// **关键行为**: +/// 1. 仅在 codex_mcp 为空且存在旧的 mcp.codex.servers 时执行 +/// 2. 转换后**立即清空 mcp.codex.servers**,避免被 unified 迁移重复处理 +/// 3. 返回 true 表示发生了迁移,需要保存配置 +pub fn migrate_codex_mcp_to_raw_toml( + config: &mut MultiAppConfig, +) -> Result { + // 已迁移过,跳过 + if config.codex_mcp.is_some() { + return Ok(false); + } + + let legacy_servers = &config.mcp.codex.servers; + if legacy_servers.is_empty() { + // 没有旧的 Codex MCP 配置,跳过 + return Ok(false); + } + + // 转换为 TOML + let toml = convert_legacy_codex_mcp_to_toml(legacy_servers)?; + config.codex_mcp = Some(CodexMcpConfig { raw_toml: toml }); + + // ✅ 关键:清空旧数据,确保 unified 迁移不会处理 Codex + config.mcp.codex.servers.clear(); + + log::info!( + "Migrated {} Codex MCP servers to raw TOML and cleared legacy storage", + legacy_servers.len() + ); + + Ok(true) +} + +/// 将 v3.6.2 时代的 mcp.codex.servers (HashMap) +/// 转换为 Codex 所需的 MCP TOML 片段 +fn convert_legacy_codex_mcp_to_toml( + servers: &HashMap, +) -> Result { + let mut toml = String::from("[mcp]\n\n"); + + for (id, entry) in servers { + // 旧结构:entry 是宽松 JSON 对象,包含 name/server/enabled 等字段 + let obj = entry + .as_object() + .ok_or_else(|| AppError::Config(format!( + "无效的 Codex MCP 条目 '{}': 必须为 JSON 对象", + id + )))?; + + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(id); + + let server = obj.get("server").ok_or_else(|| { + AppError::Config(format!( + "无效的 Codex MCP 条目 '{}': 缺少 server 字段", + id + )) + })?; + + let server_obj = server.as_object().ok_or_else(|| { + AppError::Config(format!( + "无效的 Codex MCP 条目 '{}': server 必须是 JSON 对象", + id + )) + })?; + + toml.push_str("[[mcp.servers]]\n"); + toml.push_str(&format!("name = \"{}\"\n", name)); + + // stdio 类型字段 + if let Some(cmd) = server_obj.get("command").and_then(|v| v.as_str()) { + toml.push_str(&format!("command = \"{}\"\n", cmd)); + } + + if let Some(args) = server_obj.get("args").and_then(|v| v.as_array()) { + let args_str = args + .iter() + .filter_map(|a| a.as_str()) + .map(|a| format!("\"{}\"", a)) + .collect::>() + .join(", "); + if !args_str.is_empty() { + toml.push_str(&format!("args = [{}]\n", args_str)); + } + } + + if let Some(env) = server_obj.get("env").and_then(|v| v.as_object()) { + if !env.is_empty() { + toml.push_str("\n[mcp.servers.env]\n"); + for (k, v) in env { + if let Some(val) = v.as_str() { + toml.push_str(&format!("{} = \"{}\"\n", k, val)); + } + } + } + } + + if let Some(cwd) = server_obj.get("cwd").and_then(|v| v.as_str()) { + toml.push_str(&format!("cwd = \"{}\"\n", cwd)); + } + + // http 类型字段 + if let Some(url) = server_obj.get("url").and_then(|v| v.as_str()) { + toml.push_str(&format!("url = \"{}\"\n", url)); + } + + if let Some(t) = server_obj.get("type").and_then(|v| v.as_str()) { + toml.push_str(&format!("type = \"{}\"\n", t)); + } + + if let Some(headers) = server_obj.get("headers").and_then(|v| v.as_object()) { + if !headers.is_empty() { + toml.push_str("\n[mcp.servers.headers]\n"); + for (k, v) in headers { + if let Some(val) = v.as_str() { + toml.push_str(&format!("{} = \"{}\"\n", k, val)); + } + } + } + } + + toml.push_str("\n"); + } + + Ok(toml) +} +``` + +#### 3. Tauri 命令 + +**文件**:`src-tauri/src/commands/mcp.rs` + +```rust +/// 获取 Codex MCP 配置 +#[tauri::command] +pub async fn get_codex_mcp_config( + state: State<'_, AppState> +) -> Result { + let config = state.config.read().unwrap(); + + if let Some(codex_mcp) = &config.codex_mcp { + Ok(codex_mcp.raw_toml.clone()) + } else { + // 返回默认模板 + Ok(String::from( + "[mcp]\n# 在这里填写 Codex MCP 配置\n# 示例:\n# [[mcp.servers]]\n# name = \"example\"\n# command = \"npx\"\n# args = [\"-y\", \"@modelcontextprotocol/server-example\"]\n" + )) + } +} + +/// 更新 Codex MCP 配置 +#[tauri::command] +pub async fn update_codex_mcp_config( + state: State<'_, AppState>, + raw_toml: String, +) -> Result<(), String> { + // 1. 语法验证 + toml::from_str::(&raw_toml) + .map_err(|e| format!("TOML syntax error: {}", e))?; + + // 2. 可选警告 + if !raw_toml.contains("[mcp") { + log::warn!("Codex MCP TOML doesn't contain [mcp] section"); + } + + // 3. 保存 + let mut config = state.config.write().unwrap(); + config.codex_mcp = Some(CodexMcpConfig { + raw_toml: raw_toml.clone(), + }); + config.save() + .map_err(|e| format!("Failed to save config: {}", e))?; + + Ok(()) +} + +/// 验证 TOML 语法(前端可在保存前调用) +#[tauri::command] +pub async fn validate_codex_mcp_toml( + raw_toml: String +) -> Result { + match toml::from_str::(&raw_toml) { + Ok(_) => Ok(ValidateResult { + valid: true, + error: None, + warnings: vec![], + }), + Err(e) => Ok(ValidateResult { + valid: false, + error: Some(e.to_string()), + warnings: vec![], + }), + } +} + +#[derive(Debug, Serialize)] +pub struct ValidateResult { + pub valid: bool, + pub error: Option, + pub warnings: Vec, +} + +/// 从 Codex live 配置导入 MCP 段 +#[tauri::command] +pub async fn import_codex_mcp_from_live() -> Result { + let config_path = get_codex_config_path() + .map_err(|e| e.to_string())?; + + if !config_path.exists() { + return Ok(String::from("[mcp]\n# No existing Codex config found\n")); + } + + let content = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read Codex config: {}", e))?; + + let mcp_section = extract_mcp_section_from_toml(&content)?; + Ok(mcp_section) +} + +fn extract_mcp_section_from_toml(content: &str) -> Result { + use toml_edit::DocumentMut; + + let doc = content.parse::() + .map_err(|e| format!("Invalid TOML: {}", e))?; + + if let Some(mcp_item) = doc.get("mcp") { + let mut result = String::from("[mcp]\n"); + result.push_str(&mcp_item.to_string()); + Ok(result) + } else { + Ok(String::from("[mcp]\n# No MCP config found in live file\n")) + } +} +``` + +#### 3. 切换逻辑 + +**文件**:`src-tauri/src/services/provider.rs` + +```rust +impl ProviderService { + /// 切换到 Codex provider + pub fn switch_to_codex( + &self, + provider: &Provider + ) -> Result<(), AppError> { + // 1. 读取 Codex MCP 配置(完全独立于 unified) + let codex_mcp = { + let config = self.state.config.read().unwrap(); + config.codex_mcp.clone() + }; + + // 2. 生成最终配置(base + MCP) + let final_toml = self.apply_codex_config(provider, &codex_mcp)?; + + // 3. 写入 live 文件 + self.write_codex_config(&final_toml)?; + + Ok(()) + } + + fn apply_codex_config( + &self, + provider: &Provider, + codex_mcp: &Option, + ) -> Result { + // 1. 生成基础配置(不含 MCP) + let mut base_config = self.generate_codex_base_config(provider)?; + + // 2. 追加 MCP 配置(如果有) + if let Some(mcp_cfg) = codex_mcp { + let trimmed = mcp_cfg.raw_toml.trim(); + if !trimmed.is_empty() { + // 确保有换行分隔 + if !base_config.ends_with('\n') { + base_config.push('\n'); + } + base_config.push('\n'); + base_config.push_str(trimmed); + } + } + + // 3. 验证最终 TOML 可解析 + toml::from_str::(&base_config) + .map_err(|e| AppError::Config(format!( + "Generated Codex config is invalid: {}", + e + )))?; + + Ok(base_config) + } + + /// 切换到 Claude/Gemini + pub fn switch_to_claude_or_gemini( + &self, + provider: &Provider, + app_type: AppType, + ) -> Result<(), AppError> { + // 从 unified MCP 读取配置(apps 字段仅含 claude/gemini) + let mcp_servers = { + let config = self.state.config.read().unwrap(); + config.mcp.servers + .values() + .filter(|s| s.apps.get(&app_type.to_string()).unwrap_or(&false)) + .cloned() + .collect::>() + }; + + // 生成并写入配置 + // ... + + Ok(()) + } +} +``` + +**关键点**: +- ✅ Codex 切换**完全不读取** `mcp.servers` +- ✅ Claude/Gemini 切换**完全不读取** `codexMcp` +- ✅ 无优先级判断,逻辑简单清晰 + +### 前端架构(React + TypeScript) + +#### 1. API 层 + +**文件**:`src/lib/api/mcp.ts` + +```typescript +export const codexMcpApi = { + /** + * 获取 Codex MCP 配置(raw TOML) + */ + get: () => invoke('get_codex_mcp_config'), + + /** + * 更新 Codex MCP 配置 + */ + update: (rawToml: string) => + invoke('update_codex_mcp_config', { rawToml }), + + /** + * 验证 TOML 语法 + */ + validate: (rawToml: string) => + invoke('validate_codex_mcp_toml', { rawToml }), + + /** + * 从 Codex live 配置导入 + */ + importFromLive: () => + invoke('import_codex_mcp_from_live'), +}; + +export interface ValidateResult { + valid: boolean; + error?: string; + warnings: string[]; +} +``` + +#### 2. Hooks + +**文件**:`src/hooks/useCodexMcp.ts` + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { codexMcpApi } from '@/lib/api/mcp'; +import { toast } from 'sonner'; + +export function useCodexMcp() { + const queryClient = useQueryClient(); + + // 查询 + const query = useQuery({ + queryKey: ['codexMcp'], + queryFn: codexMcpApi.get, + }); + + // 更新 + const updateMutation = useMutation({ + mutationFn: codexMcpApi.update, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['codexMcp'] }); + toast.success('Codex MCP 配置已保存'); + }, + onError: (error: Error) => { + toast.error(`保存失败: ${error.message}`); + }, + }); + + // 验证 + const validateMutation = useMutation({ + mutationFn: codexMcpApi.validate, + }); + + // 导入 + const importMutation = useMutation({ + mutationFn: codexMcpApi.importFromLive, + onSuccess: (data) => { + queryClient.setQueryData(['codexMcp'], data); + toast.success('已从 Codex 配置导入 MCP'); + }, + onError: (error: Error) => { + toast.error(`导入失败: ${error.message}`); + }, + }); + + return { + rawToml: query.data ?? '', + isLoading: query.isLoading, + update: updateMutation.mutate, + // 保存前需要拿到校验结果,因此对外暴露 mutateAsync,便于 await + validate: validateMutation.mutateAsync, + importFromLive: importMutation.mutate, + }; +} +``` + +#### 3. UI 组件 + +**文件**:`src/components/mcp/McpPanel.tsx` + +```typescript +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ClaudeGeminiMcpTab } from './ClaudeGeminiMcpTab'; +import { CodexMcpTab } from './CodexMcpTab'; + +export function McpPanel() { + return ( + + + + Claude & Gemini + + + Codex + + + + + + + + + + + + ); +} +``` + +**文件**:`src/components/mcp/ClaudeGeminiMcpTab.tsx` + +```typescript +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useMcp } from '@/hooks/useMcp'; + +/** + * Claude & Gemini 的 MCP 管理 Tab + * + * ✅ 仅操作 mcp.servers + * ✅ apps 字段仅含 claude/gemini(不含 codex) + * ✅ 完全独立于当前选中的 app + */ +export function ClaudeGeminiMcpTab() { + const { servers, addServer, updateServer, deleteServer } = useMcp(); + + return ( +
+ + + 管理 Claude 和 Gemini 的 MCP 服务器。 +
+ 注意:Codex MCP 在专用 Tab 管理(raw TOML 格式)。 +
+
+ + {/* 现有 MCP 列表组件,但需确保: */} + {/* 1. 表单中 apps 选项仅显示 claude/gemini */} + {/* 2. 过滤掉可能的历史遗留 codex 数据 */} + !s.apps.codex)} + onAdd={addServer} + onUpdate={updateServer} + onDelete={deleteServer} + availableApps={['claude', 'gemini']} // ✅ 限制可选应用 + /> +
+ ); +} +``` + +**文件**:`src/components/mcp/CodexMcpTab.tsx` + +```typescript +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { CodexMcpEditor } from './CodexMcpEditor'; +import { useCodexMcp } from '@/hooks/useCodexMcp'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +export function CodexMcpTab() { + const { rawToml, isLoading, update, validate, importFromLive } = useCodexMcp(); + const [localValue, setLocalValue] = useState(rawToml); + const [validationError, setValidationError] = useState(null); + + // 当后端数据加载完成或导入时,同步到本地编辑器 + useEffect(() => { + setLocalValue(rawToml); + }, [rawToml]); + + const handleSave = async () => { + // 保存前验证 + const result = await validate(localValue); + + if (!result.valid) { + setValidationError(result.error ?? 'Unknown error'); + return; + } + + setValidationError(null); + update(localValue); + }; + + const handleImport = () => { + importFromLive(); + }; + + if (isLoading) { + return
加载中...
; + } + + return ( +
+ + + 直接编辑 Codex MCP TOML 配置。修改会在下次切换到 Codex 时生效。 + + + + {validationError && ( + + + TOML 语法错误: {validationError} + + + )} + + + +
+ + + +
+
+ ); +} +``` + +**文件**:`src/components/mcp/CodexMcpEditor.tsx` + +```typescript +import { useEffect, useRef } from 'react'; +import { EditorView, basicSetup } from 'codemirror'; +import { toml } from '@codemirror/lang-toml'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { linter, Diagnostic } from '@codemirror/lint'; +import * as TOML from 'smol-toml'; + +const tomlLinter = linter((view) => { + const diagnostics: Diagnostic[] = []; + const content = view.state.doc.toString(); + + try { + TOML.parse(content); + } catch (e: any) { + diagnostics.push({ + from: 0, + to: content.length, + severity: 'error', + message: `TOML Syntax Error: ${e.message}`, + }); + } + + return diagnostics; +}); + +interface Props { + value: string; + onChange: (value: string) => void; +} + +export function CodexMcpEditor({ value, onChange }: Props) { + const editorRef = useRef(null); + const viewRef = useRef(); + + useEffect(() => { + if (!editorRef.current) return; + + const view = new EditorView({ + doc: value, + extensions: [ + basicSetup, + toml(), + oneDark, + tomlLinter, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChange(update.state.doc.toString()); + } + }), + ], + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => view.destroy(); + }, []); + + // 外部值变化时更新编辑器 + useEffect(() => { + if (!viewRef.current) return; + const currentValue = viewRef.current.state.doc.toString(); + if (currentValue !== value) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: currentValue.length, + insert: value, + }, + }); + } + }, [value]); + + return ( +
+ ); +} +``` + +--- + +## 实施计划 + +### Phase 0: 准备工作 + +**时间**:0.5 天 + +- [ ] 创建开发分支 `feature/codex-mcp-raw-toml` +- [ ] 安装前端依赖:`pnpm add @codemirror/lang-toml` +- [ ] 备份现有配置文件用于测试 + +### Phase 1: 后端基础(P0) + +**时间**:1.5 天 + +**任务**: + +- [ ] 在 `app_config.rs` 中定义 `CodexMcpConfig` +- [ ] 修改 `MultiAppConfig` 添加 `codex_mcp` 字段 +- [ ] 更新 `MultiAppConfig::load()` 和 `save()` 支持新字段 +- [ ] 编写迁移函数 `migrate_codex_mcp_to_raw_toml` + - [ ] 实现 `convert_servers_map_to_toml` + - [ ] 处理 stdio 类型服务器 + - [ ] 处理 http 类型服务器 +- [ ] 在 `lib.rs` 启动时执行迁移 +- [ ] 单元测试:迁移逻辑正确性 + +**验收标准**: + +- 现有配置可正确迁移为 raw TOML +- config.json 包含 `codexMcp` 字段 +- 迁移不影响 Claude/Gemini 配置 + +### Phase 2: 命令层(P0) + +**时间**:1 天 + +**任务**: + +- [ ] 在 `commands/mcp.rs` 实现命令: + - [ ] `get_codex_mcp_config` + - [ ] `update_codex_mcp_config` + - [ ] `validate_codex_mcp_toml` + - [ ] `import_codex_mcp_from_live` +- [ ] 实现 `extract_mcp_section_from_toml` 辅助函数 +- [ ] 在 `lib.rs` 注册新命令 +- [ ] 集成测试:命令调用正确性 + +**验收标准**: + +- 所有命令可通过 Tauri invoke 正常调用 +- TOML 语法验证准确 +- 从 live 配置导入功能正常 + +### Phase 3: 切换逻辑(P0) + +**时间**:1 天 + +**任务**: + +- [ ] 修改 `services/provider.rs` 的 Codex 切换逻辑 + - [ ] 实现 `switch_to_codex`(仅读取 `codexMcp`) + - [ ] 实现 `apply_codex_config`(拼接 base + raw TOML) + - [ ] 添加最终 TOML 验证 +- [ ] 确保 Claude/Gemini 切换逻辑不读取 `codexMcp` +- [ ] 原子写入机制验证 +- [ ] 集成测试:Codex 切换后配置正确 + +**验收标准**: + +- Codex 切换时,config.toml 包含 raw TOML 的 MCP 段 +- Claude/Gemini 切换时,仅使用 `mcp.servers` 中 `apps.claude/gemini=true` 的项 +- 生成的配置可被对应应用正确解析 +- 切换失败时不损坏现有配置 + +### Phase 4: 前端 API(P0) + +**时间**:0.5 天 + +**任务**: + +- [ ] 在 `lib/api/mcp.ts` 创建 `codexMcpApi` +- [ ] 定义 TypeScript 类型 `ValidateResult` +- [ ] 在 `hooks/useCodexMcp.ts` 创建 Hook + - [ ] useQuery 读取配置 + - [ ] useMutation 更新配置 + - [ ] useMutation 验证语法 + - [ ] useMutation 导入配置 + +**验收标准**: + +- API 调用成功返回数据 +- Hook 状态管理正确 +- 错误处理完善 + +### Phase 5: UI 实现(P0) + +**时间**:2 天 + +**任务**: + +- [ ] 重构 `McpPanel.tsx` 为 Tabs 布局 +- [ ] 创建 `ClaudeGeminiMcpTab.tsx` + - [ ] 移除对 `currentApp` 的依赖 + - [ ] 直接操作 `mcp.servers` + - [ ] **限制 `availableApps` 为 `['claude', 'gemini']`** + - [ ] **过滤掉 `apps.codex` 的历史数据** + - [ ] 添加提示:"Codex MCP 在专用 Tab 管理" +- [ ] 创建 `CodexMcpTab.tsx` + - [ ] 集成编辑器组件 + - [ ] 实现保存/导入/重置逻辑 + - [ ] 添加验证错误提示 +- [ ] 创建 `CodexMcpEditor.tsx` + - [ ] 集成 CodeMirror 6 + - [ ] 配置 TOML 语法高亮 + - [ ] 集成 TOML linter + - [ ] 实现双向绑定 +- [ ] 国际化:添加相关翻译 key +- [ ] **更新现有 MCP 表单组件,移除 `codex` 选项** + +**验收标准**: + +- MCP 面板有两个独立 Tab +- Tab1 (Claude & Gemini): + - `apps` 选项仅显示 claude/gemini + - 不显示任何 `apps.codex=true` 的服务器 + - 无法添加/编辑 Codex MCP +- Tab2 (Codex): + - 可正常编辑 raw TOML + - 语法错误有实时提示 + - 保存后配置持久化 + +### Phase 6: 增强功能(P1) + +**时间**:1 天 + +**任务**: + +- [ ] 添加 TOML 模板快捷插入功能 +- [ ] 导出到 Codex live 配置功能 +- [ ] 配置历史记录(可选) +- [ ] 改进错误提示(显示行号) + +**验收标准**: + +- 模板插入功能可用 +- 导出功能正常 + +### Phase 7: 测试与文档(P0) + +**时间**:1 天 + +**任务**: + +- [ ] 端到端测试: + - [ ] 新用户首次启动 + - [ ] 现有用户迁移场景(v3.6.2 → v3.7.0) + - [ ] 验证迁移后 `mcp.codex.servers` 被清空 + - [ ] 验证 unified MCP 不包含 `apps.codex` + - [ ] Claude ↔ Codex ↔ Gemini 切换 + - [ ] Codex MCP 编辑后切换生效 + - [ ] Tab1 无法操作 Codex MCP +- [ ] 更新 `CLAUDE.md` 文档 + - [ ] 明确 MCP 配置职责划分 + - [ ] 更新配置文件路径说明 +- [ ] 编写 migration guide +- [ ] 添加 CHANGELOG 条目 + +**验收标准**: + +- 所有测试用例通过 +- 文档完整准确 +- 迁移逻辑无数据丢失 + +--- + +## 风险控制 + +### 1. 数据丢失风险 + +**风险**:迁移过程中旧配置丢失 + +**控制措施**: + +- ✅ 迁移前自动备份 config.json(带时间戳) +- ✅ **迁移后清空 `mcp.codex.servers`,但不删除 `mcp.codex` 根节点**(保留结构用于回滚) +- ✅ 迁移日志记录详细信息(服务器数量、时间戳等) +- ✅ 提供回滚命令(Phase 6+) + +### 2. TOML 格式错误 + +**风险**:用户手写 TOML 导致 Codex 配置损坏 + +**控制措施**: + +- ✅ 保存前强制验证语法 +- ✅ 实时 linting 提示错误 +- ✅ 切换前再次验证最终配置 +- ✅ 写入失败时自动回滚(已有 `.bak` 机制) + +### 3. 并发写入 + +**风险**:多实例同时修改配置 + +**控制措施**: + +- ✅ 使用 RwLock 保护 config 访问 +- ✅ 使用 tauri-plugin-single-instance(已集成) + +### 4. Unified MCP 污染 + +**风险**:历史数据中存在 `apps.codex=true` 的服务器 + +**控制措施**: + +- ✅ **迁移时清空 `mcp.codex.servers`**,阻止 unified 迁移处理 Codex +- ✅ **前端过滤**:Tab1 显示时过滤掉 `apps.codex=true` 的项 +- ✅ **表单限制**:`availableApps` 仅包含 `['claude', 'gemini']` +- ✅ **后端验证**(可选):保存 unified MCP 时检查并拒绝包含 `codex` 的 apps + +--- + +## 测试验证 + +### 单元测试 + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_stdio_server_to_toml() { + let server = McpServer { + name: "test".into(), + server: ServerSpec::Stdio { + command: "npx".into(), + args: Some(vec!["-y".into(), "test".into()]), + env: Some(HashMap::from([ + ("KEY".into(), "value".into()) + ])), + cwd: None, + }, + // ... + }; + + let toml = convert_server_to_toml("test", &server).unwrap(); + + assert!(toml.contains("command = \"npx\"")); + assert!(toml.contains("args = [\"-y\", \"test\"]")); + assert!(toml.contains("KEY = \"value\"")); + } + + #[test] + fn test_toml_validation() { + let valid_toml = "[mcp]\n[[mcp.servers]]\nname = \"test\"\n"; + assert!(validate_toml(valid_toml).is_ok()); + + let invalid_toml = "[mcp\n[[mcp.servers]]\n"; + assert!(validate_toml(invalid_toml).is_err()); + } +} +``` + +### 集成测试场景 + +| 场景 | 步骤 | 预期结果 | +|------|------|----------| +| 新用户首次启动 | 1. 删除 config.json
2. 启动应用
3. 打开 Codex MCP Tab | 显示默认模板 | +| 现有用户迁移 | 1. 使用 v3.6.2 config.json(含 `mcp.codex.servers`)
2. 启动应用
3. 检查 config.json | - `codexMcp.rawToml` 存在且内容正确
- `mcp.codex.servers` 为空对象 `{}`
- `mcp.servers` 不含 `apps.codex` | +| 编辑 Codex MCP | 1. 在 Tab2 编辑 TOML
2. 保存
3. 检查 config.json | `codexMcp.rawToml` 更新 | +| 切换到 Codex | 1. 编辑 Codex MCP
2. 切换到 Codex provider
3. 检查 `~/.codex/config.toml` | MCP 段正确写入,与 raw TOML 一致 | +| 切换到 Claude | 1. 在 Tab1 添加 Claude MCP
2. 切换到 Claude provider
3. 检查 `~/.claude/settings.json` | 仅包含 `apps.claude=true` 的服务器 | +| TOML 语法错误 | 1. 在 Tab2 输入错误 TOML
2. 保存 | 显示错误提示,拒绝保存 | +| Tab1 隔离性 | 1. 打开 Tab1
2. 尝试添加服务器 | - `apps` 选项仅显示 claude/gemini
- 无法选择 codex | +| 历史数据过滤 | 1. 手动在 config.json 添加 `apps.codex=true` 的服务器
2. 打开 Tab1 | 该服务器不在列表中显示 | +| 从 live 导入 | 1. 手动编辑 `~/.codex/config.toml`
2. 点击 Tab2 "导入"
3. 检查编辑器 | 显示导入的 MCP 配置 | + +### 性能测试 + +- [ ] 大型 TOML(>10KB)编辑性能 +- [ ] CodeMirror 初始化时间(<500ms) +- [ ] 配置切换时间(<200ms) + +--- + +## 依赖项 + +### 前端新增 + +```bash +pnpm add @codemirror/lang-toml +``` + +### 后端(已有) + +- `toml = "0.8"` +- `toml_edit = "0.22"` + +--- + +## 时间线 + +| Phase | 工作量 | 累计 | +|-------|--------|------| +| Phase 0: 准备工作 | 0.5 天 | 0.5 天 | +| Phase 1: 后端基础 | 1.5 天 | 2 天 | +| Phase 2: 命令层 | 1 天 | 3 天 | +| Phase 3: 切换逻辑 | 1 天 | 4 天 | +| Phase 4: 前端 API | 0.5 天 | 4.5 天 | +| Phase 5: UI 实现 | 2 天 | 6.5 天 | +| Phase 6: 增强功能(可选)| 1 天 | 7.5 天 | +| Phase 7: 测试与文档 | 1 天 | 8.5 天 | + +**总计**:8.5 天(约 2 周) + +**MVP(最小可行产品)**:Phase 0-5 + Phase 7 = 7 天 + +--- + +## 回滚计划 + +如果重构出现严重问题,执行以下步骤: + +1. **恢复代码**: + ```bash + git checkout main + git branch -D feature/codex-mcp-raw-toml + ``` + +2. **恢复配置**: + ```bash + # 迁移时会自动备份为 config.v3.backup..json + cp ~/.cc-switch/config.v3.backup.*.json ~/.cc-switch/config.json + ``` + +3. **重启应用** + +--- + +## 成功标准 + +- ✅ 现有用户配置无损迁移 +- ✅ Codex MCP 配置保留注释和格式 +- ✅ MCP 面板与 app 切换完全解耦 +- ✅ Claude/Gemini 逻辑零改动 +- ✅ 所有测试用例通过 +- ✅ 文档完整更新 + +--- + +## 附录 + +### 示例配置 + +#### 迁移前(v3.6.2) + +```json +{ + "providers": [...], + "mcp": { + "codex": { + "servers": { + "fetch": { + "id": "fetch", + "name": "Fetch MCP", + "server": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-fetch"] + }, + "enabled": true + } + } + } + } +} +``` + +#### 迁移后(v3.7.0) + +```json +{ + "providers": [...], + "mcp": { + "servers": { + "fetch": { + "id": "fetch", + "name": "Fetch MCP", + "server": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-fetch"] + }, + "apps": { + "claude": true, + "gemini": false + } + } + }, + "codex": { + "servers": {} // ✅ 已清空,但保留结构用于回滚 + } + }, + "codexMcp": { + "rawToml": "[mcp]\n\n[[mcp.servers]]\nname = \"Fetch MCP\"\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-fetch\"]\n" + } +} +``` + +### 相关文档 + +- [Codex 官方 MCP 文档](https://codex.dev/docs/mcp) +- [TOML 规范](https://toml.io/en/) +- [CodeMirror 6 文档](https://codemirror.net/docs/) +- [项目 CLAUDE.md](../CLAUDE.md) + +--- + +**文档版本**:2.0 +**创建时间**:2025-11-18 +**最后更新**:2025-11-18 +**负责人**:Jason Young + +--- + +## 版本历史 + +### v2.0 (2025-11-18) +- ✅ **架构简化**:完全移除 unified MCP 中的 Codex 支持 +- ✅ **单一职责**:`mcp.servers` 仅用于 Claude/Gemini,`codexMcp.rawToml` 仅用于 Codex +- ✅ **迁移增强**:清空 `mcp.codex.servers` 避免重复处理 +- ✅ **UI 隔离**:Tab1 限制 `availableApps`,过滤 Codex 数据 +- ✅ **测试覆盖**:增加 Tab1 隔离性、历史数据过滤等场景 + +### v1.0 (2025-11-18) +- 初始版本