From aa8370253e428a874ff16c3077ac0940a9f59d51 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Wed, 26 Aug 2026 15:47:29 -0500 Subject: [PATCH 1/3] Launch Windows executables directly in stdio transport Bypass cmd.exe for rooted or existing .exe and .com commands so their arguments are passed without shell escaping, while preserving cmd.exe handling for shell-resolved commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/StdioClientTransport.cs | 31 ++++- .../Program.cs | 7 + .../Transport/StdioClientTransportTests.cs | 129 +++++++++++++++++- 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs index 2e44ee34f..03b7f68a9 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs @@ -63,11 +63,13 @@ public async Task ConnectAsync(CancellationToken cancellationToken = string command = _options.Command; IList? arguments = _options.Arguments; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && - !string.Equals(Path.GetFileName(command), "cmd.exe", StringComparison.OrdinalIgnoreCase)) + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + bool isCmd = string.Equals(Path.GetFileName(command), "cmd.exe", StringComparison.OrdinalIgnoreCase); + bool needsCmdEscaping = isWindows && isCmd; + if (isWindows && !isCmd && !ShouldLaunchDirectly(command)) { - // On Windows, for stdio, we need to wrap non-shell commands with cmd.exe /c {command} (usually npx or uvicorn). - // The stdio transport will not work correctly if the command is not run in a shell. + // Use cmd.exe for commands that require shell handling or resolution through PATH/PATHEXT. + needsCmdEscaping = true; arguments = arguments is null or [] ? ["/c", command] : ["/c", command, ..arguments]; command = "cmd.exe"; } @@ -98,13 +100,13 @@ public async Task ConnectAsync(CancellationToken cancellationToken = #if NET foreach (string arg in arguments) { - startInfo.ArgumentList.Add(EscapeArgumentString(arg)); + startInfo.ArgumentList.Add(needsCmdEscaping ? EscapeArgumentString(arg) : arg); } #else StringBuilder argsBuilder = new(); foreach (string arg in arguments) { - PasteArguments.AppendArgument(argsBuilder, EscapeArgumentString(arg)); + PasteArguments.AppendArgument(argsBuilder, needsCmdEscaping ? EscapeArgumentString(arg) : arg); } startInfo.Arguments = argsBuilder.ToString(); @@ -288,6 +290,23 @@ internal static bool HasExited(Process process) } } + private static bool ShouldLaunchDirectly(string command) + { + // CreateProcess can only launch real executable images directly, + // which in practice means .exe/.com. Anything else (.bat/.cmd) relies on cmd.exe + // resolving it via file association / PATHEXT, so it must keep going through the cmd.exe wrapper. + string extension = Path.GetExtension(command); + if (!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Match Process resolution on Unix: rooted paths are used as supplied, while relative + // paths are probed against the parent process's current directory. + return Path.IsPathRooted(command) || File.Exists(command); + } + private static string EscapeArgumentString(string argument) => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !ContainsWhitespaceRegex.IsMatch(argument) ? WindowsCliSpecialArgumentsRegex.Replace(argument, static match => "^" + match.Value) : diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 6812fe5d5..ee51bbae7 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -41,6 +41,13 @@ private static async Task Main(string[] args) return; } + if (args.Contains("--echo-cwd-and-exit")) + { + Console.Error.WriteLine($"CWD:{Environment.CurrentDirectory}"); + Console.Error.Flush(); + return; + } + Log.Logger.Information("Starting server..."); string? cliArg = ParseCliArgument(args); diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 60ce9cf5a..a0bc22dd8 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -165,13 +165,13 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue) Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { (true, _) => "mono", - (_, true) => testServerExecutable, + (_, true) => "cmd.exe", _ => "dotnet", }, Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { (true, _) => [testServerExecutable, "--echo-cli-arg-and-exit", cliArgument], - (_, true) => ["--echo-cli-arg-and-exit", cliArgument], + (_, true) => ["/c", testServerExecutable, "--echo-cli-arg-and-exit", cliArgument], _ => [testServerDll, "--echo-cli-arg-and-exit", cliArgument], }, StandardErrorLines = line => @@ -192,12 +192,117 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue) JsonElement parsedArgument = JsonElement.Parse(serializedArgument); Assert.Equal(cliArgumentValue ?? "", parsedArgument.GetString()); - var exception = await Assert.ThrowsAsync( - async () => await session.MessageReader.Completion.WaitAsync( + await AssertServerExitedCleanlyAsync(session); + } + + [Theory] + [InlineData("My Test Server.exe", true)] + [InlineData("My Test Server.com", false)] + [InlineData("my directory/my test server.exe", false)] + public async Task ExecutableWithSpacesHandledCorrectly(string relativeExecutablePath, bool useRootedPath) + { + const string OutputPrefix = "CLI_ARG:"; + const string CliArgumentValue = "42"; + + string rootDirectoryName = $"BypassCmd-{Guid.NewGuid():N}"; + string rootDirectory = Path.Combine(Environment.CurrentDirectory, rootDirectoryName); + string executablePath = Path.Combine(rootDirectory, relativeExecutablePath); + string executableDirectory = Path.GetDirectoryName(executablePath)!; + + Directory.CreateDirectory(executableDirectory); + try + { + foreach (string sourcePath in Directory.EnumerateFiles(AppContext.BaseDirectory)) + { + File.Copy(sourcePath, Path.Combine(executableDirectory, Path.GetFileName(sourcePath))); + } + + string sourceExecutablePath = Path.Combine(AppContext.BaseDirectory, PlatformDetection.IsWindows ? "TestServer.exe" : "TestServer"); + File.Copy(sourceExecutablePath, executablePath, overwrite: true); + + string command = useRootedPath ? executablePath : Path.Combine(rootDirectoryName, relativeExecutablePath); + + var capturedArgument = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var transport = new StdioClientTransport(new() + { + Name = "TestServer", + Command = command, + Arguments = ["--echo-cli-arg-and-exit", $"--cli-arg={CliArgumentValue}"], + StandardErrorLines = line => + { + if (line.StartsWith(OutputPrefix, StringComparison.Ordinal)) + { + capturedArgument.TrySetResult(line[OutputPrefix.Length..]); + } + }, + }, LoggerFactory); + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + string serializedArgument = await capturedArgument.Task.WaitAsync( TestConstants.DefaultTimeout, - TestContext.Current.CancellationToken)); - var completionDetails = Assert.IsType(exception.Details); - Assert.Equal(0, completionDetails.ExitCode); + TestContext.Current.CancellationToken); + Assert.Equal(CliArgumentValue, JsonElement.Parse(serializedArgument).GetString()); + + await AssertServerExitedCleanlyAsync(session); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task WorkingDirectory_IsUsedAsChildProcessCurrentDirectory() + { + const string OutputPrefix = "CWD:"; + string testServerExecutable = Path.Combine( + AppContext.BaseDirectory, + PlatformDetection.IsWindows ? "TestServer.exe" : "TestServer"); + + // A directory that does not contain the executable, so a successful launch also demonstrates the + // executable is located independently of WorkingDirectory. + string workingDirectory = Path.Combine(Path.GetTempPath(), $"McpStdioWorkingDir-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workingDirectory); + try + { + var capturedCwd = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + StdioClientTransportOptions options = new() + { + Name = "TestServer", + Command = testServerExecutable, + Arguments = ["--echo-cwd-and-exit"], + WorkingDirectory = workingDirectory, + StandardErrorLines = line => + { + if (line.StartsWith(OutputPrefix, StringComparison.Ordinal)) + { + capturedCwd.TrySetResult(line[OutputPrefix.Length..]); + } + }, + }; + + var transport = new StdioClientTransport(options, LoggerFactory); + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + string reportedCwd = (await capturedCwd.Task.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken)).Trim(); + + // The child reports the working directory we set. Compare with EndsWith because some platforms + // (e.g. macOS) report the temp path through its resolved location (/var -> /private/var). + char[] separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + Assert.EndsWith( + workingDirectory.TrimEnd(separators), + reportedCwd.TrimEnd(separators), + PlatformDetection.IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + await AssertServerExitedCleanlyAsync(session); + } + finally + { + Directory.Delete(workingDirectory, recursive: true); + } } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] @@ -431,4 +536,14 @@ public async Task ReadMessagesAsync_Should_Accept_CRLF_Delimited_Messages() Assert.IsType(readMessage); Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString()); } + + private static async Task AssertServerExitedCleanlyAsync(ITransport session) + { + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken)); + var completionDetails = Assert.IsType(exception.Details); + Assert.Equal(0, completionDetails.ExitCode); + } } From 0bff51fa597953e0f8d7bf6f0e021bbd70701a32 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Thu, 27 Aug 2026 10:40:47 -0500 Subject: [PATCH 2/3] Fix Windows executable path test staging Stage the complete test server payload so runtime-specific assets and .NET Framework binding redirects remain available when the executable is renamed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ModelContextProtocol.Tests.csproj | 4 ++++ .../Transport/StdioClientTransportTests.cs | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 677d77357..2a21c0093 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -93,6 +93,10 @@ PreserveNewest + + ExecutableWithSpacesHandledCorrectly\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index a0bc22dd8..b7f0feb62 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -209,24 +209,31 @@ public async Task ExecutableWithSpacesHandledCorrectly(string relativeExecutable string executablePath = Path.Combine(rootDirectory, relativeExecutablePath); string executableDirectory = Path.GetDirectoryName(executablePath)!; - Directory.CreateDirectory(executableDirectory); try { - foreach (string sourcePath in Directory.EnumerateFiles(AppContext.BaseDirectory)) + string sourceDirectory = Path.Combine(AppContext.BaseDirectory, nameof(ExecutableWithSpacesHandledCorrectly)) + Path.DirectorySeparatorChar; + foreach (string sourcePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) { - File.Copy(sourcePath, Path.Combine(executableDirectory, Path.GetFileName(sourcePath))); + string destinationPath = Path.Combine(executableDirectory, sourcePath[sourceDirectory.Length..]); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(sourcePath, destinationPath); } - string sourceExecutablePath = Path.Combine(AppContext.BaseDirectory, PlatformDetection.IsWindows ? "TestServer.exe" : "TestServer"); + string sourceExecutablePath = Path.Combine(sourceDirectory, PlatformDetection.IsWindows ? "TestServer.exe" : "TestServer"); File.Copy(sourceExecutablePath, executablePath, overwrite: true); - string command = useRootedPath ? executablePath : Path.Combine(rootDirectoryName, relativeExecutablePath); + // .NET Framework loads binding redirects from a configuration file matching the executable's name. + string sourceConfigurationPath = sourceExecutablePath + ".config"; + if (File.Exists(sourceConfigurationPath)) + { + File.Copy(sourceConfigurationPath, executablePath + ".config"); + } var capturedArgument = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var transport = new StdioClientTransport(new() { Name = "TestServer", - Command = command, + Command = useRootedPath ? executablePath : Path.Combine(rootDirectoryName, relativeExecutablePath), Arguments = ["--echo-cli-arg-and-exit", $"--cli-arg={CliArgumentValue}"], StandardErrorLines = line => { From 9e7c8a99414e42f6e28e52f35e1f76a6d734e2bc Mon Sep 17 00:00:00 2001 From: David Cantu Date: Thu, 27 Aug 2026 11:20:27 -0500 Subject: [PATCH 3/3] ignore UnauthorizedAccessException --- .../Transport/StdioClientTransportTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index b7f0feb62..9c3538f33 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -255,7 +255,11 @@ public async Task ExecutableWithSpacesHandledCorrectly(string relativeExecutable } finally { - Directory.Delete(rootDirectory, recursive: true); + try + { + Directory.Delete(rootDirectory, recursive: true); + } + catch (UnauthorizedAccessException) { } } }