Skip to content

Commit d97399d

Browse files
authored
Merge pull request Stack-Cairn#279 from inkdust2021/fix/mcp-windows-batch-spawn
fix(mcp): npx 等 .cmd 类 MCP 服务器在 Windows 上启动失败(os error 232)
2 parents 18f6ada + 29f2a24 commit d97399d

2 files changed

Lines changed: 127 additions & 10 deletions

File tree

  • .github/workflows
  • crates/agent-gui/src-tauri/src/commands/integration

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ jobs:
212212
CARGO_TERM_COLOR: always
213213
run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml shell_runner --lib
214214

215+
- name: Test Tauri MCP integration
216+
env:
217+
CARGO_TERM_COLOR: always
218+
run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml integration_commands::mcp --lib
219+
215220
mirror:
216221
name: GUI/WebUI Mirror Check
217222
runs-on: ubuntu-latest

crates/agent-gui/src-tauri/src/commands/integration/mcp.rs

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,23 @@ fn build_stdio_command(cmd: &str, args: &[String], cwd: Option<&Path>) -> Comman
7878
#[cfg(windows)]
7979
{
8080
if is_windows_batch_program(&program) {
81+
use std::os::windows::process::CommandExt;
82+
83+
// .cmd/.bat 无法被 CreateProcess 直接执行,需经 cmd.exe 转发。
84+
// /C 后的命令行必须用 raw_arg 原样传入:arg() 会按 MSVCRT 规则
85+
// 把内嵌引号转义成 `\"`,cmd.exe 不识别该转义,子进程瞬退,
86+
// stdin 写入报 os error 232(issue #205)。
87+
// /E:ON 保证命令扩展可用(`%%cd:~,` 防展开 hack 依赖它),
88+
// /V:OFF 关闭延迟展开,防止参数里的 `!VAR!` 被替换;
89+
// 均与 std `make_bat_command_line` 的 `/e:ON /v:OFF` 对齐。
8190
let mut command = Command::new("cmd.exe");
8291
command
92+
.arg("/E:ON")
93+
.arg("/V:OFF")
8394
.arg("/D")
8495
.arg("/S")
85-
.arg("/C")
86-
.arg(windows_batch_command_line(&program, args));
96+
.arg("/C");
97+
command.raw_arg(windows_cmd_c_argument(&program, args));
8798
return command;
8899
}
89100
}
@@ -93,27 +104,54 @@ fn build_stdio_command(cmd: &str, args: &[String], cwd: Option<&Path>) -> Comman
93104
command
94105
}
95106

96-
#[cfg(windows)]
107+
#[cfg_attr(not(windows), allow(dead_code))]
97108
fn is_windows_batch_program(path: &Path) -> bool {
98109
path.extension()
99110
.and_then(|ext| ext.to_str())
100111
.map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"))
101112
.unwrap_or(false)
102113
}
103114

104-
#[cfg(windows)]
105-
fn windows_batch_command_line(program: &Path, args: &[String]) -> String {
106-
std::iter::once(program.to_string_lossy().into_owned())
115+
/// 组装 `cmd.exe /S /C` 之后的整段命令行:外层再包一对引号,`/S` 语义下
116+
/// cmd 仅剥掉首尾引号,剩余部分按原样执行。
117+
#[cfg_attr(not(windows), allow(dead_code))]
118+
fn windows_cmd_c_argument(program: &Path, args: &[String]) -> String {
119+
let line = std::iter::once(program.to_string_lossy().into_owned())
107120
.chain(args.iter().cloned())
108121
.map(|value| windows_cmd_quote_arg(&value))
109122
.collect::<Vec<_>>()
110-
.join(" ")
123+
.join(" ");
124+
format!("\"{line}\"")
111125
}
112126

113-
#[cfg(windows)]
127+
/// 引号包裹单个参数,转义规则对齐 std `sys/args/windows.rs::append_bat_arg`:
128+
/// - 内嵌引号前的反斜杠补齐至 2n 再把引号翻倍(cmd.exe 不识别 `\"`);
129+
/// - 收尾引号前的尾部反斜杠同样翻倍,防止 `C:\dir\` 这类参数把闭合引号
130+
/// 转义掉、与后一个参数粘连;
131+
/// - `%`/`\r` 前插入 `%%cd:~,` no-op(yt-dlp hack,依赖 `/E:ON`),阻止
132+
/// `%VAR%` 被 cmd 当环境变量展开,子进程仍收到原文。
133+
#[cfg_attr(not(windows), allow(dead_code))]
114134
fn windows_cmd_quote_arg(value: &str) -> String {
115-
let escaped = value.replace('"', "\\\"");
116-
format!("\"{escaped}\"")
135+
let mut escaped = String::with_capacity(value.len() + 2);
136+
escaped.push('"');
137+
let mut backslashes = 0usize;
138+
for ch in value.chars() {
139+
if ch == '\\' {
140+
backslashes += 1;
141+
} else {
142+
if ch == '"' {
143+
escaped.extend(std::iter::repeat_n('\\', backslashes));
144+
escaped.push('"');
145+
} else if ch == '%' || ch == '\r' {
146+
escaped.push_str("%%cd:~,");
147+
}
148+
backslashes = 0;
149+
}
150+
escaped.push(ch);
151+
}
152+
escaped.extend(std::iter::repeat_n('\\', backslashes));
153+
escaped.push('"');
154+
escaped
117155
}
118156

119157
#[derive(Debug, Serialize)]
@@ -1970,4 +2008,78 @@ mod tests {
19702008
.expect("contender ensure eventually succeeds");
19712009
holder.join().expect("join holder");
19722010
}
2011+
2012+
#[test]
2013+
fn detects_windows_batch_programs_by_extension() {
2014+
assert!(is_windows_batch_program(Path::new(
2015+
r"C:\Program Files\nodejs\npx.cmd"
2016+
)));
2017+
assert!(is_windows_batch_program(Path::new(r"C:\tools\run.BAT")));
2018+
assert!(!is_windows_batch_program(Path::new(
2019+
r"C:\Program Files\nodejs\node.exe"
2020+
)));
2021+
assert!(!is_windows_batch_program(Path::new("npx")));
2022+
}
2023+
2024+
#[test]
2025+
fn windows_cmd_quote_arg_doubles_embedded_quotes() {
2026+
// cmd.exe 不认 `\"` 转义,翻倍才能保持引号配对。
2027+
assert_eq!(windows_cmd_quote_arg("-y"), r#""-y""#);
2028+
assert_eq!(windows_cmd_quote_arg(r#"a"b"#), r#""a""b""#);
2029+
assert_eq!(windows_cmd_quote_arg("with space"), r#""with space""#);
2030+
}
2031+
2032+
#[test]
2033+
fn windows_cmd_quote_arg_doubles_backslashes_before_quotes() {
2034+
// 内嵌引号前的反斜杠须补齐至 2n,重解析后还原为 n 个反斜杠 + 字面引号。
2035+
assert_eq!(windows_cmd_quote_arg(r#"a\"b"#), r#""a\\""b""#);
2036+
// 尾部反斜杠若不翻倍会把闭合引号转义掉,与后一个参数粘连。
2037+
assert_eq!(windows_cmd_quote_arg(r"C:\data\"), r#""C:\data\\""#);
2038+
// 非贴引号的反斜杠保持原样(路径分隔符不受影响)。
2039+
assert_eq!(windows_cmd_quote_arg(r"C:\a\b"), r#""C:\a\b""#);
2040+
assert_eq!(windows_cmd_quote_arg(""), r#""""#);
2041+
}
2042+
2043+
#[test]
2044+
fn windows_cmd_quote_arg_neutralizes_percent_expansion() {
2045+
// `%%cd:~,` no-op 打断 %VAR% 配对,cmd 展开后子进程仍收到原文。
2046+
assert_eq!(windows_cmd_quote_arg("%PATH%"), r#""%%cd:~,%PATH%%cd:~,%""#);
2047+
assert_eq!(windows_cmd_quote_arg("100%"), r#""100%%cd:~,%""#);
2048+
assert_eq!(windows_cmd_quote_arg("a\rb"), "\"a%%cd:~,\rb\"");
2049+
}
2050+
2051+
#[test]
2052+
fn windows_cmd_c_argument_wraps_whole_line_for_slash_s() {
2053+
// `/S` 语义:cmd 剥掉首尾引号后必须还原出可执行的完整命令行。
2054+
let program = Path::new(r"C:\Program Files\nodejs\npx.cmd");
2055+
let args = vec!["-y".to_string(), "@playwright/mcp".to_string()];
2056+
assert_eq!(
2057+
windows_cmd_c_argument(program, &args),
2058+
r#"""C:\Program Files\nodejs\npx.cmd" "-y" "@playwright/mcp"""#
2059+
);
2060+
}
2061+
2062+
#[test]
2063+
fn windows_cmd_c_argument_without_args_still_quotes_program() {
2064+
let program = Path::new(r"C:\tools\npx.cmd");
2065+
assert_eq!(
2066+
windows_cmd_c_argument(program, &[]),
2067+
r#"""C:\tools\npx.cmd"""#
2068+
);
2069+
}
2070+
2071+
#[test]
2072+
fn windows_cmd_c_argument_survives_trailing_backslash_arg() {
2073+
// filesystem 类 MCP server 常见传法:目录参数带尾部反斜杠。
2074+
let program = Path::new(r"C:\Program Files\nodejs\npx.cmd");
2075+
let args = vec![
2076+
"-y".to_string(),
2077+
"@modelcontextprotocol/server-filesystem".to_string(),
2078+
r"C:\Users\me\docs\".to_string(),
2079+
];
2080+
assert_eq!(
2081+
windows_cmd_c_argument(program, &args),
2082+
r#"""C:\Program Files\nodejs\npx.cmd" "-y" "@modelcontextprotocol/server-filesystem" "C:\Users\me\docs\\"""#
2083+
);
2084+
}
19732085
}

0 commit comments

Comments
 (0)