diff --git a/crates/tinymcp/src/transport/stdio/spawn_env/mod.rs b/crates/tinymcp/src/transport/stdio/spawn_env/mod.rs index 4d50d05f..b82a7d70 100644 --- a/crates/tinymcp/src/transport/stdio/spawn_env/mod.rs +++ b/crates/tinymcp/src/transport/stdio/spawn_env/mod.rs @@ -352,9 +352,20 @@ fn is_executable_file(path: &Path) -> bool { /// Every filename the operating system would try for `base`. #[cfg(windows)] fn executable_candidates(base: PathBuf) -> Vec { - let mut candidates = vec![base.clone()]; let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + pathext_candidates(base, &extensions) +} +/// `base` with each `PATHEXT` extension appended, then `base` itself. +/// +/// The bare name comes last. Node installs an extensionless `npx` shell script +/// beside `npx.cmd`, and `CreateProcessW` refuses the script with os error 193, +/// so taking the bare name first broke the most common stdio config, a command +/// of `npx` (#168). A real extensionless executable still resolves when nothing +/// with an extension sits beside it. +#[cfg(any(windows, test))] +fn pathext_candidates(base: PathBuf, extensions: &str) -> Vec { + let mut candidates = Vec::new(); for extension in extensions.split(';') { let extension = extension.trim(); if extension.is_empty() { @@ -364,7 +375,7 @@ fn executable_candidates(base: PathBuf) -> Vec { name.push(extension); candidates.push(PathBuf::from(name)); } - + candidates.push(base); candidates } diff --git a/crates/tinymcp/src/transport/stdio/spawn_env/mod_tests.rs b/crates/tinymcp/src/transport/stdio/spawn_env/mod_tests.rs index ff692bbd..67d7638b 100644 --- a/crates/tinymcp/src/transport/stdio/spawn_env/mod_tests.rs +++ b/crates/tinymcp/src/transport/stdio/spawn_env/mod_tests.rs @@ -10,7 +10,8 @@ use super::{ PATH_MARK_END, PATH_MARK_START, PATH_SEP, join_dirs, locate_command, merge_path_strings, - missing_command_error, order_sources, parse_marked_path, parse_version, spawn_path, + missing_command_error, order_sources, parse_marked_path, parse_version, pathext_candidates, + spawn_path, }; use std::io::Write; use std::path::{Path, PathBuf}; @@ -174,6 +175,33 @@ fn a_non_numeric_version_parses_to_nothing() { // locate_command // --------------------------------------------------------------------------- +#[test] +fn pathext_variants_come_before_the_bare_name() { + let candidates = pathext_candidates(PathBuf::from("bin").join("npx"), ".COM;.EXE; ;.BAT;.CMD"); + let expected: Vec = ["npx.COM", "npx.EXE", "npx.BAT", "npx.CMD", "npx"] + .iter() + .map(|name| PathBuf::from("bin").join(name)) + .collect(); + assert_eq!(candidates, expected); +} + +/// Node puts an extensionless `npx` shell script beside `npx.cmd`. The script +/// cannot be spawned on Windows, so the `.cmd` has to win (#168). +#[cfg(windows)] +#[test] +fn a_bare_name_prefers_its_pathext_variant_over_an_extensionless_script() { + let directory = tempfile::tempdir().unwrap(); + write_file(&directory.path().join("npx"), true); + write_file(&directory.path().join("npx.cmd"), true); + + let found = locate_command("npx", &directory.path().to_string_lossy(), None).unwrap(); + let extension = found.extension().and_then(|extension| extension.to_str()); + assert!( + extension.is_some_and(|extension| extension.eq_ignore_ascii_case("cmd")), + "{found:?}" + ); +} + #[test] fn an_empty_command_resolves_to_nothing() { assert!(locate_command("", "/usr/bin", None).is_none());