Skip to content

Commit cabf71a

Browse files
authored
feat(transport): add which_command for cross-platform executable resolution (#774)
* feat(transport): add which_command for cross-platform executable resolution Adds a `which_command()` helper that resolves executable paths via the `which` crate before constructing a `tokio::process::Command`. This fixes Windows failures where `.cmd` shim scripts (e.g. `npx.cmd`) are not found by `Command::new()` without a fully-qualified path. Closes #456 * refactor(transport): move which_command behind opt-in feature flag Address review feedback: the `which` dependency is now gated behind a separate `which-command` feature flag instead of being bundled into `transport-child-process`. Users on Linux/macOS who don't need cross-platform executable resolution no longer pull in the extra crate. Also fixes the doc example import path to use the re-exported `rmcp::transport::which_command`.
1 parent 8e22aa2 commit cabf71a

3 files changed

Lines changed: 59 additions & 0 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ tower-service = { version = "0.3", optional = true }
8181
# for child process transport
8282
process-wrap = { version = "9.0", features = ["tokio1"], optional = true }
8383

84+
# for cross-platform executable path resolution
85+
which = { version = "7", optional = true }
86+
8487
# for ws transport
8588
# tokio-tungstenite ={ version = "0.26", optional = true }
8689

@@ -163,6 +166,10 @@ transport-child-process = [
163166
"tokio/process",
164167
"dep:process-wrap",
165168
]
169+
which-command = [
170+
"transport-child-process",
171+
"dep:which",
172+
]
166173
transport-streamable-http-server = [
167174
"transport-streamable-http-server-session",
168175
"server-side-http",

crates/rmcp/src/transport.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ pub use worker::WorkerTransport;
8383

8484
#[cfg(feature = "transport-child-process")]
8585
pub mod child_process;
86+
#[cfg(feature = "which-command")]
87+
pub use child_process::which_command;
8688
#[cfg(feature = "transport-child-process")]
8789
pub use child_process::{ConfigureCommandExt, TokioChildProcess};
8890

crates/rmcp/src/transport/child_process.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,56 @@ impl ConfigureCommandExt for tokio::process::Command {
233233
}
234234
}
235235

236+
/// Resolve the absolute path to an executable using the system `PATH`,
237+
/// then return a [`tokio::process::Command`] pointing at it.
238+
///
239+
/// This is especially useful on Windows where `.cmd` / `.exe` shim scripts
240+
/// (e.g. `npx.cmd`) are not reliably found by [`tokio::process::Command`]
241+
/// without a fully-qualified path.
242+
///
243+
/// # Example
244+
/// ```rust,no_run
245+
/// use rmcp::transport::{which_command, ConfigureCommandExt};
246+
///
247+
/// # fn example() -> std::io::Result<()> {
248+
/// let cmd = which_command("npx")?
249+
/// .configure(|cmd| {
250+
/// cmd.arg("-y").arg("@modelcontextprotocol/server-everything");
251+
/// });
252+
/// # Ok(())
253+
/// # }
254+
/// ```
255+
#[cfg(feature = "which-command")]
256+
pub fn which_command(
257+
name: impl AsRef<std::ffi::OsStr>,
258+
) -> std::io::Result<tokio::process::Command> {
259+
let resolved = which::which(name.as_ref())
260+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
261+
Ok(tokio::process::Command::new(resolved))
262+
}
263+
264+
#[cfg(feature = "which-command")]
265+
#[cfg(test)]
266+
mod tests_which {
267+
#[test]
268+
fn which_command_resolves_known_binary() {
269+
// `ls` exists on every Unix system, `cmd` on Windows
270+
#[cfg(unix)]
271+
let result = super::which_command("ls");
272+
#[cfg(windows)]
273+
let result = super::which_command("cmd");
274+
275+
assert!(result.is_ok());
276+
}
277+
278+
#[test]
279+
fn which_command_fails_for_nonexistent() {
280+
let result = super::which_command("this_binary_definitely_does_not_exist_12345");
281+
assert!(result.is_err());
282+
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
283+
}
284+
}
285+
236286
#[cfg(unix)]
237287
#[cfg(test)]
238288
mod tests {

0 commit comments

Comments
 (0)