diff --git a/src/Aspire.Cli/Git/GitRepository.cs b/src/Aspire.Cli/Git/GitRepository.cs index 4e6eec6ecba..f998a8de07c 100644 --- a/src/Aspire.Cli/Git/GitRepository.cs +++ b/src/Aspire.Cli/Git/GitRepository.cs @@ -136,6 +136,11 @@ private static ProcessStartInfo CreateGitStartInfo(DirectoryInfo workingDirector private async Task RunGitAsync(ProcessStartInfo startInfo, ProfilingTelemetry.ActivityScope activity, CancellationToken cancellationToken) { + // Git can block on Windows if it inherits the MCP stdin pipe while the server is reading it. + // https://github.com/microsoft/aspire/issues/21877 + using var standardInput = File.OpenNullHandle(); + startInfo.StandardInputHandle = standardInput; + // RunAndCaptureTextAsync drains stdout and stderr concurrently, and kills git when // cancellationToken fires so Ctrl+C doesn't leave `git ls-files` walking a large repo. var result = await Process.RunAndCaptureTextAsync(startInfo, cancellationToken).ConfigureAwait(false); diff --git a/tests/Aspire.Cli.Tests/Git/GitRepositoryTests.cs b/tests/Aspire.Cli.Tests/Git/GitRepositoryTests.cs index bafbf3d3ee1..e95aa6ca08b 100644 --- a/tests/Aspire.Cli.Tests/Git/GitRepositoryTests.cs +++ b/tests/Aspire.Cli.Tests/Git/GitRepositoryTests.cs @@ -4,13 +4,19 @@ using System.Collections.Concurrent; using System.Diagnostics; using Aspire.Cli.Git; +using Aspire.Cli.Mcp; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Utils; using Aspire.Tests; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace Aspire.Cli.Tests.Git; @@ -51,6 +57,65 @@ public async Task GetIncludedFilesAsync_LogsGitOutput(bool initialized) } } + [Fact] + [PlatformSpecific(TestPlatforms.Windows)] + public async Task GetRootAsync_WhileMcpReadsStandardInput_Completes() + { + await GitTestHelper.EnsureGitAvailableAsync(outputHelper); + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + await workspace.InitializeGitAsync(); + var options = new RemoteInvokeOptions + { + StartInfo = { RedirectStandardInput = true, RedirectStandardOutput = true } + }; + options.StartInfo.Environment.Remove("GIT_REDIRECT_STDIN"); + + // A real stdio server reproduces the inherited pipe read without changing the test host's stdin. + using var remote = RemoteExecutor.Invoke(static async directoryPath => + { + var directory = new DirectoryInfo(directoryPath); + var context = new CliExecutionContext(directory, directory, directory, directory, directory, "unused.log", "local"); + using var profilingTelemetry = CreateProfilingTelemetry(); + var repository = new GitRepository(context, new TestEnvironment(), NullLogger.Instance, profilingTelemetry); + var serverOptions = new McpServerOptions + { + ServerInfo = new() { Name = "git-test", Version = "1.0" }, + Handlers = new() + { + CallToolHandler = async (_, cancellationToken) => + { + var root = await repository.GetRootAsync(cancellationToken); + return new CallToolResult + { + Content = [new TextContentBlock { Text = root?.FullName ?? string.Empty }] + }; + } + } + }; + var transport = new StdioMcpTransportFactory(NullLoggerFactory.Instance).CreateTransport(); + await using var server = McpServer.Create(transport, serverOptions); + await server.RunAsync(); + }, workspace.WorkspaceRoot.FullName, options); + + try + { + var clientTransport = new StreamClientTransport( + serverInput: remote.Process.StandardInput.BaseStream, + serverOutput: remote.Process.StandardOutput.BaseStream); + await using var client = await McpClient.CreateAsync(clientTransport).DefaultTimeout(); + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeoutDuration); + var result = await client.CallToolAsync("git_root", cancellationToken: cts.Token); + + Assert.False(result.IsError ?? false); + Assert.Collection(result.Content, + block => Assert.Equal(workspace.WorkspaceRoot.FullName, Assert.IsType(block).Text)); + } + finally + { + remote.Process.StandardInput.Close(); + } + } + [Fact] public async Task GetIncludedFilesAsync_OutsideRepo_ReturnsNull() {