Skip to content

The abstract method uses which under the hood. #456

@AdsQnn

Description

@AdsQnn

🪟 Windows Path Resolution Problem in tokio::process::Command

On Windows, launching a process like this:

use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}};
use tokio::process::Command;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = ().serve(TokioChildProcess::new(Command::new("npx").configure(|cmd| {
        cmd.arg("-y").arg("@modelcontextprotocol/server-everything");
    }))?).await?;
    Ok(())
}

can fail, because:

  • Unlike Linux or macOS, Windows does not reliably resolve executables via the PATH environment variable in tokio::process::Command.

  • Many tools like npx are .cmd shim scripts, often stored in locations like:

    C:\Users\<User>\AppData\Roaming\npm\npx.cmd
    
  • Without the full executable path, you’ll get runtime errors like:

    The system cannot find the file specified. (os error 2)
    

Why Use [which](https://docs.rs/which/latest/which/)

The which crate resolves the absolute path to an executable:

  • On Linux/macOS, it works like the native which command in a shell.
  • On Windows, it handles .cmd / .exe resolution and searches PATH correctly.

By wrapping this logic in a high-level abstraction, your library can work seamlessly across platforms without requiring users to handle these quirks manually.


💡 Solution — A Builder That Hides which Internally

Below is a minimal CmdBuilder that:

  1. Automatically resolves the executable path using which under the hood.
  2. Provides a fluent API with .arg(...) chaining for adding arguments.
  3. Offers .configure(...) for low-level full control over the Command.
  4. Returns a ready-to-use TokioChildProcess.
use which::which;
use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}};
use tokio::process::Command;
use std::error::Error;

struct CmdBuilder {
    command: Command,
}

impl CmdBuilder {
    /// Creates the builder and resolves the full program path (important on Windows).
    fn new(name: &str) -> Result<Self, Box<dyn Error>> {
        let path = which(name)?;
        Ok(Self { command: Command::new(path) })
    }

    /// Adds an argument (chainable).
    fn arg(mut self, arg: &str) -> Self {
        self.command.arg(arg);
        self
    }

    /// Allows full, low-level access to `Command`.
    fn configure<F>(mut self, f: F) -> Self
    where
        F: FnOnce(&mut Command),
    {
        f(&mut self.command);
        self
    }

    /// Finalizes and returns a `TokioChildProcess`.
    fn build(self) -> TokioChildProcess<Command> {
        TokioChildProcess::new(self.command)
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let client = ().serve(
        CmdBuilder::new("npx")?
            .arg("-y")
            .arg("@modelcontextprotocol/server-everything")
            .configure(|cmd| {
                // Optional: add environment variables or other settings
                cmd.env("MY_ENV_VAR", "123");
            })
            .build(),
    ).await?;

    Ok(())
}

⚠️ Disclaimer

This code is provisional and not a production-ready solution. It’s only meant to illustrate an idea for solving cross-platform executable path issues.

Cheers and good luck !

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium: important but non-blocking improvementT-transportTransport layer changesbugSomething is not workingneeds reproBug report that needs a minimal reproduction case

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions