From 22c0eb889b3b062912e603a06db6f8434de4f6e1 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:11:22 +0530 Subject: [PATCH] Resolve a bare Windows command through PATHEXT before the bare name executable_candidates tried the bare name first on Windows, and any file counts as executable off Unix. Node installs an extensionless npx shell script beside npx.cmd, so a stdio server with command npx resolved to the script, and CreateProcessW refused it with os error 193 (%1 is not a valid Win32 application). That is the most common stdio MCP config. Try every PATHEXT variant first and the bare name last, so npx resolves to npx.cmd while a real extensionless executable with nothing beside it still resolves. pathext_candidates holds the ordering so it is tested on every platform; a Windows-only test checks the npx/npx.cmd case on disk. Closes #168 --- .../src/transport/stdio/spawn_env/mod.rs | 15 ++++++++-- .../transport/stdio/spawn_env/mod_tests.rs | 30 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) 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());