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
|
let servers = root
|
||||||
.get("mcpServers")
|
.get("mcpServers")
|
||||||
.and_then(|v| v.as_object())
|
.and_then(|v| v.as_object())
|
||||||
.map(|obj| {
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
obj.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(servers)
|
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(
|
pub async fn get_claude_common_config_snippet(
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> 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())
|
Ok(guard.claude_common_config_snippet.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub struct McpConfigResponse {
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
pub async fn get_mcp_config(
|
pub async fn get_mcp_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
@@ -101,7 +102,8 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
apps.set_enabled_for(&app_ty, true);
|
apps.set_enabled_for(&app_ty, true);
|
||||||
|
|
||||||
// 尝试从 spec 中提取 name,否则使用 id
|
// 尝试从 spec 中提取 name,否则使用 id
|
||||||
let name = spec.get("name")
|
let name = spec
|
||||||
|
.get("name")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&id)
|
.unwrap_or(&id)
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -142,6 +144,7 @@ pub async fn delete_mcp_server_in_config(
|
|||||||
|
|
||||||
/// 设置启用状态并同步到客户端配置
|
/// 设置启用状态并同步到客户端配置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
pub async fn set_mcp_enabled(
|
pub async fn set_mcp_enabled(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
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())
|
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 管理命令
|
// v3.7.0 新增:统一 MCP 管理命令
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -219,10 +180,7 @@ pub async fn upsert_mcp_server(
|
|||||||
|
|
||||||
/// 删除 MCP 服务器
|
/// 删除 MCP 服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_mcp_server(
|
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
McpService::delete_server(&state, &id).map_err(|e| e.to_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())?;
|
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())
|
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())
|
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 文本
|
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
|
||||||
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
@@ -66,96 +48,7 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
|||||||
Ok(Some(content))
|
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 映射
|
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||||
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
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
|
let servers = root
|
||||||
.get("mcpServers")
|
.get("mcpServers")
|
||||||
.and_then(|v| v.as_object())
|
.and_then(|v| v.as_object())
|
||||||
.map(|obj| {
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
obj.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(servers)
|
Ok(servers)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ mod codex_config;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod gemini_mcp;
|
|
||||||
mod gemini_config; // 新增
|
mod gemini_config; // 新增
|
||||||
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
@@ -541,18 +541,11 @@ pub fn run() {
|
|||||||
commands::upsert_mcp_server_in_config,
|
commands::upsert_mcp_server_in_config,
|
||||||
commands::delete_mcp_server_in_config,
|
commands::delete_mcp_server_in_config,
|
||||||
commands::set_mcp_enabled,
|
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
|
// v3.7.0: Unified MCP management
|
||||||
commands::get_mcp_servers,
|
commands::get_mcp_servers,
|
||||||
commands::upsert_mcp_server,
|
commands::upsert_mcp_server,
|
||||||
commands::delete_mcp_server,
|
commands::delete_mcp_server,
|
||||||
commands::toggle_mcp_app,
|
commands::toggle_mcp_app,
|
||||||
commands::sync_all_mcp_servers,
|
|
||||||
// Prompt management
|
// Prompt management
|
||||||
commands::get_prompts,
|
commands::get_prompts,
|
||||||
commands::upsert_prompt,
|
commands::upsert_prompt,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
|
||||||
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
|
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
|
||||||
let obj = entry
|
let obj = entry
|
||||||
.as_object()
|
.as_object()
|
||||||
@@ -210,6 +211,7 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
pub fn get_servers_snapshot_for(
|
pub fn get_servers_snapshot_for(
|
||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
app: &AppType,
|
app: &AppType,
|
||||||
@@ -235,6 +237,7 @@ pub fn get_servers_snapshot_for(
|
|||||||
(snapshot, normalized)
|
(snapshot, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
pub fn upsert_in_config_for(
|
pub fn upsert_in_config_for(
|
||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
app: &AppType,
|
app: &AppType,
|
||||||
@@ -273,6 +276,7 @@ pub fn upsert_in_config_for(
|
|||||||
Ok(before.is_none())
|
Ok(before.is_none())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
pub fn delete_in_config_for(
|
pub fn delete_in_config_for(
|
||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
app: &AppType,
|
app: &AppType,
|
||||||
@@ -286,6 +290,7 @@ pub fn delete_in_config_for(
|
|||||||
Ok(existed)
|
Ok(existed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||||
/// 设置启用状态(不执行落盘或文件同步)
|
/// 设置启用状态(不执行落盘或文件同步)
|
||||||
pub fn set_enabled_flag_for(
|
pub fn set_enabled_flag_for(
|
||||||
config: &mut MultiAppConfig,
|
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 config_path = crate::codex_config::get_codex_config_path();
|
||||||
|
|
||||||
let mut doc = if config_path.exists() {
|
let mut doc = if config_path.exists() {
|
||||||
let content = std::fs::read_to_string(&config_path)
|
let content =
|
||||||
.map_err(|e| AppError::io(&config_path, e))?;
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
content
|
content
|
||||||
.parse::<toml_edit::DocumentMut>()
|
.parse::<toml_edit::DocumentMut>()
|
||||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||||
@@ -915,10 +920,10 @@ pub fn sync_single_server_to_codex(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确保 [mcp.servers] 子表存在
|
// 确保 [mcp.servers] 子表存在
|
||||||
if !doc["mcp"]
|
if doc["mcp"]
|
||||||
.as_table()
|
.as_table()
|
||||||
.and_then(|t| t.get("servers"))
|
.and_then(|t| t.get("servers"))
|
||||||
.is_some()
|
.is_none()
|
||||||
{
|
{
|
||||||
doc["mcp"]["servers"] = toml_edit::table();
|
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);
|
doc["mcp"]["servers"][id] = Item::Table(toml_table);
|
||||||
|
|
||||||
// 写回文件
|
// 写回文件
|
||||||
std::fs::write(&config_path, doc.to_string())
|
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
.map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -943,8 +947,8 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
|||||||
return Ok(()); // 文件不存在,无需删除
|
return Ok(()); // 文件不存在,无需删除
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(&config_path)
|
let content =
|
||||||
.map_err(|e| AppError::io(&config_path, e))?;
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
let mut doc = content
|
let mut doc = content
|
||||||
.parse::<toml_edit::DocumentMut>()
|
.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())
|
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
.map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,11 +163,7 @@ impl McpService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_server_from_app(
|
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
|
||||||
_state: &AppState,
|
|
||||||
id: &str,
|
|
||||||
app: &AppType,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
match app {
|
match app {
|
||||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||||
@@ -236,7 +232,10 @@ impl McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [已废弃] 从 Claude 导入 MCP(兼容旧 API)
|
/// [已废弃] 从 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> {
|
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let count = mcp::import_from_claude(&mut cfg)?;
|
let count = mcp::import_from_claude(&mut cfg)?;
|
||||||
@@ -246,7 +245,10 @@ impl McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [已废弃] 从 Codex 导入 MCP(兼容旧 API)
|
/// [已废弃] 从 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> {
|
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let count = mcp::import_from_codex(&mut cfg)?;
|
let count = mcp::import_from_codex(&mut cfg)?;
|
||||||
@@ -256,7 +258,10 @@ impl McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [已废弃] 从 Gemini 导入 MCP(兼容旧 API)
|
/// [已废弃] 从 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> {
|
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
|
||||||
let mut cfg = state.config.write()?;
|
let mut cfg = state.config.write()?;
|
||||||
let count = mcp::import_from_gemini(&mut cfg)?;
|
let count = mcp::import_from_gemini(&mut cfg)?;
|
||||||
|
|||||||
@@ -302,10 +302,7 @@ function App() {
|
|||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UnifiedMcpPanel
|
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
|
||||||
open={isMcpOpen}
|
|
||||||
onOpenChange={setIsMcpOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React, { useMemo, useState, useEffect } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
Save,
|
|
||||||
Plus,
|
|
||||||
AlertCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +12,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { McpServer, McpServerSpec } from "@/types";
|
||||||
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
|
||||||
import McpWizardModal from "./McpWizardModal";
|
import McpWizardModal from "./McpWizardModal";
|
||||||
@@ -40,14 +33,9 @@ interface McpFormModalProps {
|
|||||||
appId: AppId;
|
appId: AppId;
|
||||||
editingId?: string;
|
editingId?: string;
|
||||||
initialData?: McpServer;
|
initialData?: McpServer;
|
||||||
onSave: (
|
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
|
||||||
id: string,
|
|
||||||
server: McpServer,
|
|
||||||
options?: { syncOtherSide?: boolean },
|
|
||||||
) => Promise<void>;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
existingIds?: string[];
|
existingIds?: string[];
|
||||||
unified?: boolean; // 统一模式:使用 useUpsertMcpServer 而非传统回调
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,13 +50,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
existingIds = [],
|
existingIds = [],
|
||||||
unified = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||||
useMcpValidation();
|
useMcpValidation();
|
||||||
|
|
||||||
// 统一模式下使用 mutation
|
|
||||||
const upsertMutation = useUpsertMcpServer();
|
const upsertMutation = useUpsertMcpServer();
|
||||||
|
|
||||||
const [formId, setFormId] = useState(
|
const [formId, setFormId] = useState(
|
||||||
@@ -112,38 +98,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
const [idError, setIdError] = useState("");
|
const [idError, setIdError] = useState("");
|
||||||
const [syncOtherSide, setSyncOtherSide] = useState(false);
|
|
||||||
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
|
|
||||||
|
|
||||||
// 判断是否使用 TOML 格式
|
// 判断是否使用 TOML 格式
|
||||||
const useToml = appId === "codex";
|
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 wizardInitialSpec = useMemo(() => {
|
||||||
const fallback = initialData?.server;
|
const fallback = initialData?.server;
|
||||||
@@ -377,22 +334,13 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
name: finalName,
|
name: finalName,
|
||||||
server: serverSpec,
|
server: serverSpec,
|
||||||
// 确保 apps 字段始终存在(v3.7.0 新架构必需)
|
// 确保 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();
|
const descriptionTrimmed = formDescription.trim();
|
||||||
if (descriptionTrimmed) {
|
if (descriptionTrimmed) {
|
||||||
entry.description = descriptionTrimmed;
|
entry.description = descriptionTrimmed;
|
||||||
@@ -424,16 +372,10 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
delete entry.tags;
|
delete entry.tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式等待保存流程
|
// 保存到统一配置
|
||||||
if (unified) {
|
await upsertMutation.mutateAsync(entry);
|
||||||
// 统一模式:调用 useUpsertMcpServer mutation
|
toast.success(t("common.success"));
|
||||||
await upsertMutation.mutateAsync(entry);
|
await onSave(); // 通知父组件关闭表单
|
||||||
toast.success(t("common.success"));
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
// 传统模式:调用父组件回调
|
|
||||||
await onSave(trimmedId, entry, { syncOtherSide });
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const detail = extractErrorMessage(error);
|
const detail = extractErrorMessage(error);
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
const mapped = translateMcpBackendError(detail, t);
|
||||||
@@ -646,58 +588,24 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
|
<DialogFooter className="flex justify-end 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>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex items-center gap-3">
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
<Button type="button" variant="ghost" onClick={onClose}>
|
{t("common.cancel")}
|
||||||
{t("common.cancel")}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
onClick={handleSubmit}
|
||||||
onClick={handleSubmit}
|
disabled={saving || (!isEditing && !!idError)}
|
||||||
disabled={saving || (!isEditing && !!idError)}
|
variant="mcp"
|
||||||
variant="mcp"
|
>
|
||||||
>
|
{isEditing ? <Save size={16} /> : <Plus size={16} />}
|
||||||
{isEditing ? <Save size={16} /> : <Plus size={16} />}
|
{saving
|
||||||
{saving
|
? t("common.saving")
|
||||||
? t("common.saving")
|
: isEditing
|
||||||
: isEditing
|
? t("common.save")
|
||||||
? t("common.save")
|
: t("common.add")}
|
||||||
: t("common.add")}
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
<McpFormModal
|
||||||
appId="claude" // Default to claude for unified panel
|
appId="claude" // Default to claude for unified panel
|
||||||
editingId={editingId || undefined}
|
editingId={editingId || undefined}
|
||||||
initialData={editingId && serversMap ? serversMap[editingId] : undefined}
|
initialData={
|
||||||
|
editingId && serversMap ? serversMap[editingId] : undefined
|
||||||
|
}
|
||||||
existingIds={serversMap ? Object.keys(serversMap) : []}
|
existingIds={serversMap ? Object.keys(serversMap) : []}
|
||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
setIsFormOpen(false);
|
setIsFormOpen(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
}}
|
}}
|
||||||
onClose={handleCloseForm}
|
onClose={handleCloseForm}
|
||||||
unified
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
@@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -22,7 +22,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { mcpApi } from '@/lib/api/mcp';
|
import { mcpApi } from "@/lib/api/mcp";
|
||||||
import type { McpServer } from '@/types';
|
import type { McpServer } from "@/types";
|
||||||
import type { AppId } from '@/lib/api/types';
|
import type { AppId } from "@/lib/api/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询所有 MCP 服务器(统一管理)
|
* 查询所有 MCP 服务器(统一管理)
|
||||||
*/
|
*/
|
||||||
export function useAllMcpServers() {
|
export function useAllMcpServers() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['mcp', 'all'],
|
queryKey: ["mcp", "all"],
|
||||||
queryFn: () => mcpApi.getAllServers(),
|
queryFn: () => mcpApi.getAllServers(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export function useUpsertMcpServer() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ export function useToggleMcpApp() {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}) => mcpApi.toggleApp(serverId, app, enabled),
|
}) => mcpApi.toggleApp(serverId, app, enabled),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export function useDeleteMcpServer() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
||||||
onSuccess: () => {
|
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 });
|
return await invoke("validate_mcp_command", { cmd });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 使用 getAllServers() 代替(v3.7.0+)
|
||||||
|
*/
|
||||||
async getConfig(app: AppId = "claude"): Promise<McpConfigResponse> {
|
async getConfig(app: AppId = "claude"): Promise<McpConfigResponse> {
|
||||||
return await invoke("get_mcp_config", { app });
|
return await invoke("get_mcp_config", { app });
|
||||||
},
|
},
|
||||||
|
|
||||||
async importFromClaude(): Promise<number> {
|
/**
|
||||||
return await invoke("import_mcp_from_claude");
|
* @deprecated 使用 upsertUnifiedServer() 代替(v3.7.0+)
|
||||||
},
|
*/
|
||||||
|
|
||||||
async importFromCodex(): Promise<number> {
|
|
||||||
return await invoke("import_mcp_from_codex");
|
|
||||||
},
|
|
||||||
|
|
||||||
async upsertServerInConfig(
|
async upsertServerInConfig(
|
||||||
app: AppId,
|
app: AppId,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -61,6 +59,9 @@ export const mcpApi = {
|
|||||||
return await invoke("upsert_mcp_server_in_config", payload);
|
return await invoke("upsert_mcp_server_in_config", payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 使用 deleteUnifiedServer() 代替(v3.7.0+)
|
||||||
|
*/
|
||||||
async deleteServerInConfig(
|
async deleteServerInConfig(
|
||||||
app: AppId,
|
app: AppId,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -76,26 +77,13 @@ export const mcpApi = {
|
|||||||
return await invoke("delete_mcp_server_in_config", payload);
|
return await invoke("delete_mcp_server_in_config", payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 使用 toggleApp() 代替(v3.7.0+)
|
||||||
|
*/
|
||||||
async setEnabled(app: AppId, id: string, enabled: boolean): Promise<boolean> {
|
async setEnabled(app: AppId, id: string, enabled: boolean): Promise<boolean> {
|
||||||
return await invoke("set_mcp_enabled", { app, id, enabled });
|
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
|
// v3.7.0 新增:统一 MCP 管理 API
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -131,11 +119,4 @@ export const mcpApi = {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return await invoke("toggle_mcp_app", { serverId, app, enabled });
|
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