From d386302c3857823f8967022c32b323384a63ff14 Mon Sep 17 00:00:00 2001 From: Ella Hathaway <67609881+ellahathaway@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:07:58 -0800 Subject: [PATCH] Fix MCP doctor timeout on Windows (#21882) ## Description On Windows, the MCP `doctor` tool can time out while checking Aspire skills. Git inherits the MCP server's standard input pipe and can hang while the server is already reading from that pipe. Git discovery commands now receive a null input handle instead of inheriting MCP's input. Output capture, cancellation, and existing fallback behavior remain unchanged. No new dependencies are required. ### Testing Added one Windows regression test that calls Git through a real stdio MCP server in an isolated child process. - **Regression red/green:** the new test times out without the fix and passes with it. It also passed three additional runs. - **Git tests:** all five tests passed with this command: - **Actual MCP doctor:** before the fix, the skills check hit its 30-second timeout. After the fix, it completed without that timeout. Direct JSON doctor also completed with exit code 0. Fixes #21877 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Git/GitRepository.cs | 5 ++ .../Git/GitRepositoryTests.cs | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+) 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() {