File: Mcp\AppHostConnectionSelectionLogicTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.Backchannel;
using Aspire.Cli.Tests.TestServices;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
 
namespace Aspire.Cli.Tests.Mcp;
 
public class AppHostConnectionSelectionLogicTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void AuxiliaryBackchannelMonitorContract_DoesNotExposeFailOpenSelectionShortcuts()
    {
        Assert.Equal(
            ["Connections", "SelectedAppHostPath"],
            typeof(IAuxiliaryBackchannelMonitor)
                .GetProperties()
                .Select(property => property.Name)
                .Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void SelectedConnectionReturnsNullWhenNoConnections()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
 
        Assert.Null(monitor.SelectedConnection);
    }
 
    [Fact]
    public void SelectedConnectionPrefersExplicitSelectionWhenAvailable()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var inScope = CreateConnection(appHostPath: "C:/repo/AppHost1", isInScope: true, processId: 1);
        var outOfScope = CreateConnection(appHostPath: "C:/other/AppHost2", isInScope: false, processId: 2);
        monitor.AddConnection(inScope.Hash, inScope.SocketPath, inScope);
        monitor.AddConnection(outOfScope.Hash, outOfScope.SocketPath, outOfScope);
        monitor.SelectedAppHostPath = "C:/other/AppHost2";
 
        Assert.Same(outOfScope, monitor.SelectedConnection);
    }
 
    [Fact]
    public void SelectedConnectionClearsExplicitSelectionWhenNoLongerAvailable()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var inScope = CreateConnection(appHostPath: "C:/repo/AppHost1", isInScope: true, processId: 1);
        monitor.AddConnection(inScope.Hash, inScope.SocketPath, inScope);
        monitor.SelectedAppHostPath = "C:/missing/AppHost";
 
        var selected = monitor.SelectedConnection;
 
        Assert.Same(inScope, selected);
        Assert.Null(monitor.SelectedAppHostPath);
    }
 
    [Fact]
    public void SelectedConnectionPrefersSingleInScopeConnectionWhenNoExplicitSelection()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var inScope = CreateConnection(appHostPath: "C:/repo/AppHost1", isInScope: true, processId: 1);
        var outOfScope = CreateConnection(appHostPath: "C:/other/AppHost2", isInScope: false, processId: 2);
        monitor.AddConnection(inScope.Hash, inScope.SocketPath, inScope);
        monitor.AddConnection(outOfScope.Hash, outOfScope.SocketPath, outOfScope);
 
        Assert.Same(inScope, monitor.SelectedConnection);
    }
 
    [Fact]
    public void SelectedConnectionDistinguishesCaseDistinctAppHosts()
    {
        var tempRoot = Directory.CreateTempSubdirectory("aspire-apphost-selection-casing-");
        try
        {
            var firstDirectory = Directory.CreateDirectory(Path.Combine(tempRoot.FullName, "AppHost"));
            var secondDirectoryPath = Path.Combine(tempRoot.FullName, "apphost");
            Assert.SkipWhen(Directory.Exists(secondDirectoryPath),
                "This test requires a case-sensitive filesystem.");
 
            var secondDirectory = Directory.CreateDirectory(secondDirectoryPath);
            var firstPath = Path.Combine(firstDirectory.FullName, "AppHost.csproj");
            var secondPath = Path.Combine(secondDirectory.FullName, "AppHost.csproj");
            File.WriteAllText(firstPath, "<Project />");
            File.WriteAllText(secondPath, "<Project />");
            var firstConnection = CreateConnection(firstPath, isInScope: true, processId: 1);
            var secondConnection = CreateConnection(secondPath, isInScope: true, processId: 2);
            var monitor = new TestAuxiliaryBackchannelMonitor();
            monitor.AddConnection(firstConnection.Hash, firstConnection.SocketPath, firstConnection);
            monitor.AddConnection(secondConnection.Hash, secondConnection.SocketPath, secondConnection);
            monitor.SelectedAppHostPath = secondPath;
 
            Assert.Same(secondConnection, monitor.SelectedConnection);
        }
        finally
        {
            tempRoot.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData(@"c:\Repo\AppHost.csproj", @"C:\Repo\AppHost.csproj")]
    [InlineData("d:/Repo/AppHost.csproj", "D:/Repo/AppHost.csproj")]
    [InlineData(@"C:\repo\AppHost.csproj", @"C:\repo\AppHost.csproj")]
    [InlineData(@"\\?\c:\Repo\AppHost.csproj", @"\\?\C:\Repo\AppHost.csproj")]
    [InlineData(@"\\.\d:\Repo\AppHost.csproj", @"\\.\D:\Repo\AppHost.csproj")]
    [InlineData(@"\\server\share\Repo\AppHost.csproj", @"\\SERVER\SHARE\Repo\AppHost.csproj")]
    [InlineData(@"\\?\UNC\server\share\Repo\AppHost.csproj", @"\\?\UNC\SERVER\SHARE\Repo\AppHost.csproj")]
    [InlineData(@"\\.\UNC\server\share\Repo\AppHost.csproj", @"\\.\UNC\SERVER\SHARE\Repo\AppHost.csproj")]
    [InlineData(@"c:relative\AppHost.csproj", @"c:relative\AppHost.csproj")]
    [InlineData("/repo/AppHost.csproj", "/repo/AppHost.csproj")]
    public void NormalizeRootIdentity_OnlyCanonicalizesVolumeRoot(
        string path,
        string expected)
    {
        Assert.Equal(expected, AppHostPathComparer.NormalizeRootIdentity(path));
    }
 
    [Fact]
    public void PathsEqual_WithDriveRootCaseVariant_MatchesOnWindows()
    {
        if (!OperatingSystem.IsWindows())
        {
            Assert.Skip("Windows drive-root identity is only available on Windows.");
        }
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("CaseSensitiveSegments");
        var appHostPath = Path.Combine(appHostDirectory.FullName, "RootCase.AppHost.csproj");
        File.WriteAllText(appHostPath, "<Project />");
        var driveCaseVariant = char.ToLowerInvariant(appHostPath[0]) + appHostPath[1..];
 
        Assert.True(AppHostPathComparer.PathsEqual(appHostPath, driveCaseVariant));
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_WithSymlinkedSelectedPath_MatchesPhysicalAppHostPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var realAppHostPath = Path.Combine(realDirectory.FullName, "Symlinked.AppHost.csproj");
        File.WriteAllText(realAppHostPath, "<Project />");
        var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
 
        try
        {
            Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
        }
        catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or PlatformNotSupportedException)
        {
            Assert.Skip($"Cannot create a directory symlink in this environment: {ex.Message}");
        }
 
        var symlinkedAppHostPath = Path.Combine(symlinkDirectory, "Symlinked.AppHost.csproj");
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            SelectedAppHostPath = symlinkedAppHostPath
        };
        var connection = CreateConnection(realAppHostPath, isInScope: true, processId: 4);
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
 
        var selected = await AppHostConnectionHelper.GetSelectedConnectionAsync(
            monitor,
            NullLogger.Instance,
            TestContext.Current.CancellationToken);
 
        Assert.Same(connection, selected);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_ReturnsNullWhenNoConnections()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
 
        var selected = await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken);
 
        Assert.Null(selected);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_PrefersExplicitSelectionWhenAvailable()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
 
        var inScope = CreateConnection(appHostPath: "C:/repo/AppHost1", isInScope: true, processId: 1);
        var outOfScope = CreateConnection(appHostPath: "C:/other/AppHost2", isInScope: false, processId: 2);
 
        monitor.AddConnection("hash1", "socket.hash1", inScope);
        monitor.AddConnection("hash2", "socket.hash2", outOfScope);
        monitor.SelectedAppHostPath = "C:/other/AppHost2";
 
        var selected = await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken);
 
        Assert.Same(outOfScope, selected);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_WithCaseVariant_FollowsCurrentVolumeBehavior()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var directory = workspace.WorkspaceRoot.CreateSubdirectory("CaseSensitiveAppHost");
        var actualAppHostPath = Path.Combine(directory.FullName, "CaseSensitive.AppHost.csproj");
        File.WriteAllText(actualAppHostPath, "<Project />");
        var selectedAppHostPath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            "casesensitiveapphost",
            "casesensitive.apphost.csproj");
        var volumeResolvesCaseVariant = File.Exists(selectedAppHostPath);
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            SelectedAppHostPath = selectedAppHostPath
        };
        var connection = CreateConnection(appHostPath: actualAppHostPath, isInScope: true, processId: 3);
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
 
        if (volumeResolvesCaseVariant)
        {
            var selected = await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken);
 
            Assert.Same(connection, selected);
            Assert.Equal(selectedAppHostPath, monitor.SelectedAppHostPath);
        }
        else
        {
            var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
                await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken));
            Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
            Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
            Assert.Equal(selectedAppHostPath, monitor.SelectedAppHostPath);
        }
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_WithCachedUnrelatedConnection_ScansForExplicitSelection()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var selectedAppHostPath = "C:/repo/PinnedAppHost";
        var cachedUnrelated = CreateConnection(appHostPath: "C:/repo/UnrelatedAppHost", isInScope: true, processId: 1);
        var selectedConnection = CreateConnection(appHostPath: selectedAppHostPath, isInScope: false, processId: 2);
 
        monitor.AddConnection(cachedUnrelated.Hash, cachedUnrelated.SocketPath, cachedUnrelated);
        monitor.SelectedAppHostPath = selectedAppHostPath;
        monitor.ScanAsyncCallback = _ =>
        {
            monitor.AddConnection(selectedConnection.Hash, selectedConnection.SocketPath, selectedConnection);
            return Task.CompletedTask;
        };
 
        var selected = await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken);
 
        Assert.Same(selectedConnection, selected);
        Assert.Equal(1, monitor.ScanCallCount);
        Assert.Equal(selectedAppHostPath, monitor.SelectedAppHostPath);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_WithMissingSelectedAppHost_ThrowsWithoutFallingBack()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var selectedAppHostPath = "C:/missing/AppHost";
        var inScope = CreateConnection(appHostPath: "C:/repo/AppHost1", isInScope: true, processId: 42);
 
        monitor.AddConnection("hash1", "socket.hash1", inScope);
        monitor.SelectedAppHostPath = selectedAppHostPath;
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
            await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken));
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
        Assert.Equal(selectedAppHostPath, monitor.SelectedAppHostPath);
        Assert.Equal(
            "The selected AppHost is not available. Start that AppHost and retry.",
            exception.Message);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_WithMultipleInstancesAtSelectedPath_ThrowsWithoutChoosingOne()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            SelectedAppHostPath = "C:/repo/AppHost"
        };
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection("C:/repo/AppHost", isInScope: true, processId: 41));
        monitor.AddConnection("hash2", "socket.hash2", CreateConnection("C:/repo/AppHost", isInScope: true, processId: 42));
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
            AppHostConnectionHelper.GetSelectedConnectionAsync(
                monitor,
                NullLogger.Instance,
                TestContext.Current.CancellationToken));
 
        Assert.Equal(
            "Multiple running AppHost instances match the selected path. Stop the extra instance and retry.",
            exception.Message);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_ThrowsBoundedErrorWhenOnlyOutOfScopeConnectionsExist()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var outOfScope = CreateConnection(appHostPath: "C:/other/AppHost2", isInScope: false, processId: 2);
 
        monitor.AddConnection("hash2", "socket.hash2", outOfScope);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
            AppHostConnectionHelper.GetSelectedConnectionAsync(
                monitor,
                NullLogger.Instance,
                TestContext.Current.CancellationToken));
 
        Assert.Equal(
            "Running Aspire AppHosts were found outside the MCP server's working directory scope. " +
            "Use 'list_apphosts' to discover available AppHosts, then 'select_apphost' to choose one.",
            exception.Message);
    }
 
    [Fact]
    public async Task GetSelectedConnectionAsync_ThrowsWhenMultipleInScopeConnectionsExist()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
 
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection(appHostPath: "C:/repo/AppHost1", isInScope: true, processId: 1));
        monitor.AddConnection("hash2", "socket.hash2", CreateConnection(appHostPath: "C:/repo/AppHost2", isInScope: true, processId: 2));
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
            await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken));
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
        Assert.Equal(
            "Multiple Aspire AppHosts are running in the MCP server's working directory scope. " +
            "Use 'select_apphost' to choose the AppHost for this request.",
            exception.Message);
    }
 
    private static TestAppHostAuxiliaryBackchannel CreateConnection(string appHostPath, bool isInScope, int processId)
        => new()
        {
            Hash = $"hash-{processId}",
            SocketPath = $"/socket-{processId}.sock",
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = appHostPath,
                ProcessId = processId,
                CliProcessId = null
            },
            IsInScope = isInScope
        };
}