Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 102 additions & 48 deletions src-tauri/src/claude_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,60 +9,80 @@ use crate::error::AppError;

/// 需要在 Windows 上用 cmd /c 包装的命令
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
#[cfg(windows)]
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];

/// 判断路径是否属于 WSL 环境
/// WSL 路径特征:\\wsl.localhost\ 或 \\wsl$\
fn is_wsl_path(path: &Path) -> bool {
let path_str = path.to_string_lossy();
path_str.starts_with("\\\\wsl.localhost\\") || path_str.starts_with("\\\\wsl$\\")
}

/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
#[cfg(windows)]
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
// 只处理 stdio 类型(默认或显式)
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
if server_type != "stdio" {
///
/// # 参数
/// * `obj` - MCP 服务器配置对象
/// * `target_path` - 目标配置文件路径,用于判断是否在 WSL 环境中
///
/// # 逻辑
/// - 仅在 Windows 平台且目标路径不在 WSL 中时包装命令
/// - WSL 环境不需要 cmd /c 包装
fn wrap_command_for_windows(obj: &mut Map<String, Value>, target_path: &Path) {
#[cfg(not(windows))]
{
return;
}

let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
return;
};
#[cfg(windows)]
{
// 如果目标路径在 WSL 中,不包装命令
if is_wsl_path(target_path) {
return;
}

// 已经是 cmd 的不重复包装
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
return;
}
// 只处理 stdio 类型(默认或显式)
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
if server_type != "stdio" {
return;
}

// 提取命令名(去掉 .cmd 后缀和路径)
let cmd_name = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(cmd);
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
return;
};

let needs_wrap = WINDOWS_WRAP_COMMANDS
.iter()
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
// 已经是 cmd 的不重复包装
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
return;
}

if !needs_wrap {
return;
}
// 提取命令名(去掉 .cmd 后缀和路径)
let cmd_name = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(cmd);

// 构建新的 args: ["/c", "原命令", ...原args]
let original_args = obj
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let needs_wrap = WINDOWS_WRAP_COMMANDS
.iter()
.any(|&c| cmd_name.eq_ignore_ascii_case(c));

let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
new_args.extend(original_args);
if !needs_wrap {
return;
}

obj.insert("command".into(), Value::String("cmd".into()));
obj.insert("args".into(), Value::Array(new_args));
}
// 构建新的 args: ["/c", "原命令", ...原args]
let original_args = obj
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();

let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
new_args.extend(original_args);

/// 非 Windows 平台无需处理
#[cfg(not(windows))]
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
obj.insert("command".into(), Value::String("cmd".into()));
obj.insert("args".into(), Value::Array(new_args));
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -398,7 +418,8 @@ pub fn set_mcp_servers_map(
obj.remove("docs");

// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
// 但 WSL 环境不需要包装(通过目标路径判断)
wrap_command_for_windows(&mut obj, &path);

out.insert(id.clone(), Value::Object(obj));
}
Expand Down Expand Up @@ -427,7 +448,8 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

#[cfg(windows)]
{
Expand All @@ -451,7 +473,8 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

#[cfg(windows)]
{
Expand All @@ -467,7 +490,8 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

assert_eq!(obj["command"], "cmd");
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
Expand All @@ -481,7 +505,8 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

assert!(!obj.contains_key("command"));
assert_eq!(obj["url"], "https://example.com/mcp");
Expand All @@ -494,7 +519,8 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
assert_eq!(obj["command"], "python");
Expand All @@ -505,7 +531,8 @@ mod tests {
fn test_wrap_command_for_windows_no_args() {
// 没有 args 的情况
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

#[cfg(windows)]
{
Expand All @@ -521,7 +548,8 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

#[cfg(windows)]
{
Expand All @@ -537,12 +565,38 @@ mod tests {
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
let path = Path::new("C:\\Users\\test\\.claude.json");
wrap_command_for_windows(&mut obj, path);

#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
}
}

#[cfg(windows)]
#[test]
fn test_wrap_command_for_windows_wsl_path_skipped() {
// WSL 路径不应该被包装
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
.as_object()
.unwrap()
.clone();
let path = Path::new("\\\\wsl.localhost\\Ubuntu-24.04\\root\\.claude.json");
wrap_command_for_windows(&mut obj, path);

// WSL 路径不应该被包装
assert_eq!(obj["command"], "npx");
assert_eq!(obj["args"], json!(["-y", "@upstash/context7-mcp"]));
}

#[cfg(windows)]
#[test]
fn test_is_wsl_path() {
assert!(is_wsl_path(Path::new("\\\\wsl.localhost\\Ubuntu-24.04\\root\\.claude.json")));
assert!(is_wsl_path(Path::new("\\\\wsl$\\Ubuntu-24.04\\root\\.claude.json")));
assert!(!is_wsl_path(Path::new("C:\\Users\\test\\.claude.json")));
assert!(!is_wsl_path(Path::new("/home/test/.claude.json")));
}
}
7 changes: 5 additions & 2 deletions src-tauri/src/commands/import_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,14 @@ pub async fn import_config_from_file(
}

#[tauri::command]
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
pub async fn sync_current_providers_live(
state: State<'_, AppState>,
sync_mcp: Option<bool>,
) -> Result<Value, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
ProviderService::sync_current_to_live_with_options(&app_state, sync_mcp.unwrap_or(true))?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/mcp/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
crate::claude_mcp::set_mcp_servers_map(&enabled)
}

/// 将给定的 MCP 服务器列表全量写入 Claude 配置(覆盖 mcpServers)
pub fn sync_servers_map_to_claude(servers: &HashMap<String, Value>) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
crate::claude_mcp::set_mcp_servers_map(servers)
}

/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
Expand Down
61 changes: 61 additions & 0 deletions src-tauri/src/mcp/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,67 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
Ok(())
}

/// 将给定的 MCP 服务器列表全量写入 Codex config.toml(覆盖 [mcp_servers] 表)
pub fn sync_servers_map_to_codex(
servers: &std::collections::HashMap<String, Value>,
) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table};

// 读取现有 config.toml 文本;保持无效 TOML 的错误返回(不覆盖文件)
let base_text = crate::codex_config::read_and_validate_codex_config_text()?;

// 使用 toml_edit 解析(允许空文件)
let mut doc = if base_text.trim().is_empty() {
toml_edit::DocumentMut::default()
} else {
base_text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
};

// 清理可能存在的错误格式 [mcp.servers]
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
}

// 构造目标 servers 表(稳定的键顺序)
if servers.is_empty() {
// 无启用项:移除 mcp_servers 表
doc.as_table_mut().remove("mcp_servers");
} else {
let mut servers_tbl = Table::new();
let mut ids: Vec<_> = servers.keys().cloned().collect();
ids.sort();
for id in ids {
let spec = servers.get(&id).expect("spec must exist");
match json_server_to_toml_table(spec) {
Ok(table) => {
servers_tbl[&id[..]] = Item::Table(table);
}
Err(err) => {
log::error!("跳过无效的 MCP 服务器 '{id}': {err}");
}
}
}
// 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"] = Item::Table(servers_tbl);
}

// 写回文件
let new_text = doc.to_string();
let path = crate::codex_config::get_codex_config_path();
crate::config::write_text_file(&path, &new_text)?;
Ok(())
}

/// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
pub fn sync_single_server_to_codex(
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/mcp/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}

/// 将给定的 MCP 服务器列表全量写入 Gemini 配置(覆盖 mcpServers)
pub fn sync_servers_map_to_gemini(servers: &HashMap<String, Value>) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
crate::gemini_mcp::set_mcp_servers_map(servers)
}

/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+)
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
Expand Down
7 changes: 4 additions & 3 deletions src-tauri/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ mod validation;
// 重新导出公共 API
pub use claude::{
import_from_claude, remove_server_from_claude, sync_enabled_to_claude,
sync_single_server_to_claude,
sync_servers_map_to_claude, sync_single_server_to_claude,
};
pub use codex::{
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_servers_map_to_codex,
sync_single_server_to_codex,
};
pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
sync_servers_map_to_gemini, sync_single_server_to_gemini,
};
Loading