diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index d17f75b01..253ba0053 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -7,6 +7,64 @@ use std::path::{Path, PathBuf}; use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; 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"]; + +/// 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) { + // 只处理 stdio 类型(默认或显式) + let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); + if server_type != "stdio" { + return; + } + + let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else { + return; + }; + + // 已经是 cmd 的不重复包装 + if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") { + return; + } + + // 提取命令名(去掉 .cmd 后缀和路径) + let cmd_name = Path::new(cmd) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(cmd); + + let needs_wrap = WINDOWS_WRAP_COMMANDS + .iter() + .any(|&c| cmd_name.eq_ignore_ascii_case(c)); + + if !needs_wrap { + return; + } + + // 构建新的 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); + + obj.insert("command".into(), Value::String("cmd".into())); + obj.insert("args".into(), Value::Array(new_args)); +} + +/// 非 Windows 平台无需处理 +#[cfg(not(windows))] +fn wrap_command_for_windows(_obj: &mut Map) { + // 非 Windows 平台不做任何处理 +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpStatus { @@ -339,6 +397,9 @@ pub fn set_mcp_servers_map( obj.remove("homepage"); obj.remove("docs"); + // Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式 + wrap_command_for_windows(&mut obj); + out.insert(id.clone(), Value::Object(obj)); } @@ -352,3 +413,136 @@ pub fn set_mcp_servers_map( write_json_value(&path, &root)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 测试 Windows 命令包装功能 + /// 由于使用条件编译,在非 Windows 平台上测试的是空函数 + #[test] + fn test_wrap_command_for_windows_npx() { + let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!( + obj["args"], + json!(["/c", "npx", "-y", "@upstash/context7-mcp"]) + ); + } + + #[cfg(not(windows))] + { + // 非 Windows 平台不做任何处理 + assert_eq!(obj["command"], "npx"); + } + } + + #[test] + fn test_wrap_command_for_windows_npm() { + let mut obj = json!({"command": "npm", "args": ["run", "start"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"])); + } + } + + #[test] + fn test_wrap_command_for_windows_already_cmd() { + // 已经是 cmd 的不应该重复包装 + let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + assert_eq!(obj["command"], "cmd"); + // args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...] + assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"])); + } + + #[test] + fn test_wrap_command_for_windows_http_type_skipped() { + // http 类型不应该被处理 + let mut obj = json!({"type": "http", "url": "https://example.com/mcp"}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + assert!(!obj.contains_key("command")); + assert_eq!(obj["url"], "https://example.com/mcp"); + } + + #[test] + fn test_wrap_command_for_windows_other_command_skipped() { + // 非目标命令(如 python)不应该被包装 + let mut obj = json!({"command": "python", "args": ["server.py"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + // python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装 + assert_eq!(obj["command"], "python"); + assert_eq!(obj["args"], json!(["server.py"])); + } + + #[test] + 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); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "npx"])); + } + } + + #[test] + fn test_wrap_command_for_windows_with_cmd_suffix() { + // 处理 npx.cmd 格式 + let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"])); + } + } + + #[test] + fn test_wrap_command_for_windows_case_insensitive() { + // 大小写不敏感 + let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"])); + } + } +}