refactor(mcp): complete v3.7.0 cleanup - remove legacy code and warnings

This commit finalizes the v3.7.0 unified MCP architecture migration by
removing all deprecated code paths and eliminating compiler warnings.

Frontend Changes (~950 lines removed):
- Remove deprecated components: McpPanel, McpListItem, McpToggle
- Remove deprecated hook: useMcpActions
- Remove unused API methods: importFrom*, syncEnabledTo*, syncAllServers
- Simplify McpFormModal by removing dual-mode logic (unified/legacy)
- Remove syncOtherSide checkbox and conflict detection
- Clean up unused imports and state variables
- Delete associated test files

Backend Changes (~400 lines cleaned):
- Remove unused Tauri commands: import_mcp_from_*, sync_enabled_mcp_to_*
- Delete unused Gemini MCP functions: get_mcp_status, upsert/delete_mcp_server
- Add #[allow(deprecated)] to compatibility layer commands
- Add #[allow(dead_code)] to legacy helper functions for future migration
- Simplify boolean expression in mcp.rs per Clippy suggestion

API Deprecation:
- Mark legacy APIs with @deprecated JSDoc (getConfig, upsertServerInConfig, etc.)
- Preserve backward compatibility for v3.x, planned removal in v4.0

Verification:
-  Zero TypeScript errors (pnpm typecheck)
-  Zero Clippy warnings (cargo clippy)
-  All code formatted (prettier + cargo fmt)
-  Builds successfully

Total cleanup: ~1,350 lines of code removed/marked
Breaking changes: None (all legacy APIs still functional)
This commit is contained in:
Jason
2025-11-14 22:43:25 +08:00
parent fafca841cb
commit 2f18d6ec00
19 changed files with 100 additions and 1420 deletions

View File

@@ -242,11 +242,7 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)

View File

@@ -141,7 +141,10 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
let guard = state.config.read().map_err(|e| format!("读取配置锁失败: {e}"))?;
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.claude_common_config_snippet.clone())
}

View File

@@ -50,6 +50,7 @@ pub struct McpConfigResponse {
use std::str::FromStr;
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn get_mcp_config(
state: State<'_, AppState>,
app: String,
@@ -101,7 +102,8 @@ pub async fn upsert_mcp_server_in_config(
apps.set_enabled_for(&app_ty, true);
// 尝试从 spec 中提取 name否则使用 id
let name = spec.get("name")
let name = spec
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
@@ -142,6 +144,7 @@ pub async fn delete_mcp_server_in_config(
/// 设置启用状态并同步到客户端配置
#[tauri::command]
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
pub async fn set_mcp_enabled(
state: State<'_, AppState>,
app: String,
@@ -152,48 +155,6 @@ pub async fn set_mcp_enabled(
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Claude)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
#[tauri::command]
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Codex)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_claude(&state).map_err(|e| e.to_string())
}
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
#[tauri::command]
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_codex(&state).map_err(|e| e.to_string())
}
/// 手动同步:将启用的 MCP 投影到 ~/.gemini/settings.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_gemini(state: State<'_, AppState>) -> Result<bool, String> {
McpService::sync_enabled(&state, AppType::Gemini)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 从 ~/.gemini/settings.json 导入 MCP 定义到 config.json
#[tauri::command]
pub async fn import_mcp_from_gemini(state: State<'_, AppState>) -> Result<usize, String> {
McpService::import_from_gemini(&state).map_err(|e| e.to_string())
}
// ============================================================================
// v3.7.0 新增:统一 MCP 管理命令
// ============================================================================
@@ -219,10 +180,7 @@ pub async fn upsert_mcp_server(
/// 删除 MCP 服务器
#[tauri::command]
pub async fn delete_mcp_server(
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
}
@@ -237,9 +195,3 @@ pub async fn toggle_mcp_app(
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}
/// 手动同步所有启用的 MCP 服务器到对应的应用
#[tauri::command]
pub async fn sync_all_mcp_servers(state: State<'_, AppState>) -> Result<(), String> {
McpService::sync_all_enabled(&state).map_err(|e| e.to_string())
}

View File

@@ -38,24 +38,6 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
atomic_write(path, json.as_bytes())
}
/// 获取 Gemini MCP 状态
pub fn get_mcp_status() -> Result<McpStatus, AppError> {
let path = user_config_path();
let (exists, count) = if path.exists() {
let v = read_json_value(&path)?;
let servers = v.get("mcpServers").and_then(|x| x.as_object());
(true, servers.map(|m| m.len()).unwrap_or(0))
} else {
(false, 0)
};
Ok(McpStatus {
user_config_path: path.to_string_lossy().to_string(),
user_config_exists: exists,
server_count: count,
})
}
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
let path = user_config_path();
@@ -66,96 +48,7 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content))
}
/// 在 Gemini settings.json 中新增或更新一个 MCP 服务器
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
// 基础字段校验(尽量宽松)
if !spec.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器定义必须为 JSON 对象".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(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio".into(),
));
}
// stdio 类型必须有 command
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.is_empty() {
return Err(AppError::McpValidation(
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
));
}
}
// http 类型必须有 url
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
// 确保 mcpServers 对象存在
{
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("settings.json 根必须是对象".into()))?;
if !obj.contains_key("mcpServers") {
obj.insert("mcpServers".into(), serde_json::json!({}));
}
}
let before = root.clone();
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
servers.insert(id.to_string(), spec);
}
if before == root && path.exists() {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 Gemini settings.json 中的一个 MCP 服务器
pub fn delete_mcp_server(id: &str) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
return Ok(false);
};
let existed = servers.remove(id).is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
@@ -168,11 +61,7 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(servers)

View File

@@ -6,8 +6,8 @@ mod codex_config;
mod commands;
mod config;
mod error;
mod gemini_mcp;
mod gemini_config; // 新增
mod gemini_mcp;
mod init_status;
mod mcp;
mod prompt;
@@ -541,18 +541,11 @@ pub fn run() {
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
commands::sync_enabled_mcp_to_claude,
commands::sync_enabled_mcp_to_codex,
commands::sync_enabled_mcp_to_gemini,
commands::import_mcp_from_claude,
commands::import_mcp_from_codex,
commands::import_mcp_from_gemini,
// v3.7.0: Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
commands::sync_all_mcp_servers,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,

View File

@@ -41,6 +41,7 @@ fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
Ok(())
}
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
let obj = entry
.as_object()
@@ -210,6 +211,7 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
out
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API保留用于未来可能的迁移
pub fn get_servers_snapshot_for(
config: &mut MultiAppConfig,
app: &AppType,
@@ -235,6 +237,7 @@ pub fn get_servers_snapshot_for(
(snapshot, normalized)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API保留用于未来可能的迁移
pub fn upsert_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
@@ -273,6 +276,7 @@ pub fn upsert_in_config_for(
Ok(before.is_none())
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API保留用于未来可能的迁移
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
@@ -286,6 +290,7 @@ pub fn delete_in_config_for(
Ok(existed)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API保留用于未来可能的迁移
/// 设置启用状态(不执行落盘或文件同步)
pub fn set_enabled_flag_for(
config: &mut MultiAppConfig,
@@ -900,8 +905,8 @@ pub fn sync_single_server_to_codex(
let config_path = crate::codex_config::get_codex_config_path();
let mut doc = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.map_err(|e| AppError::io(&config_path, e))?;
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
@@ -915,10 +920,10 @@ pub fn sync_single_server_to_codex(
}
// 确保 [mcp.servers] 子表存在
if !doc["mcp"]
if doc["mcp"]
.as_table()
.and_then(|t| t.get("servers"))
.is_some()
.is_none()
{
doc["mcp"]["servers"] = toml_edit::table();
}
@@ -929,8 +934,7 @@ pub fn sync_single_server_to_codex(
doc["mcp"]["servers"][id] = Item::Table(toml_table);
// 写回文件
std::fs::write(&config_path, doc.to_string())
.map_err(|e| AppError::io(&config_path, e))?;
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
@@ -943,8 +947,8 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
return Ok(()); // 文件不存在,无需删除
}
let content = std::fs::read_to_string(&config_path)
.map_err(|e| AppError::io(&config_path, e))?;
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
@@ -958,8 +962,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
}
// 写回文件
std::fs::write(&config_path, doc.to_string())
.map_err(|e| AppError::io(&config_path, e))?;
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}

View File

@@ -163,11 +163,7 @@ impl McpService {
Ok(())
}
fn remove_server_from_app(
_state: &AppState,
id: &str,
app: &AppType,
) -> Result<(), AppError> {
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => mcp::remove_server_from_claude(id)?,
AppType::Codex => mcp::remove_server_from_codex(id)?,
@@ -236,7 +232,10 @@ impl McpService {
}
/// [已废弃] 从 Claude 导入 MCP兼容旧 API
#[deprecated(since = "3.7.0", note = "Import will be handled differently in unified structure")]
#[deprecated(
since = "3.7.0",
note = "Import will be handled differently in unified structure"
)]
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_claude(&mut cfg)?;
@@ -246,7 +245,10 @@ impl McpService {
}
/// [已废弃] 从 Codex 导入 MCP兼容旧 API
#[deprecated(since = "3.7.0", note = "Import will be handled differently in unified structure")]
#[deprecated(
since = "3.7.0",
note = "Import will be handled differently in unified structure"
)]
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_codex(&mut cfg)?;
@@ -256,7 +258,10 @@ impl McpService {
}
/// [已废弃] 从 Gemini 导入 MCP兼容旧 API
#[deprecated(since = "3.7.0", note = "Import will be handled differently in unified structure")]
#[deprecated(
since = "3.7.0",
note = "Import will be handled differently in unified structure"
)]
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_gemini(&mut cfg)?;

View File

@@ -302,10 +302,7 @@ function App() {
appId={activeApp}
/>
<UnifiedMcpPanel
open={isMcpOpen}
onOpenChange={setIsMcpOpen}
/>
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
</div>
);
}

View File

@@ -1,14 +1,7 @@
import React, { useMemo, useState, useEffect } from "react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Save,
Plus,
AlertCircle,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,7 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { mcpApi, type AppId } from "@/lib/api";
import type { AppId } from "@/lib/api/types";
import { McpServer, McpServerSpec } from "@/types";
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
import McpWizardModal from "./McpWizardModal";
@@ -40,14 +33,9 @@ interface McpFormModalProps {
appId: AppId;
editingId?: string;
initialData?: McpServer;
onSave: (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<void>;
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
onClose: () => void;
existingIds?: string[];
unified?: boolean; // 统一模式:使用 useUpsertMcpServer 而非传统回调
}
/**
@@ -62,13 +50,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onSave,
onClose,
existingIds = [],
unified = false,
}) => {
const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
useMcpValidation();
// 统一模式下使用 mutation
const upsertMutation = useUpsertMcpServer();
const [formId, setFormId] = useState(
@@ -112,38 +98,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
const [syncOtherSide, setSyncOtherSide] = useState(false);
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
// 判断是否使用 TOML 格式
const useToml = appId === "codex";
const syncTargetLabel = appId === "claude" ? "Codex" : "Claude";
const otherAppType: AppId = appId === "claude" ? "codex" : "claude";
const syncCheckboxId = useMemo(() => `sync-other-side-${appId}`, [appId]);
// 检测另一侧是否有同名 MCP
useEffect(() => {
const checkOtherSide = async () => {
const currentId = formId.trim();
if (!currentId) {
setOtherSideHasConflict(false);
return;
}
try {
const otherConfig = await mcpApi.getConfig(otherAppType);
const hasConflict = Object.keys(otherConfig.servers || {}).includes(
currentId,
);
setOtherSideHasConflict(hasConflict);
} catch (error) {
console.error("检查另一侧 MCP 配置失败:", error);
setOtherSideHasConflict(false);
}
};
checkOtherSide();
}, [formId, otherAppType]);
const wizardInitialSpec = useMemo(() => {
const fallback = initialData?.server;
@@ -377,22 +334,13 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
name: finalName,
server: serverSpec,
// 确保 apps 字段始终存在v3.7.0 新架构必需)
apps: initialData?.apps || { claude: false, codex: false, gemini: false },
apps: initialData?.apps || {
claude: false,
codex: false,
gemini: false,
},
};
// 统一模式下无需再初始化 apps上面已经处理
// 传统模式需要设置 enabled 字段
if (!unified) {
// 传统模式:新增 MCP 时默认启用enabled=true
// 编辑模式下保留原有的 enabled 状态
if (initialData?.enabled !== undefined) {
entry.enabled = initialData.enabled;
} else {
// 新增模式:默认启用
entry.enabled = true;
}
}
const descriptionTrimmed = formDescription.trim();
if (descriptionTrimmed) {
entry.description = descriptionTrimmed;
@@ -424,16 +372,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
delete entry.tags;
}
// 显式等待保存流程
if (unified) {
// 统一模式:调用 useUpsertMcpServer mutation
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
onClose();
} else {
// 传统模式:调用父组件回调
await onSave(trimmedId, entry, { syncOtherSide });
}
// 保存到统一配置
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
await onSave(); // 通知父组件关闭表单
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -646,58 +588,24 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
{/* Footer */}
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
{/* 双端同步选项 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<input
id={syncCheckboxId}
type="checkbox"
className="h-4 w-4 rounded border-border-default text-emerald-600 focus:ring-emerald-500 dark:bg-gray-800"
checked={syncOtherSide}
onChange={(event) => setSyncOtherSide(event.target.checked)}
/>
<label
htmlFor={syncCheckboxId}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
title={t("mcp.form.syncOtherSideHint", {
target: syncTargetLabel,
})}
>
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
</label>
</div>
{syncOtherSide && otherSideHasConflict && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<AlertTriangle size={14} />
<span className="text-xs font-medium">
{t("mcp.form.willOverwriteWarning", {
target: syncTargetLabel,
})}
</span>
</div>
)}
</div>
<DialogFooter className="flex justify-end gap-3 pt-4">
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
variant="mcp"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
</div>
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
variant="mcp"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,122 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Edit3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { McpServer } from "@/types";
import { mcpPresets } from "@/config/mcpPresets";
import McpToggle from "./McpToggle";
interface McpListItemProps {
id: string;
server: McpServer;
onToggle: (id: string, enabled: boolean) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
/**
* MCP 列表项组件
* 每个 MCP 占一行,左侧是 Toggle 开关,中间是名称和详细信息,右侧是编辑和删除按钮
*/
const McpListItem: React.FC<McpListItemProps> = ({
id,
server,
onToggle,
onEdit,
onDelete,
}) => {
const { t } = useTranslation();
// 仅当显式为 true 时视为启用;避免 undefined 被误判为启用
const enabled = server.enabled === true;
const name = server.name || id;
// 只显示 description没有则留空
const description = server.description || "";
// 匹配预设元信息(用于展示文档链接等)
const meta = mcpPresets.find((p) => p.id === id);
const docsUrl = server.docs || meta?.docs;
const homepageUrl = server.homepage || meta?.homepage;
const tags = server.tags || meta?.tags;
const openDocs = async () => {
const url = docsUrl || homepageUrl;
if (!url) return;
try {
await settingsApi.openExternal(url);
} catch {
// ignore
}
};
return (
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
<div className="flex items-center gap-4 h-full">
{/* 左侧Toggle 开关 */}
<div className="flex-shrink-0">
<McpToggle
enabled={enabled}
onChange={(newEnabled) => onToggle(id, newEnabled)}
/>
</div>
{/* 中间:名称和详细信息 */}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{name}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
{/* 预设标记已移除 */}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
</div>
);
};
export default McpListItem;

View File

@@ -1,236 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Server, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { type AppId } from "@/lib/api";
import { McpServer } from "@/types";
import { useMcpActions } from "@/hooks/useMcpActions";
import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog";
interface McpPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appId: AppId;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ open, onOpenChange, appId }) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
// Use MCP actions hook
const { servers, loading, reload, toggleEnabled, saveServer, deleteServer } =
useMcpActions(appId);
useEffect(() => {
const setup = async () => {
try {
// Initialize: only import existing MCPs from corresponding client
if (appId === "claude") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromClaude();
} else if (appId === "codex") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromCodex();
} else if (appId === "gemini") {
const mcpApi = await import("@/lib/api").then((m) => m.mcpApi);
await mcpApi.importFromGemini();
}
} catch (e) {
console.warn("MCP initialization import failed (ignored)", e);
} finally {
await reload();
}
};
setup();
// Re-initialize when appId changes
}, [appId, reload]);
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
title: t("mcp.confirm.deleteTitle"),
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await deleteServer(id);
setConfirmDialog(null);
} catch (e) {
// Error already handled by useMcpActions
}
},
});
};
const handleSave = async (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => {
await saveServer(id, server, options);
setIsFormOpen(false);
setEditingId(null);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingId(null);
};
const serverEntries = useMemo(
() => Object.entries(servers) as Array<[string, McpServer]>,
[servers],
);
const enabledCount = useMemo(
() => serverEntries.filter(([_, server]) => server.enabled).length,
[serverEntries],
);
const panelTitle =
appId === "claude"
? t("mcp.claudeTitle")
: appId === "codex"
? t("mcp.codexTitle")
: t("mcp.geminiTitle");
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle>{panelTitle}</DialogTitle>
<Button type="button" variant="mcp" onClick={handleAdd}>
<Plus size={16} />
{t("mcp.add")}
</Button>
</div>
</DialogHeader>
{/* Info Section */}
<div className="flex-shrink-0 px-6 py-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.serverCount", { count: Object.keys(servers).length })} ·{" "}
{t("mcp.enabledCount", { count: enabledCount })}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 pb-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : (
(() => {
const hasAny = serverEntries.length > 0;
if (!hasAny) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* 已安装 */}
{serverEntries.map(([id, server]) => (
<McpListItem
key={`installed-${id}`}
id={id}
server={server}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
{/* 预设已移至"新增 MCP"面板中展示与套用 */}
</div>
);
})()
)}
</div>
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Form Modal */}
{isFormOpen && (
<McpFormModal
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined}
existingIds={Object.keys(servers)}
onSave={handleSave}
onClose={handleCloseForm}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</>
);
};
export default McpPanel;

View File

@@ -1,41 +0,0 @@
import React from "react";
interface McpToggleProps {
enabled: boolean;
onChange: (enabled: boolean) => void;
disabled?: boolean;
}
/**
* Toggle 开关组件
* 启用时为淡绿色,禁用时为灰色
*/
const McpToggle: React.FC<McpToggleProps> = ({
enabled,
onChange,
disabled = false,
}) => {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${enabled ? "translate-x-6" : "translate-x-1"}
`}
/>
</button>
);
};
export default McpToggle;

View File

@@ -193,14 +193,15 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
<McpFormModal
appId="claude" // Default to claude for unified panel
editingId={editingId || undefined}
initialData={editingId && serversMap ? serversMap[editingId] : undefined}
initialData={
editingId && serversMap ? serversMap[editingId] : undefined
}
existingIds={serversMap ? Object.keys(serversMap) : []}
onSave={async () => {
setIsFormOpen(false);
setEditingId(null);
}}
onClose={handleCloseForm}
unified
/>
)}

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
className,
)}
{...props}
>
@@ -22,7 +22,7 @@ const Checkbox = React.forwardRef<
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }
export { Checkbox };

View File

@@ -1,14 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { mcpApi } from '@/lib/api/mcp';
import type { McpServer } from '@/types';
import type { AppId } from '@/lib/api/types';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mcpApi } from "@/lib/api/mcp";
import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types";
/**
* 查询所有 MCP 服务器(统一管理)
*/
export function useAllMcpServers() {
return useQuery({
queryKey: ['mcp', 'all'],
queryKey: ["mcp", "all"],
queryFn: () => mcpApi.getAllServers(),
});
}
@@ -21,7 +21,7 @@ export function useUpsertMcpServer() {
return useMutation({
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}
@@ -42,7 +42,7 @@ export function useToggleMcpApp() {
enabled: boolean;
}) => mcpApi.toggleApp(serverId, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}
@@ -55,7 +55,7 @@ export function useDeleteMcpServer() {
return useMutation({
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}

View File

@@ -1,136 +0,0 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { mcpApi, type AppId } from "@/lib/api";
import type { McpServer } from "@/types";
import {
extractErrorMessage,
translateMcpBackendError,
} from "@/utils/errorUtils";
export interface UseMcpActionsResult {
servers: Record<string, McpServer>;
loading: boolean;
reload: () => Promise<void>;
toggleEnabled: (id: string, enabled: boolean) => Promise<void>;
saveServer: (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<void>;
deleteServer: (id: string) => Promise<void>;
}
/**
* useMcpActions - MCP management business logic
* Responsibilities:
* - Load MCP servers
* - Toggle enable/disable status
* - Save server configuration
* - Delete server
* - Error handling and toast notifications
*/
export function useMcpActions(appId: AppId): UseMcpActionsResult {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, McpServer>>({});
const [loading, setLoading] = useState(false);
const reload = useCallback(async () => {
setLoading(true);
try {
const cfg = await mcpApi.getConfig(appId);
setServers(cfg.servers || {});
} catch (error) {
console.error("[useMcpActions] Failed to load MCP config", error);
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
toast.error(mapped || detail || t("mcp.error.loadFailed"), {
duration: mapped || detail ? 6000 : 5000,
});
} finally {
setLoading(false);
}
}, [appId, t]);
const toggleEnabled = useCallback(
async (id: string, enabled: boolean) => {
// Optimistic update
const previousServers = servers;
setServers((prev) => ({
...prev,
[id]: {
...prev[id],
enabled,
},
}));
try {
await mcpApi.setEnabled(appId, id, enabled);
toast.success(enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), {
duration: 1500,
});
} catch (error) {
// Rollback on failure
setServers(previousServers);
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
toast.error(mapped || detail || t("mcp.error.saveFailed"), {
duration: mapped || detail ? 6000 : 5000,
});
}
},
[appId, servers, t],
);
const saveServer = useCallback(
async (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => {
try {
const payload: McpServer = { ...server, id };
await mcpApi.upsertServerInConfig(appId, id, payload, {
syncOtherSide: options?.syncOtherSide,
});
await reload();
toast.success(t("mcp.msg.saved"), { duration: 1500 });
} catch (error) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
const msg = mapped || detail || t("mcp.error.saveFailed");
toast.error(msg, { duration: mapped || detail ? 6000 : 5000 });
// Re-throw to allow form-level error handling
throw error;
}
},
[appId, reload, t],
);
const deleteServer = useCallback(
async (id: string) => {
try {
await mcpApi.deleteServerInConfig(appId, id);
await reload();
toast.success(t("mcp.msg.deleted"), { duration: 1500 });
} catch (error) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
toast.error(mapped || detail || t("mcp.error.deleteFailed"), {
duration: mapped || detail ? 6000 : 5000,
});
throw error;
}
},
[appId, reload, t],
);
return {
servers,
loading,
reload,
toggleEnabled,
saveServer,
deleteServer,
};
}

View File

@@ -32,18 +32,16 @@ export const mcpApi = {
return await invoke("validate_mcp_command", { cmd });
},
/**
* @deprecated 使用 getAllServers() 代替v3.7.0+
*/
async getConfig(app: AppId = "claude"): Promise<McpConfigResponse> {
return await invoke("get_mcp_config", { app });
},
async importFromClaude(): Promise<number> {
return await invoke("import_mcp_from_claude");
},
async importFromCodex(): Promise<number> {
return await invoke("import_mcp_from_codex");
},
/**
* @deprecated 使用 upsertUnifiedServer() 代替v3.7.0+
*/
async upsertServerInConfig(
app: AppId,
id: string,
@@ -61,6 +59,9 @@ export const mcpApi = {
return await invoke("upsert_mcp_server_in_config", payload);
},
/**
* @deprecated 使用 deleteUnifiedServer() 代替v3.7.0+
*/
async deleteServerInConfig(
app: AppId,
id: string,
@@ -76,26 +77,13 @@ export const mcpApi = {
return await invoke("delete_mcp_server_in_config", payload);
},
/**
* @deprecated 使用 toggleApp() 代替v3.7.0+
*/
async setEnabled(app: AppId, id: string, enabled: boolean): Promise<boolean> {
return await invoke("set_mcp_enabled", { app, id, enabled });
},
async syncEnabledToClaude(): Promise<boolean> {
return await invoke("sync_enabled_mcp_to_claude");
},
async syncEnabledToCodex(): Promise<boolean> {
return await invoke("sync_enabled_mcp_to_codex");
},
async syncEnabledToGemini(): Promise<boolean> {
return await invoke("sync_enabled_mcp_to_gemini");
},
async importFromGemini(): Promise<number> {
return await invoke("import_mcp_from_gemini");
},
// ========================================================================
// v3.7.0 新增:统一 MCP 管理 API
// ========================================================================
@@ -131,11 +119,4 @@ export const mcpApi = {
): Promise<void> {
return await invoke("toggle_mcp_app", { serverId, app, enabled });
},
/**
* 手动同步所有启用的 MCP 服务器到对应的应用
*/
async syncAllServers(): Promise<void> {
return await invoke("sync_all_mcp_servers");
},
};

View File

@@ -1,281 +0,0 @@
import { renderHook, act, waitFor } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useMcpActions } from "@/hooks/useMcpActions";
import type { McpServer } from "@/types";
const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args),
},
}));
const getConfigMock = vi.fn();
const setEnabledMock = vi.fn();
const upsertServerInConfigMock = vi.fn();
const deleteServerInConfigMock = vi.fn();
const syncEnabledToClaudeMock = vi.fn();
const syncEnabledToCodexMock = vi.fn();
vi.mock("@/lib/api", () => ({
mcpApi: {
getConfig: (...args: unknown[]) => getConfigMock(...args),
setEnabled: (...args: unknown[]) => setEnabledMock(...args),
upsertServerInConfig: (...args: unknown[]) => upsertServerInConfigMock(...args),
deleteServerInConfig: (...args: unknown[]) => deleteServerInConfigMock(...args),
syncEnabledToClaude: (...args: unknown[]) => syncEnabledToClaudeMock(...args),
syncEnabledToCodex: (...args: unknown[]) => syncEnabledToCodexMock(...args),
},
}));
const createServer = (overrides: Partial<McpServer> = {}): McpServer => ({
id: "server-1",
name: "Test Server",
description: "desc",
enabled: false,
apps: { claude: false, codex: false, gemini: false },
server: {
type: "stdio",
command: "run.sh",
args: [],
env: {},
},
...overrides,
});
const mockConfigResponse = (servers: Record<string, McpServer>) => ({
configPath: "/mock/config.json",
servers,
});
const createDeferred = <T,>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
};
describe("useMcpActions", () => {
beforeEach(() => {
getConfigMock.mockReset();
setEnabledMock.mockReset();
upsertServerInConfigMock.mockReset();
deleteServerInConfigMock.mockReset();
syncEnabledToClaudeMock.mockReset();
syncEnabledToCodexMock.mockReset();
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
getConfigMock.mockResolvedValue(mockConfigResponse({}));
setEnabledMock.mockResolvedValue(true);
upsertServerInConfigMock.mockResolvedValue(true);
deleteServerInConfigMock.mockResolvedValue(true);
});
const renderUseMcpActions = () => renderHook(() => useMcpActions("claude"));
it("reloads servers and toggles loading state", async () => {
const server = createServer();
const deferred = createDeferred<ReturnType<typeof mockConfigResponse>>();
getConfigMock.mockReturnValueOnce(deferred.promise);
const { result } = renderUseMcpActions();
let reloadPromise: Promise<void> | undefined;
await act(async () => {
reloadPromise = result.current.reload();
});
await waitFor(() => expect(result.current.loading).toBe(true));
deferred.resolve(mockConfigResponse({ [server.id]: server }));
await act(async () => {
await reloadPromise;
});
expect(getConfigMock).toHaveBeenCalledWith("claude");
expect(result.current.loading).toBe(false);
expect(result.current.servers).toEqual({ [server.id]: server });
});
it("shows toast error when reload fails", async () => {
const error = new Error("load failed");
getConfigMock.mockRejectedValueOnce(error);
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.reload();
});
expect(toastErrorMock).toHaveBeenCalledWith("load failed", { duration: 6000 });
expect(result.current.servers).toEqual({});
expect(result.current.loading).toBe(false);
});
it("toggles enabled flag optimistically and emits success toasts", async () => {
const server = createServer({ enabled: false });
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.reload();
});
await act(async () => {
await result.current.toggleEnabled(server.id, true);
});
expect(setEnabledMock).toHaveBeenCalledWith("claude", server.id, true);
expect(result.current.servers[server.id].enabled).toBe(true);
expect(toastSuccessMock).toHaveBeenLastCalledWith("mcp.msg.enabled", { duration: 1500 });
await act(async () => {
await result.current.toggleEnabled(server.id, false);
});
expect(setEnabledMock).toHaveBeenLastCalledWith("claude", server.id, false);
expect(result.current.servers[server.id].enabled).toBe(false);
expect(toastSuccessMock).toHaveBeenLastCalledWith("mcp.msg.disabled", { duration: 1500 });
});
it("rolls back state and shows error toast when toggle fails", async () => {
const server = createServer({ enabled: false });
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.reload();
});
setEnabledMock.mockRejectedValueOnce(new Error("toggle failed"));
await act(async () => {
await result.current.toggleEnabled(server.id, true);
});
expect(result.current.servers[server.id].enabled).toBe(false);
expect(toastErrorMock).toHaveBeenCalledWith("toggle failed", { duration: 6000 });
});
it("saves server configuration and refreshes list", async () => {
const serverInput = createServer({ id: "old-id", enabled: true });
const savedServer = { ...serverInput, id: "new-server" };
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [savedServer.id]: savedServer }));
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.saveServer("new-server", serverInput, { syncOtherSide: true });
});
expect(upsertServerInConfigMock).toHaveBeenCalledWith(
"claude",
"new-server",
{ ...serverInput, id: "new-server" },
{ syncOtherSide: true },
);
expect(result.current.servers["new-server"]).toEqual(savedServer);
expect(toastSuccessMock).toHaveBeenCalledWith("mcp.msg.saved", { duration: 1500 });
});
it("propagates error when saveServer fails", async () => {
const serverInput = createServer({ id: "input-id" });
const failure = new Error("cannot save");
upsertServerInConfigMock.mockRejectedValueOnce(failure);
const { result } = renderUseMcpActions();
let captured: unknown;
await act(async () => {
try {
await result.current.saveServer("server-1", serverInput);
} catch (err) {
captured = err;
}
});
expect(upsertServerInConfigMock).toHaveBeenCalled();
expect(getConfigMock).not.toHaveBeenCalled();
expect(captured).toBe(failure);
expect(toastErrorMock).toHaveBeenCalledWith("cannot save", { duration: 6000 });
expect(toastSuccessMock).not.toHaveBeenCalled();
});
it("deletes server and refreshes list", async () => {
const server = createServer();
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.reload();
});
getConfigMock.mockResolvedValueOnce(mockConfigResponse({}));
await act(async () => {
await result.current.deleteServer(server.id);
});
expect(deleteServerInConfigMock).toHaveBeenCalledWith("claude", server.id);
expect(result.current.servers[server.id]).toBeUndefined();
expect(toastSuccessMock).toHaveBeenCalledWith("mcp.msg.deleted", { duration: 1500 });
});
it("propagates delete error and keeps state", async () => {
const server = createServer();
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.reload();
});
const failure = new Error("delete failed");
deleteServerInConfigMock.mockRejectedValueOnce(failure);
let captured: unknown;
await act(async () => {
try {
await result.current.deleteServer(server.id);
} catch (err) {
captured = err;
}
});
expect(deleteServerInConfigMock).toHaveBeenCalledWith("claude", server.id);
expect(result.current.servers[server.id]).toEqual(server);
expect(captured).toBe(failure);
expect(toastErrorMock).toHaveBeenCalledWith("delete failed", { duration: 6000 });
});
it("maps backend error message when save fails with known detail", async () => {
const serverInput = createServer({ id: "input-id" });
const backendError = { message: "stdio 类型的 MCP 服务器必须包含 command 字段" };
upsertServerInConfigMock.mockRejectedValueOnce(backendError);
const { result } = renderUseMcpActions();
await expect(async () =>
result.current.saveServer("server-1", serverInput),
).rejects.toEqual(backendError);
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.commandRequired", {
duration: 6000,
});
});
it("syncs enabled state to counterpart when appType is claude", async () => {
const server = createServer();
getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server }));
const { result } = renderUseMcpActions();
await act(async () => {
await result.current.reload();
});
await act(async () => {
await result.current.toggleEnabled(server.id, true);
});
expect(setEnabledMock).toHaveBeenCalledWith("claude", server.id, true);
expect(syncEnabledToClaudeMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,232 +0,0 @@
import React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import McpPanel from "@/components/mcp/McpPanel";
import type { McpServer } from "@/types";
import { createTestQueryClient } from "../utils/testQueryClient";
const toastSuccessMock = vi.hoisted(() => vi.fn());
const toastErrorMock = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args),
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
}),
}));
const importFromClaudeMock = vi.hoisted(() => vi.fn().mockResolvedValue(1));
const importFromCodexMock = vi.hoisted(() => vi.fn().mockResolvedValue(1));
const toggleEnabledMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const saveServerMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const deleteServerMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const reloadMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const baseServers: Record<string, McpServer> = {
sample: {
id: "sample",
name: "Sample Claude Server",
enabled: true,
apps: { claude: true, codex: false, gemini: false },
server: {
type: "stdio",
command: "claude-server",
},
},
};
vi.mock("@/lib/api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
return {
...actual,
mcpApi: {
...actual.mcpApi,
importFromClaude: (...args: unknown[]) =>
importFromClaudeMock(...args),
importFromCodex: (...args: unknown[]) => importFromCodexMock(...args),
},
};
});
vi.mock("@/components/mcp/McpListItem", () => ({
default: ({ id, server, onToggle, onEdit, onDelete }: any) => (
<div data-testid={`mcp-item-${id}`}>
<span>{server.name || id}</span>
<button
type="button"
onClick={() => onToggle(id, !server.enabled)}
data-testid={`toggle-${id}`}
>
toggle
</button>
<button type="button" onClick={() => onEdit(id)} data-testid={`edit-${id}`}>
edit
</button>
<button
type="button"
onClick={() => onDelete(id)}
data-testid={`delete-${id}`}
>
delete
</button>
</div>
),
}));
vi.mock("@/components/mcp/McpFormModal", () => ({
default: ({ onSave, onClose }: any) => (
<div data-testid="mcp-form">
<button
type="button"
onClick={() =>
onSave(
"new-server",
{
id: "new-server",
name: "New Server",
enabled: true,
server: { type: "stdio", command: "new.cmd" },
},
{ syncOtherSide: true },
)
}
>
submit-form
</button>
<button type="button" onClick={onClose}>
close-form
</button>
</div>
),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, ...rest }: any) => (
<button type="button" onClick={onClick} {...rest}>
{children}
</button>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({ isOpen, onConfirm }: any) =>
isOpen ? (
<div data-testid="confirm-dialog">
<button type="button" onClick={onConfirm}>
confirm-delete
</button>
</div>
) : null,
}));
const renderPanel = (props?: Partial<React.ComponentProps<typeof McpPanel>>) => {
const client = createTestQueryClient();
return render(
<QueryClientProvider client={client}>
<McpPanel open onOpenChange={() => {}} appId="claude" {...props} />
</QueryClientProvider>,
);
};
const useMcpActionsMock = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/useMcpActions", () => ({
useMcpActions: (...args: unknown[]) => useMcpActionsMock(...args),
}));
describe("McpPanel integration", () => {
beforeEach(() => {
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
importFromClaudeMock.mockClear();
importFromClaudeMock.mockResolvedValue(1);
importFromCodexMock.mockClear();
importFromCodexMock.mockResolvedValue(1);
toggleEnabledMock.mockClear();
saveServerMock.mockClear();
deleteServerMock.mockClear();
reloadMock.mockClear();
useMcpActionsMock.mockReturnValue({
servers: baseServers,
loading: false,
reload: reloadMock,
toggleEnabled: toggleEnabledMock,
saveServer: saveServerMock,
deleteServer: deleteServerMock,
});
});
it("加载并切换 MCP 启用状态", async () => {
renderPanel();
await waitFor(() =>
expect(screen.getByTestId("mcp-item-sample")).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId("toggle-sample"));
await waitFor(() =>
expect(toggleEnabledMock).toHaveBeenCalledWith("sample", false),
);
});
it("新增 MCP 并触发保存与同步选项", async () => {
renderPanel();
await waitFor(() =>
expect(
screen.getByText((content) => content.startsWith("mcp.serverCount")),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("mcp.add"));
await waitFor(() => expect(screen.getByTestId("mcp-form")).toBeInTheDocument());
fireEvent.click(screen.getByText("submit-form"));
await waitFor(() =>
expect(screen.queryByTestId("mcp-form")).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(saveServerMock).toHaveBeenCalledWith(
"new-server",
expect.objectContaining({ id: "new-server" }),
{ syncOtherSide: true },
),
);
});
it("删除 MCP 并发送确认请求", async () => {
renderPanel();
await waitFor(() =>
expect(screen.getByTestId("mcp-item-sample")).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId("delete-sample"));
await waitFor(() =>
expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("confirm-delete"));
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledWith("sample"));
});
});