@@ -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\n exit 0" ) ?;
595+ }
596+
597+ #[ cfg( unix) ]
598+ {
599+ let file = dir. join ( Self :: TEST_PROGRAM ) ;
600+ fs:: write ( & file, "#!/bin/sh\n exit 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