File: Commands\AgentMcpCommandTests.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.Commands;
using Aspire.Cli.Mcp;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Threading.Channels;
 
namespace Aspire.Cli.Tests.Commands;
 
/// <summary>
/// In-process unit tests for AgentMcpCommand that test the MCP server functionality
/// without starting a new CLI process. The IO communication between the MCP server
/// and test client is abstracted using in-memory pipes via DI.
/// </summary>
public class AgentMcpCommandTests(ITestOutputHelper outputHelper) : IAsyncLifetime
{
    private TemporaryWorkspace _workspace = null!;
    private ServiceProvider _serviceProvider = null!;
    private TestMcpServerTransport _testTransport = null!;
    private McpClient _mcpClient = null!;
    private AgentMcpCommand _agentMcpCommand = null!;
    private Task _serverRunTask = null!;
    private CancellationTokenSource _cts = null!;
    private ILoggerFactory _loggerFactory = null!;
    private TestAuxiliaryBackchannelMonitor _backchannelMonitor = null!;
 
    public async ValueTask InitializeAsync()
    {
        _cts = new CancellationTokenSource();
        _workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create the test transport with in-memory pipes
        _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper));
        _testTransport = new TestMcpServerTransport(_loggerFactory);
 
        // Create a backchannel monitor that we can configure for resource tool tests
        _backchannelMonitor = new TestAuxiliaryBackchannelMonitor();
 
        // Create services using CliTestHelper with custom MCP transport and test docs service
        var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper, options =>
        {
            // Override the MCP transport factory with our test transport (which implements IMcpTransportFactory)
            options.McpServerTransportFactory = _ => _testTransport;
            // Override the docs index service with a test implementation that doesn't make network calls
            options.DocsIndexServiceFactory = _ => new TestDocsIndexService();
            // Override the backchannel monitor with our test implementation
            options.AuxiliaryBackchannelMonitorFactory = _ => _backchannelMonitor;
        });
 
        _serviceProvider = services.BuildServiceProvider();
 
        // Get the AgentMcpCommand from DI and start the server
        _agentMcpCommand = _serviceProvider.GetRequiredService<AgentMcpCommand>();
        var rootCommand = _serviceProvider.GetRequiredService<RootCommand>();
        var parseResult = rootCommand.Parse("agent mcp");
 
        // Start the MCP server in the background
        _serverRunTask = Task.Run(async () =>
        {
            try
            {
                await _agentMcpCommand.ExecuteCommandAsync(parseResult, _cts.Token);
            }
            catch (OperationCanceledException)
            {
                // Expected when cancellation is requested
            }
        }, _cts.Token);
 
        // Create and connect the MCP client using the test transport's client side
        _mcpClient = await _testTransport.CreateClientAsync(_loggerFactory, _cts.Token);
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_mcpClient is not null)
        {
            await _mcpClient.DisposeAsync();
        }
 
        await _cts.CancelAsync();
 
        try
        {
            if (_serverRunTask is not null)
            {
                await _serverRunTask.WaitAsync(TimeSpan.FromSeconds(2));
            }
        }
        catch (OperationCanceledException)
        {
            // Expected when cancellation is requested
        }
        catch (TimeoutException)
        {
            // Server didn't stop in time, but that's OK for tests
        }
 
        _testTransport?.Dispose();
        if (_serviceProvider is not null)
        {
            await _serviceProvider.DisposeAsync();
        }
        _workspace?.Dispose();
        _loggerFactory?.Dispose();
        _cts?.Dispose();
    }
 
    [Fact]
    public async Task McpServer_ListTools_ReturnsExpectedTools()
    {
        // Act
        var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();
 
        // Assert
        Assert.NotNull(tools);
        Assert.Collection(tools.OrderBy(t => t.Name),
            tool => AssertTool(KnownMcpTools.Doctor, tool),
            tool => AssertTool(KnownMcpTools.ExecuteResourceCommand, tool),
            tool => AssertTool(KnownMcpTools.GetDoc, tool),
            tool => AssertTool(KnownMcpTools.ListAppHosts, tool),
            tool => AssertTool(KnownMcpTools.ListConsoleLogs, tool),
            tool => AssertTool(KnownMcpTools.ListDocs, tool),
            tool => AssertTool(KnownMcpTools.ListIntegrations, tool),
            tool => AssertTool(KnownMcpTools.ListResources, tool),
            tool => AssertTool(KnownMcpTools.ListStructuredLogs, tool),
            tool => AssertTool(KnownMcpTools.ListTraceStructuredLogs, tool),
            tool => AssertTool(KnownMcpTools.ListTraces, tool),
            tool => AssertTool(KnownMcpTools.RefreshTools, tool),
            tool => AssertTool(KnownMcpTools.SearchDocs, tool),
            tool => AssertTool(KnownMcpTools.SelectAppHost, tool));
 
        static void AssertTool(string expectedName, McpClientTool tool)
        {
            Assert.Equal(expectedName, tool.Name);
            Assert.False(string.IsNullOrEmpty(tool.Description), $"Tool '{tool.Name}' should have a description");
            Assert.NotEqual(default, tool.JsonSchema);
        }
    }
 
    [Fact]
    public async Task McpServer_ListTools_IncludesResourceMcpTools()
    {
        // Arrange - Create a mock backchannel with a resource that has MCP tools
        var mockBackchannel = new TestAppHostAuxiliaryBackchannel
        {
            Hash = "test-apphost-hash",
            IsInScope = true,
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
                ProcessId = 12345
            },
            ResourceSnapshots =
            [
                new ResourceSnapshot
                {
                    Name = "test-resource",
                    DisplayName = "Test Resource",
                    ResourceType = "Container",
                    State = "Running",
                    McpServer = new ResourceSnapshotMcpServer
                    {
                        EndpointUrl = "http://localhost:8080/mcp",
                        Tools =
                        [
                            new Tool
                            {
                                Name = "resource_tool_one",
                                Description = "A test tool from the resource"
                            },
                            new Tool
                            {
                                Name = "resource_tool_two",
                                Description = "Another test tool from the resource"
                            }
                        ]
                    }
                }
            ]
        };
 
        // Register the mock backchannel
        _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);
 
        // First call refresh_tools to discover the resource tools
        await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout();
 
        // Act - List all tools
        var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout();
 
        // Assert - Verify resource tools are included
        Assert.NotNull(tools);
 
        // The resource tools should be exposed with a prefixed name: {resource_name}_{tool_name}
        // Resource name "test-resource" becomes "test_resource" (dashes replaced with underscores)
        var resourceToolOne = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_one");
        var resourceToolTwo = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_two");
 
        Assert.NotNull(resourceToolOne);
        Assert.NotNull(resourceToolTwo);
 
        Assert.Equal("A test tool from the resource", resourceToolOne.Description);
        Assert.Equal("Another test tool from the resource", resourceToolTwo.Description);
    }
 
    [Fact]
    public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult()
    {
        // Arrange - Create a mock backchannel with a resource that has MCP tools
        var expectedToolResult = "Tool executed successfully with custom data";
        string? callResourceName = null;
        string? callToolName = null;
 
        var mockBackchannel = new TestAppHostAuxiliaryBackchannel
        {
            Hash = "test-apphost-hash",
            IsInScope = true,
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
                ProcessId = 12345
            },
            ResourceSnapshots =
            [
                new ResourceSnapshot
                {
                    Name = "my-resource",
                    DisplayName = "My Resource",
                    ResourceType = "Container",
                    State = "Running",
                    McpServer = new ResourceSnapshotMcpServer
                    {
                        EndpointUrl = "http://localhost:8080/mcp",
                        Tools =
                        [
                            new Tool
                            {
                                Name = "do_something",
                                Description = "Does something useful"
                            }
                        ]
                    }
                }
            ],
            // Configure the handler to capture the arguments and return a specific result
            CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) =>
            {
                callResourceName = resourceName;
                callToolName = toolName;
                return Task.FromResult(new CallToolResult
                {
                    Content = [new TextContentBlock { Text = expectedToolResult }]
                });
            }
        };
 
        // Register the mock backchannel
        _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel);
 
        // First call refresh_tools to discover the resource tools
        await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout();
 
        // Act - Call the resource tool (name format: {resource_name}_{tool_name} with dashes replaced by underscores)
        var result = await _mcpClient.CallToolAsync(
            "my_resource_do_something",
            cancellationToken: _cts.Token).DefaultTimeout();
 
        // Assert
        Assert.NotNull(result);
        Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
        Assert.NotNull(result.Content);
        Assert.NotEmpty(result.Content);
 
        var textContent = result.Content[0] as TextContentBlock;
        Assert.NotNull(textContent);
        Assert.Equal(expectedToolResult, textContent.Text);
 
        // Verify the handler was called with the correct resource and tool names
        Assert.Equal("my-resource", callResourceName);
        Assert.Equal("do_something", callToolName);
    }
 
    [Fact]
    public async Task McpServer_CallTool_ListAppHosts_ReturnsResult()
    {
        // Act
        var result = await _mcpClient.CallToolAsync(
            KnownMcpTools.ListAppHosts,
            cancellationToken: _cts.Token).DefaultTimeout();
 
        // Assert
        Assert.NotNull(result);
        Assert.Null(result.IsError);
        Assert.NotNull(result.Content);
        Assert.NotEmpty(result.Content);
 
        var textContent = result.Content[0] as TextContentBlock;
        Assert.NotNull(textContent);
        Assert.Contains("App hosts", textContent.Text);
    }
 
    [Fact]
    public async Task McpServer_CallTool_RefreshTools_ReturnsResult()
    {
        // Arrange - Set up a channel to receive the ToolListChanged notification
        var notificationChannel = Channel.CreateUnbounded<JsonRpcNotification>();
        await using var notificationHandler = _mcpClient.RegisterNotificationHandler(
            NotificationMethods.ToolListChangedNotification,
            (notification, cancellationToken) =>
            {
                notificationChannel.Writer.TryWrite(notification);
                return default;
            });
 
        // Act
        var result = await _mcpClient.CallToolAsync(
            KnownMcpTools.RefreshTools,
            cancellationToken: _cts.Token).DefaultTimeout();
 
        // Assert - Verify result
        Assert.NotNull(result);
        Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
        Assert.NotNull(result.Content);
        Assert.NotEmpty(result.Content);
 
        var textContent = result.Content[0] as TextContentBlock;
        Assert.NotNull(textContent);
 
        // Verify the exact text content with the correct tool count
        var expectedToolCount = _agentMcpCommand.KnownTools.Count;
        Assert.Equal($"Tools refreshed: {expectedToolCount} tools available", textContent.Text);
 
        // Assert - Verify the ToolListChanged notification was received
        var notification = await notificationChannel.Reader.ReadAsync(_cts.Token).AsTask().DefaultTimeout();
        Assert.NotNull(notification);
        Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method);
    }
 
    [Fact]
    public async Task McpServer_CallTool_UnknownTool_ReturnsError()
    {
        // Act & Assert - The MCP client throws McpProtocolException when the server returns an error
        var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
            await _mcpClient.CallToolAsync(
                "nonexistent_tool_that_does_not_exist",
                cancellationToken: _cts.Token).DefaultTimeout());
 
        Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
    }
 
    private static string GetResultText(CallToolResult result)
    {
        if (result.Content?.FirstOrDefault() is TextContentBlock textContent)
        {
            return textContent.Text;
        }
 
        return string.Empty;
    }
}