File: Mcp\ListIntegrationsToolTests.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 Microsoft.AspNetCore.InternalTesting;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.Mcp.Tools;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Tests.Mcp;
 
public class ListIntegrationsToolTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ListIntegrationsTool_CallToolAsync_UsesConfiguredSourcesFromResolvedDirectory(bool useAppHostDirectory)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, AspireConfigFile.FileName), """
            {"integrationSources":{"default":{"enabled":false},"team":{"source":"https://example.com/wrong","patterns":["Wrong.*"]}}}
            """);
        var directory = useAppHostDirectory ? workspace.CreateDirectory("app") : workspace.WorkspaceRoot;
        File.WriteAllText(Path.Combine(directory.FullName, AspireConfigFile.FileName), """
            {"integrationSources":{
              "default":{"enabled":false},
              "team":{"source":"https://example.com/team","patterns":["Contoso.Hosting.*"]},
              "other":{"source":"https://example.com/other","patterns":["Fabrikam.Hosting.*"]}
            }}
            """);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        if (useAppHostDirectory)
        {
            var appHostFile = new FileInfo(Path.Combine(directory.FullName, "AppHost.csproj"));
            File.WriteAllText(appHostFile.FullName, "<Project />");
            var connection = new TestAppHostAuxiliaryBackchannel
            {
                Hash = "app",
                SocketPath = "app-socket",
                AppHostInfo = new AppHostInformation
                {
                    AppHostPath = appHostFile.FullName,
                    ProcessId = Environment.ProcessId,
                    CliProcessId = null
                }
            };
            monitor.AddConnection(connection.SocketPath, connection);
        }
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Built-in discovery is disabled.")
        };
        var queries = new ConcurrentBag<(string Source, string Query)>();
        var cache = new FakeNuGetPackageCache
        {
            SearchLimitReached = true,
            GetPackagesAsyncCallback = (_, query, filter, includePrerelease, config, _, _) =>
            {
                var source = Assert.Single(XDocument.Load(config!.FullName).Root!.Element("packageSources")!.Elements("add")).Attribute("value")!.Value;
                queries.Add((source, query));
                Assert.True(includePrerelease);
                NuGetPackage[] packages = source switch
                {
                    "https://example.com/team" =>
                    [
                        new() { Id = "Contoso.Hosting.Redis", Version = "1.0.0", Source = source },
                        new() { Id = "Contoso.Hosting.Redis", Version = "2.0.0-beta.1", Source = source },
                        new() { Id = "Unapproved.Package", Version = "1.0.0", Source = source }
                    ],
                    "https://example.com/other" =>
                    [
                        new() { Id = "Fabrikam.Hosting.Storage", Version = "3.0.0", Source = source },
                        new() { Id = "Unapproved.Package", Version = "1.0.0", Source = source }
                    ],
                    _ => throw new InvalidOperationException($"Unexpected source: {source}")
                };
                return Task.FromResult(packages.Where(package => filter!(package.Id)));
            }
        };
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
        });
        services.AddSingleton<IPackagingService>(packagingService);
        services.AddSingleton<INuGetPackageCache>(cache);
        using var provider = services.BuildServiceProvider();
        var tool = new ListIntegrationsTool(provider.GetRequiredService<IntegrationPackageSearchService>(), provider.GetRequiredService<CliExecutionContext>(), monitor);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
        Assert.Empty(interactionService.DisplayedMessages);
        Assert.Equal(
            [("https://example.com/other", "Fabrikam.Hosting."), ("https://example.com/team", "Contoso.Hosting.")],
            queries.OrderBy(static query => query.Source, StringComparer.Ordinal));
        var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
        await VerifyJson(textContent.Text).UseParameters(useAppHostDirectory);
    }
 
    [Fact]
    public async Task ListIntegrationsTool_CallToolAsync_ReturnsWarningWhenLimitedSearchHasNoMatches()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, AspireConfigFile.FileName), """
            {"integrationSources":{"default":{"enabled":false},"team":{"source":"https://example.com/team","patterns":["Contoso.Hosting.*"]}}}
            """);
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
        });
        services.AddSingleton<INuGetPackageCache>(new FakeNuGetPackageCache { SearchLimitReached = true });
        using var provider = services.BuildServiceProvider();
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
        Assert.Empty(interactionService.DisplayedMessages);
        var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
        await Verify(textContent.Text, "json");
    }
 
    [Fact]
    public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyWhenBuiltInDiscoveryDisabled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, AspireConfigFile.FileName), """
            {"integrationSources":{"default":{"enabled":false}}}
            """);
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Built-in discovery is disabled.")
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        services.AddSingleton<IPackagingService>(packagingService);
        using var provider = services.BuildServiceProvider();
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
        var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
        await Verify(textContent.Text, "json");
    }
 
    [Fact]
    public void ListIntegrationsTool_HasCorrectName()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var provider = CreateServiceProvider(workspace, MockPackagingServiceFactory.Create());
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        Assert.Equal("list_integrations", tool.Name);
    }
 
    [Fact]
    public void ListIntegrationsTool_HasCorrectDescription()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var provider = CreateServiceProvider(workspace, MockPackagingServiceFactory.Create());
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        Assert.Contains("List available Aspire hosting integrations", tool.Description);
        Assert.Contains("This tool does not require a running AppHost", tool.Description);
    }
 
    [Fact]
    public void ListIntegrationsTool_GetInputSchema_ReturnsValidSchema()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var provider = CreateServiceProvider(workspace, MockPackagingServiceFactory.Create());
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
        var schema = tool.GetInputSchema();
 
        Assert.Equal(JsonValueKind.Object, schema.ValueKind);
        Assert.True(schema.TryGetProperty("type", out var typeElement));
        Assert.Equal("object", typeElement.GetString());
        Assert.True(schema.TryGetProperty("properties", out var propsElement));
        Assert.Equal(JsonValueKind.Object, propsElement.ValueKind);
        Assert.True(schema.TryGetProperty("description", out var descElement));
        Assert.Contains("This tool takes no input parameters", descElement.GetString());
        Assert.True(schema.TryGetProperty("additionalProperties", out var additionalPropsElement));
        Assert.False(additionalPropsElement.GetBoolean());
    }
 
    [Fact]
    public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenNoPackagesFound()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var mockPackagingService = MockPackagingServiceFactory.Create();
        using var provider = CreateServiceProvider(workspace, mockPackagingService);
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
        Assert.NotNull(result.Content);
        Assert.Single(result.Content);
        var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock;
        Assert.NotNull(textContent);
 
        // Verify it's valid JSON with empty integrations array
        using var json = JsonDocument.Parse(textContent.Text);
        Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations));
        Assert.Equal(JsonValueKind.Array, integrations.ValueKind);
        Assert.Equal(0, integrations.GetArrayLength());
    }
 
    [Fact]
    public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_WhenPackagesFound()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var mockPackagingService = MockPackagingServiceFactory.Create(new[]
        {
            new Aspire.Shared.NuGetPackageCli { Id = "CommunityToolkit.Aspire.Hosting.Aardvark", Version = "9.0.0" },
            new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.0.0" },
            new Aspire.Shared.NuGetPackageCli { Id = "Acme.Aspire.Hosting.Custom", Version = "9.0.0" },
            new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.PostgreSQL", Version = "9.0.0" },
            new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.CommunityToolkit.Redis", Version = "9.0.0" }
        });
        using var provider = CreateServiceProvider(workspace, mockPackagingService);
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
        Assert.NotNull(result.Content);
        Assert.Single(result.Content);
        var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock;
        Assert.NotNull(textContent);
 
        // Verify it's valid JSON with proper structure
        using var json = JsonDocument.Parse(textContent.Text);
        Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations));
        Assert.Equal(JsonValueKind.Array, integrations.ValueKind);
        Assert.Equal(5, integrations.GetArrayLength());
 
        // Verify the first integration has the expected properties
        var firstIntegration = integrations[0];
        Assert.True(firstIntegration.TryGetProperty("name", out _));
        Assert.True(firstIntegration.TryGetProperty("packageId", out _));
        Assert.True(firstIntegration.TryGetProperty("version", out _));
 
        var packageIds = integrations.EnumerateArray()
            .Select(e => e.GetProperty("packageId").GetString())
            .ToList();
        Assert.Equal(
            ["Aspire.Hosting.CommunityToolkit.Redis", "Aspire.Hosting.PostgreSQL", "Aspire.Hosting.Redis", "CommunityToolkit.Aspire.Hosting.Aardvark", "Acme.Aspire.Hosting.Custom"],
            packageIds);
    }
 
    [Fact]
    public async Task ListIntegrationsTool_UsesSharedDefaultDiscoveryWhenNoSourcesConfigured()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var mockPackagingService = MockPackagingServiceFactory.Create(new[]
        {
            new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.0.0" }
        });
        using var provider = CreateServiceProvider(workspace, mockPackagingService);
        var tool = ActivatorUtilities.CreateInstance<ListIntegrationsTool>(provider);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
 
        using var json = JsonDocument.Parse(((ModelContextProtocol.Protocol.TextContentBlock)result.Content![0]).Text);
        Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations));
        Assert.Equal(1, integrations.GetArrayLength());
    }
 
    private ServiceProvider CreateServiceProvider(TemporaryWorkspace workspace, IPackagingService packagingService)
    {
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        services.AddSingleton(packagingService);
 
        return services.BuildServiceProvider();
    }
}