From 511980e3ea9ae2f5e65834036a967cd56f00c95d Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Oct 2025 22:02:56 +0800 Subject: [PATCH] fix(mcp): remove SSE support; keep stdio default when type is omitted - Backend: reject "sse" in validators; accept missing type as stdio; require url only for http (mcp.rs, claude_mcp.rs) - Frontend: McpServer.type narrowed to "stdio" | "http" (optional) (src/types.ts) - UI: avoid undefined in list item details (McpListItem) - Claude-only sync after delete to update ~/.claude.json (commands.rs) Notes: - Ran typecheck and cargo check: both pass - Clippy shows advisory warnings unrelated to this change - Prettier check warns on a few files; limited scope changes kept minimal --- src-tauri/src/app_config.rs | 45 +++++++++++++++++---- src-tauri/src/claude_mcp.rs | 15 ++++--- src-tauri/src/commands.rs | 42 ++++++++++++++------ src-tauri/src/mcp.rs | 63 ++++++++++++++++++++---------- src/components/mcp/McpListItem.tsx | 6 +-- src/components/mcp/McpPanel.tsx | 14 +++---- src/lib/tauri-api.ts | 24 ++++++++---- src/types.ts | 3 +- src/vite-env.d.ts | 11 ++++-- 9 files changed, 152 insertions(+), 71 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 79d0624..db35f05 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -/// MCP 配置:集中存放于 ~/.cc-switch/config.json +/// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器) #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct McpConfig { /// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段) @@ -9,6 +9,24 @@ pub struct McpConfig { pub servers: HashMap, } +/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpRoot { + #[serde(default)] + pub claude: McpConfig, + #[serde(default)] + pub codex: McpConfig, +} + +impl Default for McpRoot { + fn default() -> Self { + Self { + claude: McpConfig::default(), + codex: McpConfig::default(), + } + } +} + use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; use crate::provider::ProviderManager; @@ -46,9 +64,9 @@ pub struct MultiAppConfig { /// 应用管理器(claude/codex) #[serde(flatten)] pub apps: HashMap, - /// MCP 配置 + /// MCP 配置(按客户端分治) #[serde(default)] - pub mcp: McpConfig, + pub mcp: McpRoot, } fn default_version() -> u32 { @@ -64,7 +82,7 @@ impl Default for MultiAppConfig { Self { version: 2, apps, - mcp: McpConfig::default(), + mcp: McpRoot::default(), } } } @@ -95,7 +113,7 @@ impl MultiAppConfig { let config = Self { version: 2, apps, - mcp: McpConfig::default(), + mcp: McpRoot::default(), }; // 迁移前备份旧版(v1)配置文件 @@ -157,8 +175,19 @@ impl MultiAppConfig { } } - /// 获取 MCP 配置(可变引用) - pub fn mcp_mut(&mut self) -> &mut McpConfig { - &mut self.mcp + /// 获取指定客户端的 MCP 配置(不可变引用) + pub fn mcp_for(&self, app: &AppType) -> &McpConfig { + match app { + AppType::Claude => &self.mcp.claude, + AppType::Codex => &self.mcp.codex, + } + } + + /// 获取指定客户端的 MCP 配置(可变引用) + pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig { + match app { + AppType::Claude => &mut self.mcp.claude, + AppType::Codex => &mut self.mcp.codex, + } } } diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index b5be71a..050bafb 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -76,16 +76,15 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { 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()); + let t_opt = spec.get("type").and_then(|x| x.as_str()); + let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理) + let is_http = t_opt.map(|t| t == "http").unwrap_or(false); + if !(is_stdio || is_http) { + return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into()); } // stdio 类型必须有 command - if t == "stdio" { + if is_stdio { let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or(""); if cmd.is_empty() { return Err("stdio 类型的 MCP 服务器缺少 command 字段".into()); @@ -93,7 +92,7 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { } // http 类型必须有 url - if t == "http" { + if is_http { let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); if url.is_empty() { return Err("http 类型的 MCP 服务器缺少 url 字段".into()); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d2f8166..864eb1a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -707,13 +707,14 @@ pub struct McpConfigResponse { /// 获取 MCP 配置(来自 ~/.cc-switch/config.json) #[tauri::command] -pub async fn get_mcp_config(state: State<'_, AppState>) -> Result { +pub async fn get_mcp_config(state: State<'_, AppState>, app: Option) -> 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); + let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); + let servers = crate::mcp::get_servers_snapshot_for(&cfg, &app_ty); Ok(McpConfigResponse { config_path, servers }) } @@ -721,6 +722,7 @@ pub async fn get_mcp_config(state: State<'_, AppState>) -> Result, + app: Option, id: String, spec: serde_json::Value, ) -> Result { @@ -728,7 +730,8 @@ pub async fn upsert_mcp_server_in_config( .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - let changed = crate::mcp::upsert_in_config(&mut cfg, &id, spec)?; + 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)?; drop(cfg); state.save()?; Ok(changed) @@ -736,31 +739,44 @@ pub async fn upsert_mcp_server_in_config( /// 在 config.json 中删除一个 MCP 服务器定义 #[tauri::command] -pub async fn delete_mcp_server_in_config(state: State<'_, AppState>, id: String) -> Result { +pub async fn delete_mcp_server_in_config( + state: State<'_, AppState>, + app: Option, + id: String, +) -> Result { let mut cfg = state .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; - let existed = crate::mcp::delete_in_config(&mut cfg, &id)?; + let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); + let existed = crate::mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?; drop(cfg); state.save()?; - // 同步一次,确保启用项从 ~/.claude.json 中移除 - let cfg2 = state - .config - .lock() - .map_err(|e| format!("获取锁失败: {}", e))?; - crate::mcp::sync_enabled_to_claude(&cfg2)?; + // 若删除的是 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)?; + } Ok(existed) } /// 设置启用状态并同步到 ~/.claude.json #[tauri::command] -pub async fn set_mcp_enabled(state: State<'_, AppState>, id: String, enabled: bool) -> Result { +pub async fn set_mcp_enabled( + state: State<'_, AppState>, + app: Option, + 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)?; + let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); + let changed = crate::mcp::set_enabled_and_sync_for(&mut cfg, &app_ty, &id, enabled)?; drop(cfg); state.save()?; Ok(changed) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 1c2c7d0..db34d68 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -1,24 +1,29 @@ use serde_json::{json, Value}; use std::collections::HashMap; -use crate::app_config::{McpConfig, MultiAppConfig}; +use crate::app_config::{AppType, McpConfig, MultiAppConfig}; -/// 基础校验:type 必须是 stdio/http;对应必填字段存在 +/// 基础校验:允许 stdio/http;或省略 type(视为 stdio)。对应必填字段存在 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()); + let t_opt = spec.get("type").and_then(|x| x.as_str()); + // 支持两种:stdio/http;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致) + let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); + let is_http = t_opt.map(|t| t == "http").unwrap_or(false); + + if !(is_stdio || is_http) { + return Err("MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into()); } - if t == "stdio" { + + if is_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" { + if is_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()); @@ -42,42 +47,52 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { out } -pub fn get_servers_snapshot(config: &MultiAppConfig) -> HashMap { - config.mcp.servers.clone() +pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashMap { + config.mcp_for(app).servers.clone() } -pub fn upsert_in_config(config: &mut MultiAppConfig, id: &str, spec: Value) -> Result { +pub fn upsert_in_config_for( + config: &mut MultiAppConfig, + app: &AppType, + 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() { + if spec.get("enabled").is_none() { // 缺省不设,以便后续 set_enabled 独立控制 } - let servers = &mut config.mcp_mut().servers; + let servers = &mut config.mcp_for_mut(app).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 { +pub fn delete_in_config_for(config: &mut MultiAppConfig, app: &AppType, id: &str) -> Result { if id.trim().is_empty() { return Err("MCP 服务器 ID 不能为空".into()); } - let existed = config.mcp_mut().servers.remove(id).is_some(); + let existed = config.mcp_for_mut(app).servers.remove(id).is_some(); Ok(existed) } /// 设置启用状态并同步到 ~/.claude.json -pub fn set_enabled_and_sync(config: &mut MultiAppConfig, id: &str, enabled: bool) -> Result { +pub fn set_enabled_and_sync_for( + config: &mut MultiAppConfig, + app: &AppType, + 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) { + if let Some(spec) = config.mcp_for_mut(app).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)); @@ -87,14 +102,22 @@ pub fn set_enabled_and_sync(config: &mut MultiAppConfig, id: &str, enabled: bool return Ok(false); } - // 同步启用项到 ~/.claude.json - sync_enabled_to_claude(config)?; + // 同步启用项 + match app { + AppType::Claude => { + // 将启用项投影到 ~/.claude.json + sync_enabled_to_claude(config)?; + } + AppType::Codex => { + // Codex 的 MCP 写入尚未实现(TOML 结构未定),此处先跳过 + } + } 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); + let enabled = collect_enabled_servers(&config.mcp.claude); crate::claude_mcp::set_mcp_servers_map(&enabled) } @@ -115,7 +138,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result 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()); + let entry = config.mcp_for_mut(&AppType::Claude).servers.entry(id.clone()); use std::collections::hash_map::Entry; match entry { Entry::Vacant(vac) => { diff --git a/src/components/mcp/McpListItem.tsx b/src/components/mcp/McpListItem.tsx index 554d9dc..f92c4ec 100644 --- a/src/components/mcp/McpListItem.tsx +++ b/src/components/mcp/McpListItem.tsx @@ -30,9 +30,9 @@ const McpListItem: React.FC = ({ const enabled = server.enabled !== false; // 构建详细信息文本 - const details = [server.type, server.command, ...(server.args || [])].join( - " · ", - ); + const details = ([server.type, server.command, ...(server.args || [])] + .filter(Boolean) as string[]) + .join(" · "); return (
diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 2b32807..6dd3709 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -40,7 +40,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { const reload = async () => { setLoading(true); try { - const cfg = await window.api.getMcpConfig(); + const cfg = await window.api.getMcpConfig("claude"); setStatus({ userConfigPath: cfg.configPath, userConfigExists: true, @@ -59,7 +59,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { await window.api.importMcpFromClaude(); // 读取现有 config.json 内容 - const cfg = await window.api.getMcpConfig(); + const cfg = await window.api.getMcpConfig("claude"); const existing = cfg.servers || {}; // 将预设落库为禁用(若缺失) @@ -70,7 +70,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { enabled: false, source: "preset", } as unknown as McpServer; - await window.api.upsertMcpServerInConfig(p.id, seed); + await window.api.upsertMcpServerInConfig("claude", p.id, seed); } } catch (e) { console.warn("MCP 初始化导入/落库失败(忽略继续)", e); @@ -87,9 +87,9 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { if (!server) { const preset = mcpPresets.find((p) => p.id === id); if (!preset) return; - await window.api.upsertMcpServerInConfig(id, preset.server as McpServer); + await window.api.upsertMcpServerInConfig("claude", id, preset.server as McpServer); } - await window.api.setMcpEnabled(id, enabled); + await window.api.setMcpEnabled("claude", id, enabled); await reload(); onNotify?.( enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), @@ -123,7 +123,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { message: t("mcp.confirm.deleteMessage", { id }), onConfirm: async () => { try { - await window.api.deleteMcpServerInConfig(id); + await window.api.deleteMcpServerInConfig("claude", id); await reload(); setConfirmDialog(null); onNotify?.(t("mcp.msg.deleted"), "success", 1500); @@ -141,7 +141,7 @@ const McpPanel: React.FC = ({ onClose, onNotify }) => { const handleSave = async (id: string, server: McpServer) => { try { - await window.api.upsertMcpServerInConfig(id, server); + await window.api.upsertMcpServerInConfig("claude", id, server); await reload(); setIsFormOpen(false); setEditingId(null); diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 082040b..f93cc5f 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -339,10 +339,10 @@ export const tauriAPI = { } }, - // 新:config.json 为 SSOT 的 MCP API - getMcpConfig: async (): Promise => { + // 新:config.json 为 SSOT 的 MCP API(按客户端) + getMcpConfig: async (app: AppType = "claude"): Promise => { try { - return await invoke("get_mcp_config"); + return await invoke("get_mcp_config", { app }); } catch (error) { console.error("获取 MCP 配置失败:", error); throw error; @@ -350,29 +350,37 @@ export const tauriAPI = { }, upsertMcpServerInConfig: async ( + app: AppType = "claude", id: string, spec: McpServer | Record, ): Promise => { try { - return await invoke("upsert_mcp_server_in_config", { id, spec }); + return await invoke("upsert_mcp_server_in_config", { app, id, spec }); } catch (error) { console.error("写入 MCP(config.json)失败:", error); throw error; } }, - deleteMcpServerInConfig: async (id: string): Promise => { + deleteMcpServerInConfig: async ( + app: AppType = "claude", + id: string, + ): Promise => { try { - return await invoke("delete_mcp_server_in_config", { id }); + return await invoke("delete_mcp_server_in_config", { app, id }); } catch (error) { console.error("删除 MCP(config.json)失败:", error); throw error; } }, - setMcpEnabled: async (id: string, enabled: boolean): Promise => { + setMcpEnabled: async ( + app: AppType = "claude", + id: string, + enabled: boolean, + ): Promise => { try { - return await invoke("set_mcp_enabled", { id, enabled }); + return await invoke("set_mcp_enabled", { app, id, enabled }); } catch (error) { console.error("设置 MCP 启用状态失败:", error); throw error; diff --git a/src/types.ts b/src/types.ts index 16e1539..247234e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,7 +55,8 @@ export interface Settings { // MCP 服务器定义(宽松:允许扩展字段) export interface McpServer { - type: "stdio" | "http"; + // 可选:社区常见 .mcp.json 中 stdio 配置可不写 type + type?: "stdio" | "http"; // stdio 字段 command?: string; args?: string[]; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8b247dc..d87083c 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -71,13 +71,18 @@ declare global { deleteClaudeMcpServer: (id: string) => Promise; validateMcpCommand: (cmd: string) => Promise; // 新:config.json 为 SSOT 的 MCP API - getMcpConfig: () => Promise; + getMcpConfig: (app?: AppType) => Promise; upsertMcpServerInConfig: ( + app: AppType | undefined, id: string, spec: Record, ) => Promise; - deleteMcpServerInConfig: (id: string) => Promise; - setMcpEnabled: (id: string, enabled: boolean) => Promise; + deleteMcpServerInConfig: (app: AppType | undefined, id: string) => Promise; + setMcpEnabled: ( + app: AppType | undefined, + id: string, + enabled: boolean, + ) => Promise; syncEnabledMcpToClaude: () => Promise; importMcpFromClaude: () => Promise; testApiEndpoints: (