File: Mcp\WaitForResourcesToolTests.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 System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Mcp.Tools;
using Aspire.Cli.Tests.TestServices;
using Aspire.Dashboard.Model;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Tests.Mcp;
 
public class WaitForResourcesToolTests
{
    private const string AppHostPath = "/repo/TestAppHost/TestAppHost.csproj";
 
    [Theory]
    [InlineData("""{"resourceNames":"api"}""")]
    [InlineData("""{"resourceNames":["api",42]}""")]
    [InlineData("""{"targetState":"ready"}""")]
    [InlineData("""{"timeoutSeconds":0}""")]
    [InlineData("""{"timeoutSeconds":3601}""")]
    [InlineData("""{"timeoutSeconds":1.5}""")]
    [InlineData("""{"resourceName":"api"}""")]
    public async Task WaitForResourcesTool_RejectsInvalidArguments(string argumentsJson)
    {
        var tool = CreateTool(new TestAuxiliaryBackchannelMonitor());
        var arguments = ParseArguments(argumentsJson);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).AsTask());
 
        Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_RejectsTooManyResourceNames()
    {
        var tool = CreateTool(new TestAuxiliaryBackchannelMonitor());
        var resourceNames = Enumerable.Range(0, 101).Select(static index => $"resource-{index}").ToArray();
        var arguments = ParseArguments(JsonSerializer.Serialize(new { resourceNames }));
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).AsTask());
 
        Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_RejectsResourceNameThatIsTooLong()
    {
        var tool = CreateTool(new TestAuxiliaryBackchannelMonitor());
        var resourceName = string.Concat(Enumerable.Repeat("\U0001F680", WaitForResourcesTool.MaximumResourceNameLength + 1));
        var arguments = ParseArguments(JsonSerializer.Serialize(new { resourceNames = new[] { resourceName } }));
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).AsTask());
 
        Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_AcceptsResourceNameWithinUnicodeCharacterLimit()
    {
        var tool = CreateTool(new TestAuxiliaryBackchannelMonitor());
        var resourceName = string.Concat(Enumerable.Repeat("\U0001F680", 256));
        var arguments = ParseArguments(JsonSerializer.Serialize(new { resourceNames = new[] { resourceName } }));
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).AsTask());
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("""{"resourceNames":[]}""")]
    public async Task WaitForResourcesTool_WaitsForAllVisibleNonExcludedResources_WhenNamesAreOmittedOrEmpty(
        string? argumentsJson)
    {
        var waitCalls = new List<(string ResourceName, string TargetState, int TimeoutSeconds)>();
        var connection = CreateConnection(
            new ResourceSnapshot
            {
                Name = "api",
                DisplayName = "API",
                State = "Running",
                HealthStatus = "Healthy"
            },
            new ResourceSnapshot
            {
                Name = "hidden-proxy",
                State = "Running",
                IsHidden = true
            },
            new ResourceSnapshot
            {
                Name = "secret-store",
                State = "Running",
                Properties =
                {
                    [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true)
                }
            });
        connection.WaitForResourceHandler = (resourceName, targetState, timeoutSeconds, _) =>
        {
            waitCalls.Add((resourceName, targetState, timeoutSeconds));
            return Task.FromResult(new WaitForResourceResponse
            {
                Success = true,
                State = "Running",
                HealthStatus = "Healthy"
            });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
        var arguments = argumentsJson is null ? null : ParseArguments(argumentsJson);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, connection.GetResourceSnapshotsCallCount);
        Assert.True(connection.LastGetResourceSnapshotsIncludeHidden);
        Assert.Equal([("api", "healthy", 120)], waitCalls);
 
        using var json = GetWaitResult(result);
        Assert.Equal(
            ["outcome", "target_state", "resources"],
            json.RootElement.EnumerateObject().Select(static property => property.Name));
        Assert.Equal("success", json.RootElement.GetProperty("outcome").GetString());
        Assert.Equal("healthy", json.RootElement.GetProperty("target_state").GetString());
 
        var resource = Assert.Single(json.RootElement.GetProperty("resources").EnumerateArray());
        Assert.Equal(
            ["name", "state", "health", "outcome"],
            resource.EnumerateObject().Select(static property => property.Name));
        Assert.Equal("api", resource.GetProperty("name").GetString());
        Assert.Equal("Running", resource.GetProperty("state").GetString());
        Assert.Equal("Healthy", resource.GetProperty("health").GetString());
        Assert.Equal("success", resource.GetProperty("outcome").GetString());
    }
 
    [Fact]
    public async Task WaitForResourcesTool_RejectsUnboundedImplicitResourceSet()
    {
        var snapshots = Enumerable.Range(0, WaitForResourcesTool.MaximumResourceNameCount + 1)
            .Select(index => new ResourceSnapshot
            {
                Name = $"resource-{index}",
                ResourceType = "Project",
                State = "Running"
            })
            .ToArray();
        var connection = CreateConnection(snapshots);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
            tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                TestContext.Current.CancellationToken).AsTask());
 
        Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
        Assert.Equal(
            $"The selected AppHost has too many resources to wait for implicitly. Specify resourceNames in batches of at most {WaitForResourcesTool.MaximumResourceNameCount}.",
            exception.Message);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ImplicitSelectionAcceptsResourceNameAtUnicodeCharacterLimit()
    {
        var resourceName = string.Concat(Enumerable.Repeat("\U0001F680", WaitForResourcesTool.MaximumResourceNameLength));
        var waitedResourceNames = new List<string>();
        var connection = CreateConnection(new ResourceSnapshot
        {
            Name = resourceName,
            ResourceType = "Project",
            State = "Running"
        });
        connection.WaitForResourceHandler = (name, _, _, _) =>
        {
            waitedResourceNames.Add(name);
            return Task.FromResult(new WaitForResourceResponse
            {
                Success = true,
                State = "Running",
                HealthStatus = "Healthy"
            });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.Equal([resourceName], waitedResourceNames);
        using var json = GetWaitResult(result);
        Assert.Equal("success", json.RootElement.GetProperty("outcome").GetString());
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ImplicitSelectionRejectsResourceNameAboveUnicodeCharacterLimit()
    {
        var resourceName = string.Concat(Enumerable.Repeat("\U0001F680", WaitForResourcesTool.MaximumResourceNameLength + 1));
        var waitCallCount = 0;
        var connection = CreateConnection(new ResourceSnapshot
        {
            Name = resourceName,
            ResourceType = "Project",
            State = "Running"
        });
        connection.WaitForResourceHandler = (_, _, _, _) =>
        {
            Interlocked.Increment(ref waitCallCount);
            return Task.FromResult(new WaitForResourceResponse { Success = true });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
            tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                TestContext.Current.CancellationToken).AsTask());
 
        Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
        Assert.Equal(0, waitCallCount);
    }
 
    [Theory]
    [InlineData(null, false)]
    [InlineData("""{"resourceNames":[]}""", false)]
    [InlineData(null, true)]
    [InlineData("""{"resourceNames":[]}""", true)]
    public async Task WaitForResourcesTool_ReturnsFailureWithoutWaiting_WhenNoEligibleResourcesAreAvailable(
        string? argumentsJson,
        bool includeUnavailableResources)
    {
        var waitCallCount = 0;
        ResourceSnapshot[] snapshots = includeUnavailableResources
            ?
            [
                new ResourceSnapshot
                {
                    Name = "hidden-proxy",
                    State = "Running",
                    IsHidden = true
                },
                new ResourceSnapshot
                {
                    Name = "secret-store",
                    State = "Running",
                    Properties =
                    {
                        [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true)
                    }
                }
            ]
            : [];
        var connection = CreateConnection(snapshots);
        connection.WaitForResourceHandler = (_, _, _, _) =>
        {
            Interlocked.Increment(ref waitCallCount);
            return Task.FromResult(new WaitForResourceResponse { Success = true });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
        var arguments = argumentsJson is null ? null : ParseArguments(argumentsJson);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, waitCallCount);
        Assert.Equal(1, connection.GetResourceSnapshotsCallCount);
        Assert.True(connection.LastGetResourceSnapshotsIncludeHidden);
 
        using var json = GetWaitResult(result);
        Assert.Equal(
            ["outcome", "target_state", "error", "resources"],
            json.RootElement.EnumerateObject().Select(static property => property.Name));
        Assert.Equal("failure", json.RootElement.GetProperty("outcome").GetString());
        Assert.Equal("healthy", json.RootElement.GetProperty("target_state").GetString());
        Assert.Equal(
            "No eligible resources were found in the selected AppHost.",
            json.RootElement.GetProperty("error").GetString());
        Assert.Empty(json.RootElement.GetProperty("resources").EnumerateArray());
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ResolvesRuntimeNamesBeforeUnambiguousDisplayNames()
    {
        var waitCalls = new List<(string ResourceName, string TargetState, int TimeoutSeconds)>();
        var connection = CreateConnection(
            new ResourceSnapshot
            {
                Name = "api-runtime",
                DisplayName = "API",
                State = "Starting"
            },
            new ResourceSnapshot
            {
                Name = "worker-runtime",
                DisplayName = "Worker",
                State = "Starting"
            });
        connection.WaitForResourceHandler = (resourceName, targetState, timeoutSeconds, _) =>
        {
            waitCalls.Add((resourceName, targetState, timeoutSeconds));
            return Task.FromResult(new WaitForResourceResponse
            {
                Success = true,
                State = "Running"
            });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
        var arguments = ParseArguments(
            """
            {
              "resourceNames": ["api-runtime", "Worker"],
              "targetState": "up",
              "timeoutSeconds": 30
            }
            """);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(
            [("api-runtime", "up", 30), ("worker-runtime", "up", 30)],
            waitCalls);
 
        using var json = GetWaitResult(result);
        Assert.Equal("up", json.RootElement.GetProperty("target_state").GetString());
        Assert.Equal(
            ["api-runtime", "worker-runtime"],
            json.RootElement.GetProperty("resources")
                .EnumerateArray()
                .Select(static resource => resource.GetProperty("name").GetString()));
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ReportsUnavailableNamedResourcesWithoutWaiting()
    {
        var waitCallCount = 0;
        var connection = CreateConnection(
            new ResourceSnapshot
            {
                Name = "hidden-runtime",
                DisplayName = "Hidden",
                State = "Running",
                IsHidden = true
            },
            new ResourceSnapshot
            {
                Name = "excluded-runtime",
                DisplayName = "Excluded",
                State = "Running",
                HealthStatus = "Healthy",
                Properties =
                {
                    [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true)
                }
            },
            new ResourceSnapshot
            {
                Name = "replica-a",
                DisplayName = "Replica",
                State = "Running"
            },
            new ResourceSnapshot
            {
                Name = "replica-b",
                DisplayName = "Replica",
                State = "Running"
            });
        connection.WaitForResourceHandler = (_, _, _, _) =>
        {
            Interlocked.Increment(ref waitCallCount);
            return Task.FromResult(new WaitForResourceResponse { Success = true });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
        var arguments = ParseArguments(
            """
            {
              "resourceNames": ["Hidden", "Excluded", "Replica", "missing"]
            }
            """);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, waitCallCount);
        Assert.Equal(1, connection.GetResourceSnapshotsCallCount);
 
        using var json = GetWaitResult(result);
        Assert.Equal("failure", json.RootElement.GetProperty("outcome").GetString());
        var resources = json.RootElement.GetProperty("resources").EnumerateArray().ToArray();
        Assert.Collection(
            resources,
            resource => AssertResourceFailure(
                resource,
                "Hidden",
                null,
                null,
                "Resource is hidden and cannot be waited for through MCP."),
            resource => AssertResourceFailure(
                resource,
                "Excluded",
                null,
                null,
                "Resource is excluded from MCP."),
            resource => AssertResourceFailure(
                resource,
                "Replica",
                null,
                null,
                "Display name is ambiguous; use an exact runtime name."),
            resource => AssertResourceFailure(
                resource,
                "missing",
                null,
                null,
                "Resource was not found in the selected AppHost."));
    }
 
    [Fact]
    public async Task WaitForResourcesTool_MapsBoundedOutcomesWithFailurePrecedence()
    {
        var responses = new Dictionary<string, WaitForResourceResponse>(StringComparers.ResourceName)
        {
            ["clean-exit"] = new() { Success = true, State = "Exited" },
            ["slow"] = new()
            {
                Success = false,
                TimedOut = true,
                State = "Running",
                ErrorMessage = "secret=timeout-credential"
            },
            ["disappeared"] = new()
            {
                Success = false,
                ResourceNotFound = true,
                ErrorMessage = "secret=resource-credential"
            },
            ["failed"] = new()
            {
                Success = false,
                State = "FailedToStart",
                ErrorMessage = "secret=terminal-credential"
            },
            ["false-down"] = new()
            {
                Success = true,
                State = "FailedToStart",
                ErrorMessage = "secret=false-success-credential"
            },
            ["custom-state"] = new()
            {
                Success = false,
                State = "secret-custom-state"
            }
        };
        var connection = CreateConnection(
            responses.Keys.Select(static name => new ResourceSnapshot
            {
                Name = name,
                State = "Running"
            }).ToArray());
        connection.WaitForResourceHandler = (resourceName, _, _, _) =>
            Task.FromResult(responses[resourceName]);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
        var arguments = ParseArguments("""{"targetState":"down"}""");
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetWaitResult(result);
        Assert.Equal("failure", json.RootElement.GetProperty("outcome").GetString());
        Assert.Collection(
            json.RootElement.GetProperty("resources").EnumerateArray(),
            resource => AssertResourceOutcome(resource, "clean-exit", "Exited", "success", null),
            resource => AssertResourceOutcome(
                resource,
                "slow",
                "Running",
                "timeout",
                "Timed out waiting for the target state."),
            resource => AssertResourceOutcome(
                resource,
                "disappeared",
                null,
                "failure",
                "Resource was not found while waiting."),
            resource => AssertResourceOutcome(
                resource,
                "failed",
                "FailedToStart",
                "failure",
                "Resource entered a terminal failed state."),
            resource => AssertResourceOutcome(
                resource,
                "false-down",
                "FailedToStart",
                "failure",
                "Resource entered a terminal failed state."),
            resource => AssertResourceOutcome(
                resource,
                "custom-state",
                "unknown",
                "failure",
                "Resource wait failed."));
    }
 
    [Fact]
    public async Task WaitForResourcesTool_UsesTimeoutOutcomeWhenNoResourceFails()
    {
        var connection = CreateConnection(
            new ResourceSnapshot { Name = "ready", State = "Running" },
            new ResourceSnapshot { Name = "slow", State = "Starting" });
        connection.WaitForResourceHandler = (resourceName, _, _, _) =>
            Task.FromResult(resourceName == "ready"
                ? new WaitForResourceResponse { Success = true, State = "Running", HealthStatus = "Healthy" }
                : new WaitForResourceResponse { Success = false, TimedOut = true, State = "Starting" });
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetWaitResult(result);
        Assert.Equal("timeout", json.RootElement.GetProperty("outcome").GetString());
        Assert.Equal(
            ["success", "timeout"],
            json.RootElement.GetProperty("resources")
                .EnumerateArray()
                .Select(static resource => resource.GetProperty("outcome").GetString()));
    }
 
    [Fact]
    public async Task WaitForResourcesTool_WaitsOnceForDuplicateResourceSelections()
    {
        var waitCallCount = 0;
        var connection = CreateConnection(
            new ResourceSnapshot
            {
                Name = "api",
                DisplayName = "API",
                State = "Starting"
            });
        connection.WaitForResourceHandler = (_, _, _, _) =>
        {
            Interlocked.Increment(ref waitCallCount);
            return Task.FromResult(new WaitForResourceResponse
            {
                Success = true,
                State = "Running",
                HealthStatus = "Healthy"
            });
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
        var arguments = ParseArguments("""{"resourceNames":["api","API","api"]}""");
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, waitCallCount);
        using var json = GetWaitResult(result);
        Assert.Equal(
            ["api", "api", "api"],
            json.RootElement.GetProperty("resources")
                .EnumerateArray()
                .Select(static resource => resource.GetProperty("name").GetString()));
    }
 
    [Fact]
    public async Task WaitForResourcesTool_WaitsConcurrentlyWithOneSharedDeadline()
    {
        var timeProvider = new FakeTimeProvider();
        var receivedTimeouts = new ConcurrentQueue<int>();
        var allWaitsStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var allowWaitsToComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var startedCount = 0;
        var connection = CreateConnection(
            new ResourceSnapshot { Name = "api", State = "Starting" },
            new ResourceSnapshot { Name = "worker", State = "Starting" },
            new ResourceSnapshot { Name = "database", State = "Starting" });
        connection.WaitForResourceHandler = async (_, _, timeoutSeconds, cancellationToken) =>
        {
            receivedTimeouts.Enqueue(timeoutSeconds);
            timeProvider.Advance(TimeSpan.FromSeconds(1));
            if (Interlocked.Increment(ref startedCount) == 3)
            {
                allWaitsStarted.SetResult();
            }
 
            await allowWaitsToComplete.Task.WaitAsync(cancellationToken);
            return new WaitForResourceResponse { Success = true, State = "Running" };
        };
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor, timeProvider);
        var arguments = ParseArguments("""{"targetState":"up","timeoutSeconds":30}""");
 
        var waitTask = tool.CallToolAsync(
            CallToolContextTestHelper.Create(arguments),
            CancellationToken.None).AsTask();
 
        try
        {
            await allWaitsStarted.Task.DefaultTimeout();
            Assert.False(waitTask.IsCompleted);
            Assert.Equal([30, 29, 28], receivedTimeouts);
        }
        finally
        {
            allowWaitsToComplete.TrySetResult();
        }
 
        var result = await waitTask.DefaultTimeout();
        using var json = GetWaitResult(result);
        Assert.Equal("success", json.RootElement.GetProperty("outcome").GetString());
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ThrowsBoundedMcpError_WhenConnectionRetrievalFails()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            ScanAsyncCallback = _ => throw new InvalidOperationException("secret=connection-credential")
        };
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                CancellationToken.None).AsTask());
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
        Assert.Equal("Unable to resolve an Aspire AppHost connection.", exception.Message);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ThrowsBoundedMcpError_WhenSnapshotRetrievalFails()
    {
        var connection = CreateConnection();
        connection.GetResourceSnapshotsHandler = _ =>
            throw new InvalidOperationException("secret=snapshot-credential");
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                CancellationToken.None).AsTask());
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
        Assert.Equal("Unable to retrieve resources from the selected AppHost.", exception.Message);
        Assert.DoesNotContain(AppHostPath, exception.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_ReportsBoundedFailure_WhenOneWaitThrows()
    {
        var connection = CreateConnection(
            new ResourceSnapshot { Name = "api", State = "Starting" },
            new ResourceSnapshot { Name = "worker", State = "Starting" });
        connection.WaitForResourceHandler = (resourceName, _, _, _) =>
            resourceName == "api"
                ? Task.FromException<WaitForResourceResponse>(
                    new InvalidOperationException("secret=wait-credential"))
                : Task.FromResult(new WaitForResourceResponse
                {
                    Success = true,
                    State = "Running",
                    HealthStatus = "Healthy"
                });
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetWaitResult(result);
        Assert.Equal("failure", json.RootElement.GetProperty("outcome").GetString());
        Assert.Collection(
            json.RootElement.GetProperty("resources").EnumerateArray(),
            resource => AssertResourceOutcome(
                resource,
                "api",
                null,
                "failure",
                "Resource wait failed."),
            resource => AssertResourceOutcome(
                resource,
                "worker",
                "Running",
                "success",
                null));
    }
 
    [Fact]
    public async Task WaitForResourcesTool_PropagatesConnectionCancellation()
    {
        using var cancellationSource = new CancellationTokenSource();
        cancellationSource.Cancel();
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            ScanAsyncCallback = cancellationToken => Task.FromCanceled(cancellationToken)
        };
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                cancellationSource.Token).AsTask());
 
        Assert.Equal(cancellationSource.Token, exception.CancellationToken);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_PropagatesSnapshotCancellation()
    {
        using var cancellationSource = new CancellationTokenSource();
        cancellationSource.Cancel();
        var connection = CreateConnection();
        connection.GetResourceSnapshotsHandler = cancellationToken =>
            Task.FromCanceled<List<ResourceSnapshot>>(cancellationToken);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                cancellationSource.Token).AsTask());
 
        Assert.Equal(cancellationSource.Token, exception.CancellationToken);
    }
 
    [Fact]
    public async Task WaitForResourcesTool_PropagatesWaitCancellation()
    {
        using var cancellationSource = new CancellationTokenSource();
        cancellationSource.Cancel();
        var connection = CreateConnection(new ResourceSnapshot { Name = "api", State = "Starting" });
        connection.WaitForResourceHandler = (_, _, _, cancellationToken) =>
            Task.FromCanceled<WaitForResourceResponse>(cancellationToken);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = CreateTool(monitor);
 
        var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => tool.CallToolAsync(
                CallToolContextTestHelper.Create(),
                cancellationSource.Token).AsTask());
 
        Assert.Equal(cancellationSource.Token, exception.CancellationToken);
    }
 
    private static IReadOnlyDictionary<string, JsonElement> ParseArguments(string json)
    {
        using var document = JsonDocument.Parse(json);
        return document.RootElement
            .EnumerateObject()
            .ToDictionary(static property => property.Name, static property => property.Value.Clone());
    }
 
    private static TestAppHostAuxiliaryBackchannel CreateConnection(params ResourceSnapshot[] snapshots)
    {
        return new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = AppHostPath,
                ProcessId = 4242
            },
            ResourceSnapshots = [.. snapshots]
        };
    }
 
    private static WaitForResourcesTool CreateTool(
        TestAuxiliaryBackchannelMonitor monitor,
        TimeProvider? timeProvider = null)
    {
        return new WaitForResourcesTool(
            monitor,
            new ResourceWaitService(
                timeProvider ?? TimeProvider.System,
                NullLogger<ResourceWaitService>.Instance),
            NullLogger<WaitForResourcesTool>.Instance);
    }
 
    private static JsonDocument GetWaitResult(CallToolResult result)
    {
        Assert.True(result.IsError is null or false);
        var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
        const string marker = "# WAIT RESULT";
        var markerIndex = textContent.Text.IndexOf(marker, StringComparison.Ordinal);
        Assert.True(markerIndex >= 0, "Response should contain the wait result marker.");
 
        return JsonDocument.Parse(textContent.Text[(markerIndex + marker.Length)..].Trim());
    }
 
    private static void AssertResourceFailure(
        JsonElement resource,
        string name,
        string? state,
        string? health,
        string error)
    {
        Assert.Equal(name, resource.GetProperty("name").GetString());
        Assert.Equal(state, resource.TryGetProperty("state", out var stateElement) ? stateElement.GetString() : null);
        Assert.Equal(health, resource.TryGetProperty("health", out var healthElement) ? healthElement.GetString() : null);
        Assert.Equal("failure", resource.GetProperty("outcome").GetString());
        Assert.Equal(error, resource.GetProperty("error").GetString());
    }
 
    private static void AssertResourceOutcome(
        JsonElement resource,
        string name,
        string? state,
        string outcome,
        string? error)
    {
        Assert.Equal(name, resource.GetProperty("name").GetString());
        Assert.Equal(state, resource.TryGetProperty("state", out var stateElement) ? stateElement.GetString() : null);
        Assert.Equal(outcome, resource.GetProperty("outcome").GetString());
        Assert.Equal(error, resource.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null);
    }
}