Skip to content

Commit 5c75710

Browse files
committed
pr3828
1 parent 2030b28 commit 5c75710

File tree

3 files changed

+224
-2
lines changed

3 files changed

+224
-2
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/rmcp-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ tokio = { workspace = true, features = [
5656
tracing = { workspace = true, features = ["log"] }
5757
urlencoding = { workspace = true }
5858
webbrowser = { workspace = true }
59+
which = { workspace = true }
5960

6061
[dev-dependencies]
6162
escargot = { workspace = true }

codex-rs/rmcp-client/src/rmcp_client.rs

Lines changed: 222 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,20 @@ impl RmcpClient {
9191
cwd: Option<PathBuf>,
9292
) -> io::Result<Self> {
9393
let program_name = program.to_string_lossy().into_owned();
94-
let mut command = Command::new(&program);
94+
95+
// Build environment for program resolution and subprocess
96+
let envs = create_env_for_mcp_server(env, env_vars);
97+
98+
// Resolve program to executable path (platform-specific)
99+
let resolved_program = program_resolver::resolve(program, &envs)?;
100+
101+
let mut command = Command::new(resolved_program);
95102
command
96103
.kill_on_drop(true)
97104
.stdin(Stdio::piped())
98105
.stdout(Stdio::piped())
99106
.env_clear()
100-
.envs(create_env_for_mcp_server(env, env_vars))
107+
.envs(envs)
101108
.args(&args);
102109
if let Some(cwd) = cwd {
103110
command.current_dir(cwd);
@@ -412,3 +419,216 @@ async fn create_oauth_transport_and_runtime(
412419

413420
Ok((transport, runtime))
414421
}
422+
423+
/// Platform-specific program resolution for MCP server execution.
424+
///
425+
/// Resolves executable paths differently based on the operating system:
426+
/// - **Windows**: Uses `which` crate to find executables including scripts (.cmd, .bat)
427+
/// - **Unix**: Returns the program unchanged (native PATH resolution handles it)
428+
mod program_resolver {
429+
use super::*;
430+
use tracing::debug;
431+
432+
/// Resolves a program to its executable path for the current platform.
433+
///
434+
/// On Windows, `Command::new()` cannot execute scripts (e.g., `.cmd`, `.bat`)
435+
/// directly without their extension. This function uses the `which` crate to
436+
/// search the `PATH` and find the full path to the executable, including
437+
/// any necessary script extensions defined in `PATHEXT`.
438+
///
439+
/// On Unix, the kernel handles script execution natively (via shebangs), so
440+
/// this function is a no-op and returns the program name unchanged.
441+
#[cfg(windows)]
442+
pub fn resolve(program: OsString, env: &HashMap<String, String>) -> std::io::Result<OsString> {
443+
use std::env;
444+
445+
// Get current directory for relative path resolution
446+
let cwd = env::current_dir()
447+
.map_err(|e| std::io::Error::other(format!("Failed to get current directory: {e}")))?;
448+
449+
// Extract PATH from environment for search locations
450+
let search_path = env.get("PATH");
451+
452+
// Attempt resolution via which crate
453+
match which::which_in(&program, search_path, &cwd) {
454+
Ok(resolved) => {
455+
debug!("Resolved {:?} to {:?}", program, resolved);
456+
Ok(resolved.into_os_string())
457+
}
458+
Err(e) => {
459+
debug!(
460+
"Failed to resolve {:?}: {}. Using original path",
461+
program, e
462+
);
463+
// Fallback to original program - let Command::new() handle the error
464+
Ok(program)
465+
}
466+
}
467+
}
468+
469+
/// Unix systems handle PATH resolution natively.
470+
///
471+
/// The OS can execute scripts directly, so no resolution needed.
472+
#[cfg(unix)]
473+
pub fn resolve(program: OsString, _env: &HashMap<String, String>) -> std::io::Result<OsString> {
474+
Ok(program)
475+
}
476+
}
477+
478+
#[cfg(test)]
479+
mod tests {
480+
use super::program_resolver;
481+
use super::*;
482+
use anyhow::Result;
483+
use std::collections::HashMap;
484+
use std::fs;
485+
use std::path::Path;
486+
use tempfile::TempDir;
487+
488+
/// Unix: Verifies the OS handles script execution without file extensions.
489+
#[cfg(unix)]
490+
#[tokio::test]
491+
async fn test_unix_executes_script_without_extension() -> Result<()> {
492+
let env = TestExecutableEnv::new()?;
493+
let mut cmd = Command::new(&env.program_name);
494+
cmd.envs(&env.mcp_env);
495+
496+
let output = cmd.output().await;
497+
assert!(output.is_ok(), "Unix should execute scripts directly");
498+
Ok(())
499+
}
500+
501+
/// Windows: Verifies scripts fail to execute without the proper extension.
502+
#[cfg(windows)]
503+
#[tokio::test]
504+
async fn test_windows_fails_without_extension() -> Result<()> {
505+
let env = TestExecutableEnv::new()?;
506+
let mut cmd = Command::new(&env.program_name);
507+
cmd.envs(&env.mcp_env);
508+
509+
let output = cmd.output().await;
510+
assert!(
511+
output.is_err(),
512+
"Windows requires .cmd/.bat extension for direct execution"
513+
);
514+
Ok(())
515+
}
516+
517+
/// Windows: Verifies scripts with an explicit extension execute correctly.
518+
#[cfg(windows)]
519+
#[tokio::test]
520+
async fn test_windows_succeeds_with_extension() -> Result<()> {
521+
let env = TestExecutableEnv::new()?;
522+
// Append the `.cmd` extension to the program name
523+
let program_with_ext = format!("{}.cmd", env.program_name);
524+
let mut cmd = Command::new(&program_with_ext);
525+
cmd.envs(&env.mcp_env);
526+
527+
let output = cmd.output().await;
528+
assert!(
529+
output.is_ok(),
530+
"Windows should execute scripts when the extension is provided"
531+
);
532+
Ok(())
533+
}
534+
535+
/// Verifies program resolution enables successful execution on all platforms.
536+
#[tokio::test]
537+
async fn test_resolved_program_executes_successfully() -> Result<()> {
538+
let env = TestExecutableEnv::new()?;
539+
let program = OsString::from(&env.program_name);
540+
541+
// Apply platform-specific resolution
542+
let resolved = program_resolver::resolve(program, &env.mcp_env)?;
543+
544+
// Verify resolved path executes successfully
545+
let mut cmd = Command::new(resolved);
546+
cmd.envs(&env.mcp_env);
547+
let output = cmd.output().await;
548+
549+
assert!(
550+
output.is_ok(),
551+
"Resolved program should execute successfully"
552+
);
553+
Ok(())
554+
}
555+
556+
// Test fixture for creating temporary executables in a controlled environment.
557+
struct TestExecutableEnv {
558+
// Held to prevent the temporary directory from being deleted.
559+
_temp_dir: TempDir,
560+
program_name: String,
561+
mcp_env: HashMap<String, String>,
562+
}
563+
564+
impl TestExecutableEnv {
565+
const TEST_PROGRAM: &'static str = "test_mcp_server";
566+
567+
fn new() -> Result<Self> {
568+
let temp_dir = TempDir::new()?;
569+
let dir_path = temp_dir.path();
570+
571+
Self::create_executable(dir_path)?;
572+
573+
// Build a clean environment with the temp dir in the PATH.
574+
let mut extra_env = HashMap::new();
575+
extra_env.insert("PATH".to_string(), Self::build_path(dir_path));
576+
577+
#[cfg(windows)]
578+
extra_env.insert("PATHEXT".to_string(), Self::ensure_cmd_extension());
579+
580+
let mcp_env = create_env_for_mcp_server(Some(extra_env), &[]);
581+
582+
Ok(Self {
583+
_temp_dir: temp_dir,
584+
program_name: Self::TEST_PROGRAM.to_string(),
585+
mcp_env,
586+
})
587+
}
588+
589+
/// Creates a simple, platform-specific executable script.
590+
fn create_executable(dir: &Path) -> Result<()> {
591+
#[cfg(windows)]
592+
{
593+
let file = dir.join(format!("{}.cmd", Self::TEST_PROGRAM));
594+
fs::write(&file, "@echo off\nexit 0")?;
595+
}
596+
597+
#[cfg(unix)]
598+
{
599+
let file = dir.join(Self::TEST_PROGRAM);
600+
fs::write(&file, "#!/bin/sh\nexit 0")?;
601+
Self::set_executable(&file)?;
602+
}
603+
604+
Ok(())
605+
}
606+
607+
#[cfg(unix)]
608+
fn set_executable(path: &Path) -> Result<()> {
609+
use std::os::unix::fs::PermissionsExt;
610+
let mut perms = fs::metadata(path)?.permissions();
611+
perms.set_mode(0o755);
612+
fs::set_permissions(path, perms)?;
613+
Ok(())
614+
}
615+
616+
/// Prepends the given directory to the system's PATH variable.
617+
fn build_path(dir: &Path) -> String {
618+
let current = std::env::var("PATH").unwrap_or_default();
619+
let sep = if cfg!(windows) { ";" } else { ":" };
620+
format!("{}{sep}{current}", dir.to_string_lossy())
621+
}
622+
623+
/// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery.
624+
#[cfg(windows)]
625+
fn ensure_cmd_extension() -> String {
626+
let current = std::env::var("PATHEXT").unwrap_or_default();
627+
if current.to_uppercase().contains(".CMD") {
628+
current
629+
} else {
630+
format!(".CMD;{current}")
631+
}
632+
}
633+
}
634+
}

0 commit comments

Comments
 (0)