Skip to content

Commit 443e23c

Browse files
committed
fix(windows): wrap npx/npm commands with cmd /c for MCP export
On Windows, npx, npm, yarn, pnpm, node, bun, and deno are actually .cmd batch files that require cmd /c wrapper to execute properly. This fixes the Claude Code /doctor warning: "Windows requires 'cmd /c' wrapper to execute npx" The transformation is applied when exporting MCP config to ~/.claude.json: - Before: {"command": "npx", "args": ["-y", "foo"]} - After: {"command": "cmd", "args": ["/c", "npx", "-y", "foo"]} Uses conditional compilation (#[cfg(windows)]) for zero overhead on other platforms. Closes #453
1 parent f26a011 commit 443e23c

File tree

1 file changed

+194
-0
lines changed

1 file changed

+194
-0
lines changed

src-tauri/src/claude_mcp.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,64 @@ use std::path::{Path, PathBuf};
77
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
88
use crate::error::AppError;
99

10+
/// 需要在 Windows 上用 cmd /c 包装的命令
11+
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
12+
#[cfg(windows)]
13+
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
14+
15+
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
16+
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
17+
#[cfg(windows)]
18+
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
19+
// 只处理 stdio 类型(默认或显式)
20+
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
21+
if server_type != "stdio" {
22+
return;
23+
}
24+
25+
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
26+
return;
27+
};
28+
29+
// 已经是 cmd 的不重复包装
30+
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
31+
return;
32+
}
33+
34+
// 提取命令名(去掉 .cmd 后缀和路径)
35+
let cmd_name = Path::new(cmd)
36+
.file_stem()
37+
.and_then(|s| s.to_str())
38+
.unwrap_or(cmd);
39+
40+
let needs_wrap = WINDOWS_WRAP_COMMANDS
41+
.iter()
42+
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
43+
44+
if !needs_wrap {
45+
return;
46+
}
47+
48+
// 构建新的 args: ["/c", "原命令", ...原args]
49+
let original_args = obj
50+
.get("args")
51+
.and_then(|v| v.as_array())
52+
.cloned()
53+
.unwrap_or_default();
54+
55+
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
56+
new_args.extend(original_args);
57+
58+
obj.insert("command".into(), Value::String("cmd".into()));
59+
obj.insert("args".into(), Value::Array(new_args));
60+
}
61+
62+
/// 非 Windows 平台无需处理
63+
#[cfg(not(windows))]
64+
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
65+
// 非 Windows 平台不做任何处理
66+
}
67+
1068
#[derive(Debug, Clone, Serialize, Deserialize)]
1169
#[serde(rename_all = "camelCase")]
1270
pub struct McpStatus {
@@ -339,6 +397,9 @@ pub fn set_mcp_servers_map(
339397
obj.remove("homepage");
340398
obj.remove("docs");
341399

400+
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
401+
wrap_command_for_windows(&mut obj);
402+
342403
out.insert(id.clone(), Value::Object(obj));
343404
}
344405

@@ -352,3 +413,136 @@ pub fn set_mcp_servers_map(
352413
write_json_value(&path, &root)?;
353414
Ok(())
354415
}
416+
417+
#[cfg(test)]
418+
mod tests {
419+
use super::*;
420+
use serde_json::json;
421+
422+
/// 测试 Windows 命令包装功能
423+
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
424+
#[test]
425+
fn test_wrap_command_for_windows_npx() {
426+
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
427+
.as_object()
428+
.unwrap()
429+
.clone();
430+
wrap_command_for_windows(&mut obj);
431+
432+
#[cfg(windows)]
433+
{
434+
assert_eq!(obj["command"], "cmd");
435+
assert_eq!(
436+
obj["args"],
437+
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
438+
);
439+
}
440+
441+
#[cfg(not(windows))]
442+
{
443+
// 非 Windows 平台不做任何处理
444+
assert_eq!(obj["command"], "npx");
445+
}
446+
}
447+
448+
#[test]
449+
fn test_wrap_command_for_windows_npm() {
450+
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
451+
.as_object()
452+
.unwrap()
453+
.clone();
454+
wrap_command_for_windows(&mut obj);
455+
456+
#[cfg(windows)]
457+
{
458+
assert_eq!(obj["command"], "cmd");
459+
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
460+
}
461+
}
462+
463+
#[test]
464+
fn test_wrap_command_for_windows_already_cmd() {
465+
// 已经是 cmd 的不应该重复包装
466+
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
467+
.as_object()
468+
.unwrap()
469+
.clone();
470+
wrap_command_for_windows(&mut obj);
471+
472+
assert_eq!(obj["command"], "cmd");
473+
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
474+
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
475+
}
476+
477+
#[test]
478+
fn test_wrap_command_for_windows_http_type_skipped() {
479+
// http 类型不应该被处理
480+
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
481+
.as_object()
482+
.unwrap()
483+
.clone();
484+
wrap_command_for_windows(&mut obj);
485+
486+
assert!(!obj.contains_key("command"));
487+
assert_eq!(obj["url"], "https://example.com/mcp");
488+
}
489+
490+
#[test]
491+
fn test_wrap_command_for_windows_other_command_skipped() {
492+
// 非目标命令(如 python)不应该被包装
493+
let mut obj = json!({"command": "python", "args": ["server.py"]})
494+
.as_object()
495+
.unwrap()
496+
.clone();
497+
wrap_command_for_windows(&mut obj);
498+
499+
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
500+
assert_eq!(obj["command"], "python");
501+
assert_eq!(obj["args"], json!(["server.py"]));
502+
}
503+
504+
#[test]
505+
fn test_wrap_command_for_windows_no_args() {
506+
// 没有 args 的情况
507+
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
508+
wrap_command_for_windows(&mut obj);
509+
510+
#[cfg(windows)]
511+
{
512+
assert_eq!(obj["command"], "cmd");
513+
assert_eq!(obj["args"], json!(["/c", "npx"]));
514+
}
515+
}
516+
517+
#[test]
518+
fn test_wrap_command_for_windows_with_cmd_suffix() {
519+
// 处理 npx.cmd 格式
520+
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
521+
.as_object()
522+
.unwrap()
523+
.clone();
524+
wrap_command_for_windows(&mut obj);
525+
526+
#[cfg(windows)]
527+
{
528+
assert_eq!(obj["command"], "cmd");
529+
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
530+
}
531+
}
532+
533+
#[test]
534+
fn test_wrap_command_for_windows_case_insensitive() {
535+
// 大小写不敏感
536+
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
537+
.as_object()
538+
.unwrap()
539+
.clone();
540+
wrap_command_for_windows(&mut obj);
541+
542+
#[cfg(windows)]
543+
{
544+
assert_eq!(obj["command"], "cmd");
545+
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
546+
}
547+
}
548+
}

0 commit comments

Comments
 (0)