File: Mcp\ListResourcesToolTests.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.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.Logging.Testing;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Tests.Mcp;
 
public class ListResourcesToolTests(ITestOutputHelper outputHelper)
{
    private const string AppHostPath = "/repo/TestAppHost/TestAppHost.csproj";
 
    [Fact]
    public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout();
 
        Assert.Contains("No Aspire AppHost", exception.Message);
        Assert.Contains("aspire start", exception.Message);
    }
 
    [Fact]
    public async Task ListResourcesTool_ReportsOutOfScopeAppHostsWithoutExposingTheirIdentity()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = CreateConnection("/other/Private/Private.AppHost.csproj");
        connection.IsInScope = false;
        monitor.AddConnection("hash1", "socket.hash1", connection);
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout();
 
        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 ListResourcesTool_ReturnsExplicitEmptyResult_WhenSnapshotsAreEmpty()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection());
 
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        Assert.True(result.IsError is null or false);
        using var json = GetResourceData(result);
 
        Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
        Assert.Empty(json.RootElement.EnumerateArray());
    }
 
    [Fact]
    public async Task ListResourcesTool_SerializesEmptyCollectionsAsArrays()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(new ResourceSnapshot
            {
                Name = "api-service",
                ResourceType = "Project",
                State = "Running"
            }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var resource = json.RootElement[0];
        Assert.Equal("[]", resource.GetProperty("waiting_for").GetRawText());
        Assert.Equal("[]", resource.GetProperty("urls").GetRawText());
        Assert.Equal("[]", resource.GetProperty("relationships").GetRawText());
        Assert.Equal("{}", resource.GetProperty("commands").GetRawText());
    }
 
    [Fact]
    public async Task ListResourcesTool_FansOutAndDeduplicatesRelationshipsToReplicaRuntimeNames()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(
                new ResourceSnapshot
                {
                    Name = "api",
                    DisplayName = "API",
                    ResourceType = "Project",
                    State = "Running",
                    Relationships =
                    [
                        new ResourceSnapshotRelationship
                        {
                            ResourceName = "Redis",
                            Type = "Reference"
                        },
                        new ResourceSnapshotRelationship
                        {
                            ResourceName = "Redis",
                            Type = "Reference"
                        }
                    ]
                },
                new ResourceSnapshot
                {
                    Name = "redis-instance-1",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Running"
                },
                new ResourceSnapshot
                {
                    Name = "redis-instance-2",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Running"
                },
                new ResourceSnapshot
                {
                    Name = "redis-instance-3",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Running"
                }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var relationships = json.RootElement
            [0]
            .GetProperty("relationships")
            .EnumerateArray()
            .ToArray();
        Assert.Equal(3, relationships.Length);
        Assert.All(
            relationships,
            relationship => Assert.Equal("Reference", relationship.GetProperty("type").GetString()));
        Assert.Equal(
            ["redis-instance-1", "redis-instance-2", "redis-instance-3"],
            relationships.Select(relationship => relationship.GetProperty("resource_name").GetString()));
    }
 
    [Fact]
    public async Task ListResourcesTool_RelationshipFanOutExcludesHiddenDuplicateRuntimeName()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(
                new ResourceSnapshot
                {
                    Name = "api",
                    DisplayName = "API",
                    ResourceType = "Project",
                    State = "Running",
                    Relationships =
                    [
                        new ResourceSnapshotRelationship
                        {
                            ResourceName = "Redis",
                            Type = "Reference"
                        }
                    ]
                },
                new ResourceSnapshot
                {
                    Name = "redis-visible",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Running"
                },
                new ResourceSnapshot
                {
                    Name = "redis-hidden",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Hidden"
                }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        Assert.Equal(
            ["redis-visible"],
            json.RootElement[0]
                .GetProperty("relationships")
                .EnumerateArray()
                .Select(relationship => relationship.GetProperty("resource_name").GetString()));
        Assert.Equal(
            ["api", "redis-visible"],
            json.RootElement
                .EnumerateArray()
                .Select(resource => resource.GetProperty("name").GetString()));
    }
 
    [Fact]
    public async Task ListResourcesTool_WaitingForPreservesHiddenDuplicateIdentity()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(
                new ResourceSnapshot
                {
                    Name = "api",
                    DisplayName = "API",
                    ResourceType = "Project",
                    State = "Running",
                    WaitingFor = ["redis-visible", "redis-hidden"]
                },
                new ResourceSnapshot
                {
                    Name = "redis-visible",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Running"
                },
                new ResourceSnapshot
                {
                    Name = "redis-hidden",
                    DisplayName = "Redis",
                    ResourceType = "Container",
                    State = "Hidden"
                }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        Assert.Equal(
            ["redis-visible", "redis-hidden"],
            json.RootElement[0]
                .GetProperty("waiting_for")
                .EnumerateArray()
                .Select(value => value.GetString()));
        Assert.Equal(
            ["api", "redis-visible"],
            json.RootElement
                .EnumerateArray()
                .Select(resource => resource.GetProperty("name").GetString()));
    }
 
    [Fact]
    public async Task ListResourcesTool_DoesNotExposeMatchingExplicitSymlinkedAppHostPath()
    {
        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);
        monitor.AddConnection("hash1", "socket.hash1", connection);
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
    }
 
    [Fact]
    public async Task ListResourcesTool_ReturnsMultipleResources()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = CreateConnection(
            new ResourceSnapshot
            {
                Name = "api-service",
                DisplayName = "API Service",
                ResourceType = "Project",
                State = "Running"
            },
            new ResourceSnapshot
            {
                Name = "redis",
                DisplayName = "Redis",
                ResourceType = "Container",
                State = "Running"
            },
            new ResourceSnapshot
            {
                Name = "postgres",
                DisplayName = "PostgreSQL",
                ResourceType = "Container",
                State = "Starting"
            });
        monitor.AddConnection("hash1", "socket.hash1", connection);
 
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var resources = json.RootElement;
 
        Assert.Equal(["api-service", "postgres", "redis"], resources.EnumerateArray().Select(r => r.GetProperty("name").GetString()));
    }
 
    [Fact]
    public async Task ListResourcesTool_DescribesPaginationInputs()
    {
        var tool = new ListResourcesTool(new TestAuxiliaryBackchannelMonitor(), NullLogger<ListResourcesTool>.Instance);
 
        await Verify(tool.GetInputSchema().GetRawText(), "json");
    }
 
    [Theory]
    [InlineData("""{"offset":-1}""", "offset")]
    [InlineData("""{"offset":2147483648}""", "offset")]
    [InlineData("""{"offset":1.5}""", "offset")]
    [InlineData("""{"offset":"64"}""", "offset")]
    [InlineData("""{"offset":null}""", "offset")]
    [InlineData("""{"offset":true}""", "offset")]
    [InlineData("""{"limit":0}""", "limit")]
    [InlineData("""{"limit":65}""", "limit")]
    [InlineData("""{"limit":2147483648}""", "limit")]
    [InlineData("""{"limit":1.5}""", "limit")]
    [InlineData("""{"limit":"1"}""", "limit")]
    [InlineData("""{"limit":null}""", "limit")]
    [InlineData("""{"limit":[]}""", "limit")]
    [InlineData("""{"unexpected":"value"}""", "unexpected")]
    public async Task ListResourcesTool_RejectsInvalidPaginationBeforeReadingResources(string json, string invalidArgument)
    {
        using var arguments = JsonDocument.Parse(json);
        var tool = new ListResourcesTool(new TestAuxiliaryBackchannelMonitor(), NullLogger<ListResourcesTool>.Instance);
        var context = CallToolContextTestHelper.Create(
            arguments.RootElement.EnumerateObject().ToDictionary(property => property.Name, property => property.Value));
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(context, CancellationToken.None).AsTask()).DefaultTimeout();
 
        Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
        Assert.Equal(invalidArgument switch
        {
            "offset" => "Argument 'offset' must be an integer from 0 through 2147483647.",
            "limit" => "Argument 'limit' must be an integer from 1 through 64.",
            _ => "Arguments may contain only 'offset' and 'limit'."
        }, exception.Message);
    }
 
    [Fact]
    public async Task ListResourcesTool_PagesThroughEveryVisibleResourceInStableOrder()
    {
        var names = Enumerable.Range(0, 140).Select(index => $"resource-{index:D3}").ToArray();
        var connection = CreateConnection(names.Reverse().Select(name => new ResourceSnapshot
        {
            Name = name,
            State = "Running"
        }).ToArray());
        connection.ResourceSnapshots.Insert(0, new ResourceSnapshot { Name = "hidden", IsHidden = true });
        connection.ResourceSnapshots.Insert(1, new ResourceSnapshot { Name = "hidden-state", State = "Hidden" });
        connection.ResourceSnapshots.Insert(2, new ResourceSnapshot
        {
            Name = "excluded",
            Properties = new Dictionary<string, JsonNode?>
            {
                [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true)
            }
        });
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", connection);
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var returnedNames = new List<string?>();
 
        foreach (var offset in new[] { 0, 64, 128 })
        {
            connection.ResourceSnapshots.Reverse();
            var context = CallToolContextTestHelper.Create(new Dictionary<string, JsonElement>
            {
                ["offset"] = JsonSerializer.SerializeToElement(offset)
            });
            var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout();
 
            using var resources = GetResourceData(result);
            using var pagination = GetPagination(result);
            var pageNames = resources.RootElement.EnumerateArray().Select(resource => resource.GetProperty("name").GetString()).ToArray();
            Assert.Equal(names.Skip(offset).Take(64), pageNames);
            returnedNames.AddRange(pageNames);
            Assert.Equal(offset, pagination.RootElement.GetProperty("offset").GetInt32());
            Assert.Equal(64, pagination.RootElement.GetProperty("limit").GetInt32());
            Assert.Equal(names.Length, pagination.RootElement.GetProperty("total").GetInt32());
            Assert.Equal(offset < 128, pagination.RootElement.TryGetProperty("next_offset", out var nextOffset));
            if (offset < 128)
            {
                Assert.Equal(offset + 64, nextOffset.GetInt32());
            }
        }
 
        Assert.Equal(names, returnedNames);
    }
 
    [Theory]
    [InlineData(0, 1, 1, 1)]
    [InlineData(1, 1, 1, 2)]
    [InlineData(1, 64, 2, null)]
    [InlineData(3, 2, 0, null)]
    [InlineData(int.MaxValue, 64, 0, null)]
    public async Task ListResourcesTool_HonorsPageSizeAndHandlesOffsetsPastTheEnd(
        int offset, int limit, int expectedCount, int? expectedNextOffset)
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection(
            new ResourceSnapshot { Name = "first" },
            new ResourceSnapshot { Name = "second" },
            new ResourceSnapshot { Name = "third" }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var context = CallToolContextTestHelper.Create(new Dictionary<string, JsonElement>
        {
            ["offset"] = JsonSerializer.SerializeToElement(offset),
            ["limit"] = JsonSerializer.SerializeToElement(limit)
        });
 
        var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout();
 
        using var resources = GetResourceData(result);
        using var pagination = GetPagination(result);
        Assert.Equal(expectedCount, resources.RootElement.GetArrayLength());
        Assert.Equal(
            new[] { "first", "second", "third" }.Skip(offset).Take(limit),
            resources.RootElement.EnumerateArray().Select(resource => resource.GetProperty("name").GetString()));
        Assert.Equal(3, pagination.RootElement.GetProperty("total").GetInt32());
        Assert.Equal(expectedNextOffset is not null, pagination.RootElement.TryGetProperty("next_offset", out var nextOffset));
        if (expectedNextOffset is not null)
        {
            Assert.Equal(expectedNextOffset.Value, nextOffset.GetInt32());
        }
    }
 
    [Fact]
    public async Task ListResourcesTool_PreservesCrossPageRelationshipsAndHiddenReplicaIdentity()
    {
        var snapshots = Enumerable.Range(0, 300).Select(index => new ResourceSnapshot
        {
            Name = $"resource-{index:D3}"
        }).ToList();
        snapshots.Insert(0, new ResourceSnapshot
        {
            Name = "api",
            WaitingFor = ["redis-visible", "redis-hidden"],
            Relationships =
            [
                new ResourceSnapshotRelationship { ResourceName = "Redis", Type = "Reference" }
            ]
        });
        snapshots.Add(new ResourceSnapshot { Name = "redis-visible", DisplayName = "Redis" });
        snapshots.Add(new ResourceSnapshot { Name = "redis-hidden", DisplayName = "Redis", IsHidden = true });
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection([.. snapshots]));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var context = CallToolContextTestHelper.Create(new Dictionary<string, JsonElement>
        {
            ["limit"] = JsonSerializer.SerializeToElement(1)
        });
 
        var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout();
 
        await Verify(GetResultText(result), "txt");
    }
 
    [Fact]
    public async Task ListResourcesTool_ListsCommandMetadataWithoutCurrentOrDefaultInputValues()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection(new ResourceSnapshot
        {
            Name = "api",
            Commands =
            [
                new ResourceSnapshotCommand
                {
                    Name = "reload",
                    State = "Enabled",
                    Description = "Reload configuration.",
                    ArgumentInputs =
                    [
                        new ResourceSnapshotCommandArgument
                        {
                            Name = "configuration",
                            InputType = "Text",
                            Description = "Configuration name.",
                            Required = true,
                            MaxLength = 100,
                            Value = "private-default-configuration",
                            Placeholder = "private-placeholder"
                        },
                        new ResourceSnapshotCommandArgument
                        {
                            Name = "password",
                            InputType = "SecretText",
                            Required = true,
                            Value = "secret-password",
                            Options = new Dictionary<string, string?> { ["secret-option"] = "secret-label" }
                        },
                        new ResourceSnapshotCommandArgument
                        {
                            Name = "environment",
                            InputType = "Choice",
                            Required = true,
                            AllowCustomChoice = true,
                            Options = new Dictionary<string, string?> { ["staging"] = "Staging", ["production"] = "Production" },
                            Value = "private-current-environment",
                            DynamicLoading = new ResourceSnapshotCommandArgumentDynamicLoading
                            {
                                AlwaysLoadOnStart = true,
                                DependsOnInputs = ["configuration"]
                            }
                        },
                        new ResourceSnapshotCommandArgument { Name = "force", InputType = "Boolean", Value = "true" },
                        new ResourceSnapshotCommandArgument { Name = "attempts", InputType = "Number", Value = "5", Disabled = true }
                    ]
                },
                new ResourceSnapshotCommand { Name = "disabled", State = "disabled", Visibility = "api" },
                new ResourceSnapshotCommand { Name = "hidden", State = "Hidden" },
                new ResourceSnapshotCommand { Name = "ui-only", State = "Enabled", Visibility = "UI" },
                new ResourceSnapshotCommand { Name = "none", State = "Enabled", Visibility = "None" }
            ]
        }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        await Verify(json.RootElement[0].GetProperty("commands").GetRawText(), "json");
    }
 
    [Fact]
    public async Task ListResourcesTool_BoundsCommandDescriptionsWithoutTruncatingInvocationMetadata()
    {
        var description = new string('x', 255) + "\U0001F680\ntrailing text";
        var commands = Enumerable.Range(0, 40).Select(index => new ResourceSnapshotCommand
        {
            Name = $"command-{index:D3}",
            State = "Enabled",
            Description = description,
            ArgumentInputs = index == 39
                ? Enumerable.Range(0, 40).Select(argumentIndex => new ResourceSnapshotCommandArgument
                {
                    Name = $"argument-{argumentIndex:D3}",
                    InputType = "Choice",
                    Description = description,
                    Options = argumentIndex == 39
                        ? Enumerable.Range(0, 40).ToDictionary(optionIndex => $"option-{optionIndex:D3}", _ => (string?)description)
                        : null
                }).ToArray()
                : []
        }).ToArray();
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection(new ResourceSnapshot { Name = "api", Commands = commands }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var context = CallToolContextTestHelper.Create(new Dictionary<string, JsonElement>
        {
            ["limit"] = JsonSerializer.SerializeToElement(1)
        });
 
        var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var metadata = json.RootElement[0].GetProperty("commands");
        Assert.Equal(commands.Select(command => command.Name), metadata.EnumerateObject().Select(command => command.Name));
        var command = metadata.GetProperty("command-039");
        Assert.Equal(new string('x', 255) + "\U0001F680", command.GetProperty("description").GetString());
        var arguments = command.GetProperty("argument_inputs");
        Assert.Equal(commands[39].ArgumentInputs.Select(input => input.Name),
            arguments.EnumerateArray().Select(input => input.GetProperty("name").GetString()));
        var argument = arguments[39];
        Assert.Equal(new string('x', 255) + "\U0001F680", argument.GetProperty("description").GetString());
        var options = argument.GetProperty("options");
        Assert.Equal(commands[39].ArgumentInputs[39].Options!.Keys, options.EnumerateObject().Select(option => option.Name));
        Assert.All(options.EnumerateObject(), option => Assert.Equal(new string('x', 255) + "\U0001F680", option.Value.GetString()));
    }
 
    [Theory]
    [InlineData(null, null)]
    [InlineData("Active", "Active")]
    [InlineData("Building", "Building")]
    [InlineData("Exited", "Exited")]
    [InlineData("FailedToStart", "FailedToStart")]
    [InlineData("Finished", "Finished")]
    [InlineData("NotStarted", "NotStarted")]
    [InlineData("Running", "Running")]
    [InlineData("RuntimeUnhealthy", "RuntimeUnhealthy")]
    [InlineData("Starting", "Starting")]
    [InlineData("Stopping", "Stopping")]
    [InlineData("ValueMissing", "ValueMissing")]
    [InlineData("Waiting", "Waiting")]
    [InlineData("Unknown", "unknown")]
    [InlineData("Downloading model ghcr.io/private/repository:latest", "unknown")]
    [InlineData("running", "unknown")]
    public async Task ListResourcesTool_ProjectsOnlySupportedResourceStates(string? state, string? expectedState)
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(new ResourceSnapshot
            {
                Name = "model",
                ResourceType = "Custom",
                State = state
            }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var resource = json.RootElement[0];
        if (expectedState is null)
        {
            Assert.False(resource.TryGetProperty("state", out _));
        }
        else
        {
            Assert.Equal(expectedState, resource.GetProperty("state").GetString());
        }
    }
 
    [Fact]
    public async Task ListResourcesTool_BoundsModelFacingCollectionsAndText()
    {
        var resourceNames = Enumerable.Range(0, 70).Select(index => $"resource-{index}").ToArray();
        var longText = new string('x', 300) + "\ncontrol";
        var snapshots = resourceNames.Select((name, index) => new ResourceSnapshot
        {
            Name = name,
            DisplayName = index == 0 ? longText : name,
            ResourceType = index == 0 ? longText : "Custom",
            State = index == 0 ? longText : "Running",
            StateStyle = index == 0 ? longText : null,
            HealthStatus = index == 0 ? longText : null,
            WaitingFor = index == 0 ? resourceNames[1..41] : [],
            Urls = index == 0
                ? Enumerable.Range(0, 20)
                    .Select(urlIndex => new ResourceSnapshotUrl
                    {
                        Name = longText,
                        Url = $"https://example.com/{urlIndex}/{longText}"
                    })
                    .ToArray()
                : [],
            Relationships = index == 0
                ? resourceNames[1..41]
                    .Select(target => new ResourceSnapshotRelationship
                    {
                        Type = longText,
                        ResourceName = target
                    })
                    .ToArray()
                : []
        }).ToArray();
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection(snapshots));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        Assert.Equal(64, json.RootElement.GetArrayLength());
        using var pagination = GetPagination(result);
        Assert.Equal(70, pagination.RootElement.GetProperty("total").GetInt32());
        Assert.Equal(64, pagination.RootElement.GetProperty("next_offset").GetInt32());
        var firstResource = json.RootElement[0];
        foreach (var propertyName in new[] { "display_name", "resource_type", "state_style", "health_status" })
        {
            var value = Assert.IsType<string>(firstResource.GetProperty(propertyName).GetString());
            Assert.Equal(256, value.Length);
            Assert.All(value, character => Assert.False(char.IsControl(character)));
        }
        Assert.Equal("unknown", firstResource.GetProperty("state").GetString());
        Assert.Equal(32, firstResource.GetProperty("waiting_for").GetArrayLength());
        Assert.Equal(16, firstResource.GetProperty("urls").GetArrayLength());
        Assert.Equal(32, firstResource.GetProperty("relationships").GetArrayLength());
    }
 
    [Fact]
    public async Task ListResourcesTool_UsesCrossPlatformBasenamesForSources()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(
                new ResourceSnapshot
                {
                    Name = "api-service",
                    ResourceType = "Project",
                    State = "Running",
                    Properties = new Dictionary<string, JsonNode?>
                    {
                        [KnownProperties.Project.Path] = JsonValue.Create(@"C:\repo\Api\Api.csproj")
                    }
                },
                new ResourceSnapshot
                {
                    Name = "worker",
                    ResourceType = "Executable",
                    State = "Running",
                    Properties = new Dictionary<string, JsonNode?>
                    {
                        [KnownProperties.Executable.Path] = JsonValue.Create(@"C:\repo\bin\worker.exe")
                    }
                }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var resources = json.RootElement;
        Assert.Equal("Api.csproj", resources[0].GetProperty("source").GetString());
        Assert.Equal("worker.exe", resources[1].GetProperty("source").GetString());
    }
 
    [Fact]
    public async Task ListResourcesTool_ExplicitEmptySourceSuppressesInferredSource()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(
                new ResourceSnapshot
                {
                    Name = "project",
                    ResourceType = KnownResourceTypes.Project,
                    State = "Running",
                    Properties = new Dictionary<string, JsonNode?>
                    {
                        [KnownProperties.Resource.Source] = JsonValue.Create(string.Empty),
                        [KnownProperties.Project.Path] = JsonValue.Create("/repo/Project/Project.csproj")
                    }
                },
                new ResourceSnapshot
                {
                    Name = "executable",
                    ResourceType = KnownResourceTypes.Executable,
                    State = "Running",
                    Properties = new Dictionary<string, JsonNode?>
                    {
                        [KnownProperties.Resource.Source] = JsonValue.Create(string.Empty),
                        [KnownProperties.Executable.Path] = JsonValue.Create("/repo/bin/worker")
                    }
                },
                new ResourceSnapshot
                {
                    Name = "container",
                    ResourceType = KnownResourceTypes.Container,
                    State = "Running",
                    Properties = new Dictionary<string, JsonNode?>
                    {
                        [KnownProperties.Resource.Source] = JsonValue.Create(string.Empty),
                        [KnownProperties.Container.Image] = JsonValue.Create("redis:8")
                    }
                }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        Assert.Equal(["container", "executable", "project"], json.RootElement.EnumerateArray().Select(resource => resource.GetProperty("name").GetString()));
        Assert.All(json.RootElement.EnumerateArray(), resource => Assert.False(resource.TryGetProperty("source", out _)));
    }
 
    [Fact]
    public async Task ListResourcesTool_BoundsSourceByUnicodeScalarsAndSanitizesControls()
    {
        const string AstralRune = "\U0001F680";
        var sourceValue = new string('x', 254) + "\n" + AstralRune + "-truncated";
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(new ResourceSnapshot
            {
                Name = "unicode-source",
                ResourceType = "Container",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Container.Image] = JsonValue.Create(sourceValue)
                }
            }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var source = Assert.IsType<string>(json.RootElement[0].GetProperty("source").GetString());
        Assert.Equal(new string('x', 254) + " " + AstralRune, source);
        Assert.Equal(256, source.EnumerateRunes().Count());
    }
 
    [Fact]
    public async Task ListResourcesTool_AppliesResourceEndpointUrlPolicy()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(new ResourceSnapshot
            {
                Name = "endpoints",
                ResourceType = "Custom",
                State = "Running",
                Urls =
                [
                    new ResourceSnapshotUrl { Name = "tcp", Url = "tcp://cache.example.com:6379" },
                    new ResourceSnapshotUrl { Name = "udp", Url = "udp://dns.example.com:53" },
                    new ResourceSnapshotUrl { Name = "ws", Url = "ws://events.example.com/socket" },
                    new ResourceSnapshotUrl { Name = "wss", Url = "wss://events.example.com/socket" },
                    new ResourceSnapshotUrl { Name = "postgres", Url = "postgresql://db.example.com:5432/catalog" },
                    new ResourceSnapshotUrl { Name = "file", Url = "file:///repo/private.txt" },
                    new ResourceSnapshotUrl { Name = "windows", Url = @"C:\repo\private.txt" }
                ]
            }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(),
            CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var urls = json.RootElement[0].GetProperty("urls");
        Assert.Equal(
            [
                "tcp://cache.example.com:6379",
                "udp://dns.example.com:53",
                "ws://events.example.com/socket",
                "wss://events.example.com/socket",
                "postgresql://db.example.com:5432/catalog"
            ],
            urls.EnumerateArray()
                .Where(url => url.TryGetProperty("url", out _))
                .Select(url => url.GetProperty("url").GetString()));
        Assert.False(urls[5].TryGetProperty("url", out _));
        Assert.False(urls[6].TryGetProperty("url", out _));
    }
 
    [Fact]
    public async Task ListResourcesTool_ReturnsOnlyBoundedResourceDataAndSanitizedUrls()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = CreateConnection(
            new ResourceSnapshot
            {
                Name = "api-service",
                DisplayName = "API Service",
                ResourceType = "Project",
                State = "Running",
                WaitingFor = ["Redis"],
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Project.Path] = JsonValue.Create("/repo/Api/Api.csproj"),
                    ["secret.property"] = JsonValue.Create("property-secret")
                },
                EnvironmentVariables =
                [
                    new ResourceSnapshotEnvironmentVariable
                    {
                        Name = "API_PASSWORD",
                        Value = "environment-secret",
                        IsFromSpec = true
                    }
                ],
                Urls =
                [
                    new ResourceSnapshotUrl
                    {
                        Name = "https",
                        Url = "https://endpoint-user:endpoint-password@localhost:5001/api?view=summary&TOKEN=endpoint-secret#access_token=fragment-secret",
                        IsInternal = true,
                        DisplayProperties = new ResourceSnapshotUrlDisplayProperties
                        {
                            DisplayName = "HTTPS"
                        }
                    }
                ],
                Relationships =
                [
                    new ResourceSnapshotRelationship
                    {
                        ResourceName = "Redis",
                        Type = "Reference"
                    }
                ],
                Volumes =
                [
                    new ResourceSnapshotVolume
                    {
                        Source = "/repo/private",
                        Target = "/app/private",
                        MountType = "bind"
                    }
                ],
                HealthReports =
                [
                    new ResourceSnapshotHealthReport
                    {
                        Name = "ready",
                        Status = "Healthy",
                        Description = "health-secret",
                        ExceptionText = "exception-secret"
                    }
                ],
                Commands =
                [
                    new ResourceSnapshotCommand
                    {
                        Name = "connect",
                        State = "Enabled",
                        ArgumentInputs =
                        [
                            new ResourceSnapshotCommandArgument
                            {
                                Name = "password",
                                InputType = "SecretText",
                                Value = "command-secret"
                            }
                        ]
                    }
                ]
            },
            new ResourceSnapshot
            {
                Name = "redis-instance",
                DisplayName = "Redis",
                ResourceType = "Container",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Container.Image] = JsonValue.Create("redis:8"),
                    [KnownProperties.Executable.Path] = JsonValue.Create("/repo/bin/container-secret")
                }
            },
            new ResourceSnapshot
            {
                Name = "worker",
                ResourceType = "Executable",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Executable.Path] = JsonValue.Create("/repo/bin/worker")
                }
            },
            new ResourceSnapshot
            {
                Name = "custom",
                ResourceType = "Custom",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Resource.Source] = JsonValue.Create("generic-source-secret")
                }
            },
            new ResourceSnapshot
            {
                Name = "case-mismatched-project",
                ResourceType = "project",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Project.Path] = JsonValue.Create("/repo/CaseMismatched/CaseMismatched.csproj"),
                    [KnownProperties.Resource.Source] = JsonValue.Create("case-mismatch-secret")
                }
            });
        connection.DashboardUrlsState = new DashboardUrlsState
        {
            BaseUrlWithLoginToken = "https://dashboard-user:dashboard-password@dashboard.localhost:18888/login?t=dashboard-secret&view=resources#access_token=fragment-secret"
        };
        monitor.AddConnection("hash1", "socket.hash1", connection);
 
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        var resources = json.RootElement;
        var resource = resources[0];
 
        Assert.Equal(
            ["name", "display_name", "resource_type", "state", "waiting_for", "source", "dashboard_url", "urls", "relationships", "commands"],
            resource.EnumerateObject().Select(p => p.Name));
        Assert.Equal(["Redis"], resource.GetProperty("waiting_for").EnumerateArray().Select(value => value.GetString()));
        Assert.Equal("Api.csproj", resource.GetProperty("source").GetString());
        Assert.Equal(
            "https://localhost:5001/api?view=summary",
            resource.GetProperty("urls")[0].GetProperty("url").GetString());
        Assert.Equal(
            ["name", "display_name", "url", "is_internal"],
            resource.GetProperty("urls")[0].EnumerateObject().Select(p => p.Name));
        Assert.Equal(
            ["type", "resource_name"],
            resource.GetProperty("relationships")[0].EnumerateObject().Select(p => p.Name));
        Assert.Equal("redis-instance", resource.GetProperty("relationships")[0].GetProperty("resource_name").GetString());
        Assert.Equal(
            "https://dashboard.localhost:18888?view=resources&resource=api-service",
            resource.GetProperty("dashboard_url").GetString());
        Assert.False(resources[1].TryGetProperty("source", out _));
        Assert.False(resources[2].TryGetProperty("source", out _));
        Assert.Equal("redis:8", resources[3].GetProperty("source").GetString());
        Assert.Equal("worker", resources[4].GetProperty("source").GetString());
        await Verify(resource.GetProperty("commands").GetRawText(), "json");
    }
 
    [Fact]
    public async Task ListResourcesTool_DoesNotMaterializeUnrelatedProperties()
    {
        var cyclicValue = new List<object?>();
        cyclicValue.Add(cyclicValue);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection(
            "hash1",
            "socket.hash1",
            CreateConnection(new ResourceSnapshot
            {
                Name = "custom",
                ResourceType = "Custom",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    ["unrelated.full.property"] = JsonValue.Create<object>(cyclicValue)
                }
            }));
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout();
 
        using var json = GetResourceData(result);
        Assert.Equal(
            ["name", "resource_type", "state", "waiting_for", "dashboard_url", "urls", "relationships", "commands"],
            json.RootElement[0].EnumerateObject().Select(property => property.Name));
    }
 
    [Fact]
    public async Task ListResourcesTool_ThrowsMcpErrorWithoutSensitiveDetails_WhenSnapshotRetrievalFails()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = CreateConnection();
        connection.GetResourceSnapshotsHandler = _ => throw new InvalidOperationException(
            "secret-value /other/Unrelated.AppHost.csproj PID 9876");
        monitor.AddConnection("hash1", "socket.hash1", connection);
        var sink = new TestSink();
        var logger = new TestLogger<ListResourcesTool>(
            new TestLoggerFactory(sink, enabled: true));
        var tool = new ListResourcesTool(monitor, logger);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout();
 
        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);
        Assert.Collection(
            sink.Writes,
            write =>
            {
                Assert.Equal($"Using single in-scope AppHost: {AppHostPath}", write.Message);
                Assert.Null(write.Exception);
            },
            write =>
            {
                Assert.Equal(
                    $"Error retrieving resources for AppHost {AppHostPath}: InvalidOperationException",
                    write.Message);
                Assert.Null(write.Exception);
            });
    }
 
    [Fact]
    public async Task ListResourcesTool_PropagatesRequestCancellationUnchanged()
    {
        using var cancellationSource = new CancellationTokenSource();
        var expectedException = new OperationCanceledException(cancellationSource.Token);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = CreateConnection();
        connection.GetResourceSnapshotsHandler = cancellationToken =>
        {
            Assert.Equal(cancellationSource.Token, cancellationToken);
            throw expectedException;
        };
        monitor.AddConnection("hash1", "socket.hash1", connection);
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), cancellationSource.Token).AsTask()).DefaultTimeout();
 
        Assert.Same(expectedException, exception);
    }
 
    [Fact]
    public async Task ListResourcesTool_DoesNotExposeCandidateAppHostPaths_WhenMultipleAppHostsAreAvailable()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection("/repo/First/First.AppHost.csproj"));
        monitor.AddConnection("hash2", "socket.hash2", CreateConnection("/repo/Second/Second.AppHost.csproj"));
 
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout();
 
        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);
    }
 
    [Fact]
    public async Task ListResourcesTool_DoesNotFallBack_WhenPinnedAppHostIsUnavailable()
    {
        const string pinnedAppHostPath = "/repo/Pinned/Pinned.AppHost.csproj";
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            SelectedAppHostPath = pinnedAppHostPath
        };
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection("/repo/Other/Other.AppHost.csproj"));
 
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout();
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
        Assert.Equal(
            "The selected AppHost is not available. Start that AppHost and retry.",
            exception.Message);
        Assert.DoesNotContain(pinnedAppHostPath, exception.Message, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task ListResourcesTool_PreservesDuplicatePinnedAppHostDiagnosis()
    {
        const string pinnedAppHostPath = "/repo/Pinned/Pinned.AppHost.csproj";
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            SelectedAppHostPath = pinnedAppHostPath
        };
        monitor.AddConnection("hash1", "socket.hash1", CreateConnection(pinnedAppHostPath));
        monitor.AddConnection("hash2", "socket.hash2", CreateConnection(pinnedAppHostPath));
 
        var tool = new ListResourcesTool(monitor, NullLogger<ListResourcesTool>.Instance);
 
        var exception = await Assert.ThrowsAsync<McpProtocolException>(
            () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout();
 
        Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
        Assert.Equal(
            "Multiple running AppHost instances match the selected path. Stop the extra instance and retry.",
            exception.Message);
        Assert.DoesNotContain(pinnedAppHostPath, exception.Message, StringComparison.Ordinal);
    }
 
    private static TestAppHostAuxiliaryBackchannel CreateConnection(params ResourceSnapshot[] snapshots)
        => CreateConnection(AppHostPath, snapshots);
 
    private static TestAppHostAuxiliaryBackchannel CreateConnection(string appHostPath, params ResourceSnapshot[] snapshots)
    {
        return new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = appHostPath,
                ProcessId = 4242
            },
            ResourceSnapshots = [.. snapshots],
            DashboardUrlsState = new DashboardUrlsState
            {
                BaseUrlWithLoginToken = "http://localhost:18888/login?t=dashboard-secret"
            }
        };
    }
 
    private static JsonDocument GetResourceData(CallToolResult result)
    {
        Assert.NotNull(result.Content);
        var textContent = Assert.IsType<TextContentBlock>(Assert.Single(result.Content));
        const string marker = "# RESOURCE DATA";
        var markerIndex = textContent.Text.IndexOf(marker, StringComparison.Ordinal);
        Assert.True(markerIndex >= 0, "Response should contain the resource data marker.");
        var jsonText = textContent.Text[(markerIndex + marker.Length)..].Trim();
        Assert.StartsWith("[", jsonText, StringComparison.Ordinal);
 
        return JsonDocument.Parse(jsonText);
    }
 
    private static JsonDocument GetPagination(CallToolResult result)
    {
        // Responses contain "# PAGINATION", its JSON object, then "# RESOURCE DATA" and its array.
        var text = GetResultText(result);
        const string marker = "# PAGINATION";
        var start = text.IndexOf(marker, StringComparison.Ordinal);
        var end = text.IndexOf("# RESOURCE DATA", StringComparison.Ordinal);
        Assert.True(start >= 0 && end > start, "Response should contain pagination before resource data.");
 
        return JsonDocument.Parse(text[(start + marker.Length)..end].Trim());
    }
 
    private static string GetResultText(CallToolResult result)
    {
        Assert.NotNull(result.Content);
        return Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
    }
}