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:
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -302,10 +302,7 @@ function App() {
|
||||
appId={activeApp}
|
||||
/>
|
||||
|
||||
<UnifiedMcpPanel
|
||||
open={isMcpOpen}
|
||||
onOpenChange={setIsMcpOpen}
|
||||
/>
|
||||
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user