From f6bf8611cd16cc653243e298779f5e02c3149119 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Oct 2025 21:08:42 +0800 Subject: [PATCH] feat(mcp): use project config as SSOT and sync enabled servers to ~/.claude.json - Add McpConfig to MultiAppConfig and persist MCP servers in ~/.cc-switch/config.json - Add Tauri commands: get_mcp_config, upsert_mcp_server_in_config, delete_mcp_server_in_config, set_mcp_enabled, sync_enabled_mcp_to_claude, import_mcp_from_claude - Only write enabled MCPs to ~/.claude.json (mcpServers) and strip UI-only fields (enabled/source) - Frontend: update API wrappers and MCP panel to read/write via config.json; seed presets on first open; import from ~/.claude.json - Fix warnings (remove unused mut, dead code) - Verified with cargo check and pnpm typecheck --- src-tauri/src/app_config.rs | 29 ++++++- src-tauri/src/claude_mcp.rs | 31 ++++++- src-tauri/src/commands.rs | 99 ++++++++++++++++++++++- src-tauri/src/lib.rs | 8 ++ src-tauri/src/mcp.rs | 139 ++++++++++++++++++++++++++++++++ src/components/mcp/McpPanel.tsx | 65 +++++++++------ src/lib/tauri-api.ts | 59 ++++++++++++++ src/types.ts | 6 ++ src/vite-env.d.ts | 12 ++- 9 files changed, 417 insertions(+), 31 deletions(-) create mode 100644 src-tauri/src/mcp.rs diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index c6f1cc6..79d0624 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -1,6 +1,14 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// MCP 配置:集中存放于 ~/.cc-switch/config.json +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct McpConfig { + /// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段) + #[serde(default)] + pub servers: HashMap, +} + use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; use crate::provider::ProviderManager; @@ -35,8 +43,12 @@ impl From<&str> for AppType { pub struct MultiAppConfig { #[serde(default = "default_version")] pub version: u32, + /// 应用管理器(claude/codex) #[serde(flatten)] pub apps: HashMap, + /// MCP 配置 + #[serde(default)] + pub mcp: McpConfig, } fn default_version() -> u32 { @@ -49,7 +61,11 @@ impl Default for MultiAppConfig { apps.insert("claude".to_string(), ProviderManager::default()); apps.insert("codex".to_string(), ProviderManager::default()); - Self { version: 2, apps } + Self { + version: 2, + apps, + mcp: McpConfig::default(), + } } } @@ -76,7 +92,11 @@ impl MultiAppConfig { apps.insert("claude".to_string(), v1_config); apps.insert("codex".to_string(), ProviderManager::default()); - let config = Self { version: 2, apps }; + let config = Self { + version: 2, + apps, + mcp: McpConfig::default(), + }; // 迁移前备份旧版(v1)配置文件 let backup_dir = get_app_config_dir(); @@ -136,4 +156,9 @@ impl MultiAppConfig { .insert(app.as_str().to_string(), ProviderManager::default()); } } + + /// 获取 MCP 配置(可变引用) + pub fn mcp_mut(&mut self) -> &mut McpConfig { + &mut self.mcp + } } diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 4998952..b5be71a 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -183,3 +183,32 @@ pub fn validate_command_in_path(cmd: &str) -> Result { } Ok(false) } + +/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段 +/// 仅覆盖 mcpServers,其他字段保持不变 +pub fn set_mcp_servers_map(servers: &std::collections::HashMap) -> Result<(), String> { + let path = user_config_path(); + let mut root = if path.exists() { read_json_value(&path)? } else { serde_json::json!({}) }; + + // 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范 + let mut out: Map = Map::new(); + for (id, spec) in servers.iter() { + if let Some(mut obj) = spec.as_object().cloned() { + obj.remove("enabled"); + obj.remove("source"); + out.insert(id.clone(), Value::Object(obj)); + } else { + return Err(format!("MCP 服务器 '{}' 不是对象", id)); + } + } + + { + let obj = root + .as_object_mut() + .ok_or_else(|| "~/.claude.json 根必须是对象".to_string())?; + obj.insert("mcpServers".into(), Value::Object(out)); + } + + write_json_value(&path, &root)?; + Ok(()) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b7d1a3b..d2f8166 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,11 +8,11 @@ use tauri_plugin_opener::OpenerExt; use crate::app_config::AppType; use crate::claude_plugin; use crate::claude_mcp; +use crate::store::AppState; use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; use crate::provider::{Provider, ProviderMeta}; use crate::speedtest; -use crate::store::AppState; fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> { match app_type { @@ -695,6 +695,103 @@ pub async fn validate_mcp_command(cmd: String) -> Result { claude_mcp::validate_command_in_path(&cmd) } +// ===================== +// 新:集中以 config.json 为 SSOT 的 MCP 配置命令 +// ===================== + +#[derive(serde::Serialize)] +pub struct McpConfigResponse { + pub config_path: String, + pub servers: std::collections::HashMap, +} + +/// 获取 MCP 配置(来自 ~/.cc-switch/config.json) +#[tauri::command] +pub async fn get_mcp_config(state: State<'_, AppState>) -> Result { + let config_path = crate::config::get_app_config_path().to_string_lossy().to_string(); + let cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let servers = crate::mcp::get_servers_snapshot(&cfg); + Ok(McpConfigResponse { config_path, servers }) +} + +/// 在 config.json 中新增或更新一个 MCP 服务器定义 +#[tauri::command] +pub async fn upsert_mcp_server_in_config( + state: State<'_, AppState>, + id: String, + spec: serde_json::Value, +) -> Result { + let mut cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let changed = crate::mcp::upsert_in_config(&mut cfg, &id, spec)?; + drop(cfg); + state.save()?; + Ok(changed) +} + +/// 在 config.json 中删除一个 MCP 服务器定义 +#[tauri::command] +pub async fn delete_mcp_server_in_config(state: State<'_, AppState>, id: String) -> Result { + let mut cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let existed = crate::mcp::delete_in_config(&mut cfg, &id)?; + drop(cfg); + state.save()?; + // 同步一次,确保启用项从 ~/.claude.json 中移除 + let cfg2 = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + crate::mcp::sync_enabled_to_claude(&cfg2)?; + Ok(existed) +} + +/// 设置启用状态并同步到 ~/.claude.json +#[tauri::command] +pub async fn set_mcp_enabled(state: State<'_, AppState>, id: String, enabled: bool) -> Result { + let mut cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let changed = crate::mcp::set_enabled_and_sync(&mut cfg, &id, enabled)?; + drop(cfg); + state.save()?; + Ok(changed) +} + +/// 手动同步:将启用的 MCP 投影到 ~/.claude.json(不更改 config.json) +#[tauri::command] +pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result { + let cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + crate::mcp::sync_enabled_to_claude(&cfg)?; + Ok(true) +} + +/// 从 ~/.claude.json 导入 MCP 定义到 config.json,返回变更数量 +#[tauri::command] +pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result { + let mut cfg = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + let changed = crate::mcp::import_from_claude(&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 ca6d137..76afbc2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod app_config; mod claude_plugin; mod claude_mcp; +mod mcp; mod codex_config; mod commands; mod config; @@ -428,6 +429,13 @@ pub fn run() { commands::upsert_claude_mcp_server, commands::delete_claude_mcp_server, commands::validate_mcp_command, + // New MCP via config.json (SSOT) + commands::get_mcp_config, + commands::upsert_mcp_server_in_config, + commands::delete_mcp_server_in_config, + commands::set_mcp_enabled, + commands::sync_enabled_mcp_to_claude, + commands::import_mcp_from_claude, // 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 new file mode 100644 index 0000000..1c2c7d0 --- /dev/null +++ b/src-tauri/src/mcp.rs @@ -0,0 +1,139 @@ +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::app_config::{McpConfig, MultiAppConfig}; + +/// 基础校验:type 必须是 stdio/http;对应必填字段存在 +fn validate_mcp_spec(spec: &Value) -> Result<(), String> { + if !spec.is_object() { + return Err("MCP 服务器定义必须为 JSON 对象".into()); + } + let t = spec.get("type").and_then(|x| x.as_str()).unwrap_or(""); + if t != "stdio" && t != "http" { + return Err("MCP 服务器 type 必须是 'stdio' 或 'http'".into()); + } + if t == "stdio" { + let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); + if cmd.trim().is_empty() { + return Err("stdio 类型的 MCP 服务器缺少 command 字段".into()); + } + } + if t == "http" { + let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); + if url.trim().is_empty() { + return Err("http 类型的 MCP 服务器缺少 url 字段".into()); + } + } + Ok(()) +} + +/// 返回已启用的 MCP 服务器(过滤 enabled==true) +fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { + let mut out = HashMap::new(); + for (id, spec) in cfg.servers.iter() { + let enabled = spec + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if enabled { + out.insert(id.clone(), spec.clone()); + } + } + out +} + +pub fn get_servers_snapshot(config: &MultiAppConfig) -> HashMap { + config.mcp.servers.clone() +} + +pub fn upsert_in_config(config: &mut MultiAppConfig, id: &str, spec: Value) -> Result { + if id.trim().is_empty() { + return Err("MCP 服务器 ID 不能为空".into()); + } + validate_mcp_spec(&spec)?; + + // 默认 enabled 不强制设值;若字段不存在则保持不变(或 UI 决定) + if !spec.get("enabled").is_some() { + // 缺省不设,以便后续 set_enabled 独立控制 + } + + let servers = &mut config.mcp_mut().servers; + let before = servers.get(id).cloned(); + servers.insert(id.to_string(), spec); + + Ok(before.is_none()) +} + +pub fn delete_in_config(config: &mut MultiAppConfig, id: &str) -> Result { + if id.trim().is_empty() { + return Err("MCP 服务器 ID 不能为空".into()); + } + let existed = config.mcp_mut().servers.remove(id).is_some(); + Ok(existed) +} + +/// 设置启用状态并同步到 ~/.claude.json +pub fn set_enabled_and_sync(config: &mut MultiAppConfig, id: &str, enabled: bool) -> Result { + if id.trim().is_empty() { + return Err("MCP 服务器 ID 不能为空".into()); + } + if let Some(spec) = config.mcp_mut().servers.get_mut(id) { + // 写入 enabled 字段 + let mut obj = spec.as_object().cloned().ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?; + obj.insert("enabled".into(), json!(enabled)); + *spec = Value::Object(obj); + } else { + // 若不存在则直接返回 false + return Ok(false); + } + + // 同步启用项到 ~/.claude.json + sync_enabled_to_claude(config)?; + Ok(true) +} + +/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json +pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> { + let enabled = collect_enabled_servers(&config.mcp); + crate::claude_mcp::set_mcp_servers_map(&enabled) +} + +/// 从 ~/.claude.json 导入 mcpServers 到 config.json(设为 enabled=true)。 +/// 已存在的项仅强制 enabled=true,不覆盖其他字段。 +pub fn import_from_claude(config: &mut MultiAppConfig) -> Result { + let text_opt = crate::claude_mcp::read_mcp_json()?; + let Some(text) = text_opt else { return Ok(0) }; + let v: Value = serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?; + let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { return Ok(0) }; + + let mut changed = 0usize; + for (id, spec) in map.iter() { + // 校验目标 spec + validate_mcp_spec(spec)?; + + // 规范化为对象 + let mut obj = spec.as_object().cloned().ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?; + obj.insert("enabled".into(), json!(true)); + + let entry = config.mcp_mut().servers.entry(id.clone()); + use std::collections::hash_map::Entry; + match entry { + Entry::Vacant(vac) => { + vac.insert(Value::Object(obj)); + changed += 1; + } + Entry::Occupied(mut occ) => { + // 只确保 enabled=true;不覆盖其他字段 + 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(Value::Object(existing)); + changed += 1; + } + } + } + } + } + Ok(changed) +} diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 03c3767..2b32807 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -40,43 +40,56 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { const reload = async () => { setLoading(true); try { - const s = await window.api.getClaudeMcpStatus(); - setStatus(s); - const text = await window.api.readClaudeMcpConfig(); - if (text) { - try { - const obj = JSON.parse(text); - const list = (obj?.mcpServers || {}) as Record; - setServers(list); - } catch (e) { - console.error("Failed to parse mcp.json", e); - setServers({}); - } - } else { - setServers({}); - } + const cfg = await window.api.getMcpConfig(); + setStatus({ + userConfigPath: cfg.configPath, + userConfigExists: true, + serverCount: Object.keys(cfg.servers || {}).length, + }); + setServers(cfg.servers || {}); } finally { setLoading(false); } }; useEffect(() => { - reload(); + const setup = async () => { + try { + // 先从 ~/.claude.json 导入已存在的 MCP(设为 enabled=true) + await window.api.importMcpFromClaude(); + + // 读取现有 config.json 内容 + const cfg = await window.api.getMcpConfig(); + const existing = cfg.servers || {}; + + // 将预设落库为禁用(若缺失) + const missing = mcpPresets.filter((p) => !existing[p.id]); + for (const p of missing) { + const seed: McpServer = { + ...(p.server as McpServer), + enabled: false, + source: "preset", + } as unknown as McpServer; + await window.api.upsertMcpServerInConfig(p.id, seed); + } + } catch (e) { + console.warn("MCP 初始化导入/落库失败(忽略继续)", e); + } finally { + await reload(); + } + }; + setup(); }, []); const handleToggle = async (id: string, enabled: boolean) => { try { const server = servers[id]; - let updatedServer: McpServer | null = null; - if (server) { - updatedServer = { ...server, enabled }; - } else { + if (!server) { const preset = mcpPresets.find((p) => p.id === id); - if (!preset) return; // 既不是已安装项也不是预设,忽略 - updatedServer = { ...(preset.server as McpServer), enabled }; + if (!preset) return; + await window.api.upsertMcpServerInConfig(id, preset.server as McpServer); } - - await window.api.upsertClaudeMcpServer(id, updatedServer as McpServer); + await window.api.setMcpEnabled(id, enabled); await reload(); onNotify?.( enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), @@ -110,7 +123,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { message: t("mcp.confirm.deleteMessage", { id }), onConfirm: async () => { try { - await window.api.deleteClaudeMcpServer(id); + await window.api.deleteMcpServerInConfig(id); await reload(); setConfirmDialog(null); onNotify?.(t("mcp.msg.deleted"), "success", 1500); @@ -128,7 +141,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { const handleSave = async (id: string, server: McpServer) => { try { - await window.api.upsertClaudeMcpServer(id, server); + await window.api.upsertMcpServerInConfig(id, server); await reload(); setIsFormOpen(false); setEditingId(null); diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index ae15b38..082040b 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -6,6 +6,7 @@ import { CustomEndpoint, McpStatus, McpServer, + McpConfigResponse, } from "../types"; // 应用类型 @@ -338,6 +339,64 @@ export const tauriAPI = { } }, + // 新:config.json 为 SSOT 的 MCP API + getMcpConfig: async (): Promise => { + try { + return await invoke("get_mcp_config"); + } catch (error) { + console.error("获取 MCP 配置失败:", error); + throw error; + } + }, + + upsertMcpServerInConfig: async ( + id: string, + spec: McpServer | Record, + ): Promise => { + try { + return await invoke("upsert_mcp_server_in_config", { id, spec }); + } catch (error) { + console.error("写入 MCP(config.json)失败:", error); + throw error; + } + }, + + deleteMcpServerInConfig: async (id: string): Promise => { + try { + return await invoke("delete_mcp_server_in_config", { id }); + } catch (error) { + console.error("删除 MCP(config.json)失败:", error); + throw error; + } + }, + + setMcpEnabled: async (id: string, enabled: boolean): Promise => { + try { + return await invoke("set_mcp_enabled", { id, enabled }); + } catch (error) { + console.error("设置 MCP 启用状态失败:", error); + throw error; + } + }, + + syncEnabledMcpToClaude: async (): Promise => { + try { + return await invoke("sync_enabled_mcp_to_claude"); + } catch (error) { + console.error("同步启用 MCP 到 .claude.json 失败:", error); + throw error; + } + }, + + importMcpFromClaude: async (): Promise => { + try { + return await invoke("import_mcp_from_claude"); + } catch (error) { + console.error("从 ~/.claude.json 导入 MCP 失败:", error); + throw error; + } + }, + // ours: 第三方/自定义供应商——测速与端点管理 // 第三方/自定义供应商:批量测试端点延迟 testApiEndpoints: async ( diff --git a/src/types.ts b/src/types.ts index 1552f7a..16e1539 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,3 +75,9 @@ export interface McpStatus { userConfigExists: boolean; serverCount: number; } + +// 新:来自 config.json 的 MCP 列表响应 +export interface McpConfigResponse { + configPath: string; + servers: Record; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f033595..8b247dc 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,6 +1,6 @@ /// -import { Provider, Settings, CustomEndpoint, McpStatus } from "./types"; +import { Provider, Settings, CustomEndpoint, McpStatus, McpConfigResponse } from "./types"; import { AppType } from "./lib/tauri-api"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -70,6 +70,16 @@ declare global { ) => Promise; deleteClaudeMcpServer: (id: string) => Promise; validateMcpCommand: (cmd: string) => Promise; + // 新:config.json 为 SSOT 的 MCP API + getMcpConfig: () => Promise; + upsertMcpServerInConfig: ( + id: string, + spec: Record, + ) => Promise; + deleteMcpServerInConfig: (id: string) => Promise; + setMcpEnabled: (id: string, enabled: boolean) => Promise; + syncEnabledMcpToClaude: () => Promise; + importMcpFromClaude: () => Promise; testApiEndpoints: ( urls: string[], options?: { timeoutSecs?: number },