Skip to content
Merged
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
15 changes: 13 additions & 2 deletions crates/tinymcp/src/transport/stdio/spawn_env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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<PathBuf> {
let mut candidates = Vec::new();
for extension in extensions.split(';') {
let extension = extension.trim();
if extension.is_empty() {
Expand All @@ -364,7 +375,7 @@ fn executable_candidates(base: PathBuf) -> Vec<PathBuf> {
name.push(extension);
candidates.push(PathBuf::from(name));
}

candidates.push(base);
candidates
}

Expand Down
30 changes: 29 additions & 1 deletion crates/tinymcp/src/transport/stdio/spawn_env/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<PathBuf> = ["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());
Expand Down
Loading