Skip to content

The abstract method uses which under the hood. #456

Closed

Description

@adsqx

🪟 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 !

Activity

  1. jokemanfire commented on Sep 30, 2025

    @jokemanfire
    Member

    Could this idea be proposed in the tokio:: process:: Command library?

  2. adsqx commented on Sep 30, 2025

    @adsqx
    Author

    I think definitely yes, especially since Rust was designed to be maximally portable.

    Normally, at the system level, you need where for Windows and which for Unix/Linux, etc.

    Of course, you can always implement this handling yourself if you don’t want to rely on the existing which.

    But overall, the situation is quite simple, so I’m not sure it makes sense to reinvent the wheel—just use which.

    If you want to write truly multiplatform code, you practically have to repeat this manually anyway, so I think it would make sense to have it included in Tokio.

    I just noticed that with many libraries, for example Codex, there’s a problem where calling MCP doesn’t work on Windows, and instead of using npx users have to provide the full path. That’s why it seems to be a common issue that could be solved quite easily.

    However, it’s hard for me to clearly determine what exactly the authors of Tokio are guided by and what their goals are, or whether this is in some way contradictory.

    It simply seems neutral here, or even helpful.

    It’s just that in that case low-level implementation might be justified. Here, however, I don’t really see a reason why anyone would want to intervene on such a low level. More likely, whenever someone builds this kind of MCP handling, they’ll want it to be multiplatform rather than tied to a dedicated system. Otherwise, it just becomes a gap for errors if someone doesn’t know how to do it properly. And in practice, it’s almost always required for things to actually work.

    So Tokio is a general-purpose library, and someone might not want to use it for certain reasons in a specific project. This library, however, is a high-level tool for building MCP, so there’s really no reason to operate on that level here

  3. added
    bugSomething is not working
    P2Medium: important but non-blocking improvement
    T-transportTransport layer changes
    needs reproBug report that needs a minimal reproduction case
    on Feb 26, 2026
  4. alexhancock commented on Feb 27, 2026

    @alexhancock
    Contributor

    I'd entertain a PR!

  5. kakarot-dev commented on Mar 18, 2026

    @kakarot-dev
    Contributor

    Hey, I'd like to take a crack at this!
    I'm thinking a single helper function would be cleaner than a full builder — keeps things minimal and doesn't break existing usage:

      pub fn which_command(name: impl AsRef<OsStr>) -> std::io::Result<Command> {
          let resolved = which::which(name.as_ref())
              .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
          Ok(Command::new(resolved))
      }
    

    So instead of Command::new("npx") you'd do which_command("npx")? — same API from there, works with .configure() and everything. Just resolves the path first so Windows .cmd shims don't blow up.

    Would add which as an optional dep behind transport-child-process, update the npx example, and add a test.

    Does this work for you @alexhancock

  6. added a commit that references this issue on Mar 25, 2026
    98ab478
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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