File: Mcp\Tools\ListResourcesTool.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Aspire.Shared;
using Aspire.Shared.Model.Serialization;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Mcp.Tools;
 
internal sealed class McpResourceUrlJson
{
    public string? Name { get; init; }
    public string? DisplayName { get; init; }
    public string? Url { get; init; }
    public bool IsInternal { get; init; }
}
 
internal sealed class McpResourceRelationshipJson
{
    public string? Type { get; init; }
    public string? ResourceName { get; init; }
}
 
internal sealed class McpResourceCommandArgumentJson
{
    public required string Name { get; init; }
    public required string InputType { get; init; }
    public string? Description { get; init; }
    public bool Required { get; init; }
    public Dictionary<string, string?>? Options { get; init; }
    public bool AllowCustomChoice { get; init; }
    public bool Disabled { get; init; }
    public int? MaxLength { get; init; }
    public ResourceCommandArgumentDynamicLoadingJson? DynamicLoading { get; init; }
}
 
internal sealed class McpResourceCommandJson
{
    public required string State { get; init; }
    public string? Description { get; init; }
    public McpResourceCommandArgumentJson[] ArgumentInputs { get; init; } = [];
}
 
internal sealed record McpResourcePageJson(int Offset, int Limit, int Total, int? NextOffset);
 
internal sealed class McpResourceJson
{
    public string? Name { get; init; }
    public string? DisplayName { get; init; }
    public string? ResourceType { get; init; }
    public string? State { get; init; }
    public string[] WaitingFor { get; init; } = [];
    public string? StateStyle { get; init; }
    public string? Source { get; init; }
    public int? ExitCode { get; init; }
    public string? HealthStatus { get; init; }
    public string? DashboardUrl { get; init; }
    public McpResourceUrlJson[] Urls { get; init; } = [];
    public McpResourceRelationshipJson[] Relationships { get; init; } = [];
    public Dictionary<string, McpResourceCommandJson> Commands { get; init; } = [];
}
 
[JsonSerializable(typeof(McpResourceJson[]))]
[JsonSerializable(typeof(McpResourceUrlJson[]))]
[JsonSerializable(typeof(McpResourceRelationshipJson[]))]
[JsonSerializable(typeof(McpResourcePageJson))]
[JsonSourceGenerationOptions(
    WriteIndented = true,
    PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal sealed partial class ListResourcesToolJsonContext : JsonSerializerContext
{
    private static ListResourcesToolJsonContext? s_relaxedEscaping;
 
    /// <summary>
    /// Gets a context with relaxed JSON escaping for non-ASCII character support (pretty-printed).
    /// </summary>
    public static ListResourcesToolJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    });
}
 
/// <summary>
/// MCP tool for listing application resources.
/// Gets resource data directly from the AppHost backchannel instead of forwarding to the dashboard.
/// </summary>
internal sealed class ListResourcesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger<ListResourcesTool> logger) : CliMcpTool
{
    private const int MaxResources = 64;
    private const int MaxUrlsPerResource = 16;
    private const int MaxRelationshipsPerResource = 32;
    private const int MaxWaitingForPerResource = 32;
    private const int MaxTextLength = 256;
 
    private static readonly JsonElement s_inputSchema = JsonDocument.Parse(
        $$"""
        {
          "type": "object",
          "properties": {
            "offset": {
              "type": "integer",
              "minimum": 0,
              "maximum": {{int.MaxValue}},
              "default": 0,
              "description": "Zero-based offset in the visible resources ordered by runtime name. Use next_offset from the previous response to continue."
            },
            "limit": {
              "type": "integer",
              "minimum": 1,
              "maximum": {{MaxResources}},
              "default": {{MaxResources}},
              "description": "Maximum number of resources to return in this page."
            }
          },
          "additionalProperties": false
        }
        """).RootElement;
 
    public override string Name => KnownMcpTools.ListResources;
 
    public override string Description => "List the application resources for the selected AppHost. Includes runtime information such as resource type, state, source, endpoints, health status, relationships, and API-visible command metadata without current or default argument values. Returns up to 64 resources ordered by runtime name; use offset and limit to page through all visible resources.";
 
    public override JsonElement GetInputSchema()
    {
        return s_inputSchema;
    }
 
    public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
    {
        var (offset, limit) = ParseArguments(context.Arguments);
        var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(
            auxiliaryBackchannelMonitor,
            logger,
            cancellationToken).ConfigureAwait(false);
 
        if (connection is null)
        {
            logger.LogWarning("No Aspire AppHost is currently running");
            throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError);
        }
 
        if (connection.AppHostInfo?.AppHostPath is not { Length: > 0 } appHostPath)
        {
            logger.LogWarning("The selected AppHost connection does not have a project path");
            throw new McpProtocolException("The selected AppHost project path is not available.", McpErrorCode.InternalError);
        }
 
        var selectedAppHostPath = auxiliaryBackchannelMonitor.SelectedAppHostPath;
        if (selectedAppHostPath is not null &&
            AppHostPathComparer.PathsEqual(selectedAppHostPath, appHostPath))
        {
            // Preserve the identity the caller selected (including a symlinked spelling) while
            // still requiring it to resolve to the connection chosen by the shared comparer.
            appHostPath = selectedAppHostPath;
        }
 
        try
        {
            // Get dashboard URL and resource snapshots in parallel
            var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
            var snapshotsTask = connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken);
 
            await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);
 
            var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false);
            var allSnapshots = await snapshotsTask.ConfigureAwait(false);
 
            // Hidden snapshots still participate in display-name identity so a dependency on
            // two runtime resources cannot collapse merely because one target is hidden.
            var eligibleSnapshots = allSnapshots
                .Where(snapshot => !McpToolHelpers.IsExcludedFromMcp(snapshot))
                .ToList();
            var visibleSnapshots = eligibleSnapshots
                .Where(snapshot => !ResourceSnapshotMapper.IsHiddenResource(snapshot))
                .OrderBy(snapshot => snapshot.Name, StringComparer.Ordinal)
                .ToList();
            var pageSnapshots = visibleSnapshots.Skip(offset).Take(limit).ToList();
 
            // Use the dashboard base URL if available
            var dashboardBaseUrl = McpToolHelpers.StripLoginPath(dashboardUrls?.BaseUrlWithLoginToken);
            // Resolve references against the whole snapshot, not just the current page: a
            // dependency on another page must retain its identity and replica ambiguity.
            var resourceIdentities = CreateResourceIdentityMap(eligibleSnapshots);
            var relationshipTargets = CreateRelationshipTargetMap(visibleSnapshots);
 
            // Project directly from the snapshot so unrelated properties, volumes, command
            // argument values, environment values, and health details never enter serialization.
            var boundedResources = pageSnapshots.Select(snapshot => new McpResourceJson
            {
                Name = GetBoundedText(snapshot.Name),
                DisplayName = GetBoundedText(snapshot.DisplayName),
                ResourceType = GetBoundedText(snapshot.ResourceType),
                State = McpToolHelpers.MapResourceState(snapshot.State),
                WaitingFor = GetBoundedWaitingFor(snapshot, resourceIdentities),
                StateStyle = GetBoundedText(snapshot.StateStyle),
                Source = GetBoundedSource(snapshot),
                ExitCode = snapshot.ExitCode,
                HealthStatus = GetBoundedText(snapshot.HealthStatus),
                DashboardUrl = GetDashboardUrl(snapshot, dashboardBaseUrl),
                Urls = snapshot.Urls.Take(MaxUrlsPerResource).Select(url => new McpResourceUrlJson
                {
                    Name = GetBoundedText(url.Name),
                    DisplayName = GetBoundedText(url.DisplayProperties?.DisplayName),
                    Url = GetBoundedText(McpToolHelpers.SanitizeResourceUrl(url.Url)),
                    IsInternal = url.IsInternal
                }).ToArray(),
                Relationships = GetBoundedRelationships(snapshot, relationshipTargets),
                Commands = GetCommandMetadata(snapshot)
            }).ToArray();
            var resourceGraphData = JsonSerializer.Serialize(boundedResources, ListResourcesToolJsonContext.RelaxedEscaping.McpResourceJsonArray);
            int? nextOffset = offset < visibleSnapshots.Count - boundedResources.Length
                ? offset + boundedResources.Length
                : null;
            var pagination = JsonSerializer.Serialize(
                new McpResourcePageJson(offset, limit, visibleSnapshots.Count, nextOffset),
                ListResourcesToolJsonContext.RelaxedEscaping.McpResourcePageJson);
 
            var response = $"""
            resource_name is the identifier of resources.
            Console logs for a resource can provide more information about why a resource is not in a running state.
            Command names and argument names can be passed to execute_resource_command. Current and default argument values are never included.
            Use next_offset with the same limit to retrieve the next page. An absent next_offset means there are no more resources.
            Each page reads current state; restart from offset 0 if resource membership changes while paging.
 
            # PAGINATION
 
            {pagination}
 
            # RESOURCE DATA
 
            {resourceGraphData}
            """;
 
            return new CallToolResult
            {
                Content = [new TextContentBlock { Text = response }]
            };
        }
        catch (Exception ex) when (ex is not McpProtocolException and not OperationCanceledException)
        {
            logger.LogError(
                "Error retrieving resources for AppHost {AppHostPath}: {Diagnostic}",
                appHostPath,
                McpToolHelpers.GetBoundedExceptionDiagnostic(ex));
            throw new McpProtocolException(
                "Unable to retrieve resources from the selected AppHost.",
                McpErrorCode.InternalError);
        }
    }
 
    private static (int Offset, int Limit) ParseArguments(IReadOnlyDictionary<string, JsonElement>? arguments)
    {
        // MCP pagination arguments use { "offset": 64, "limit": 64 }. Omitted fields retain
        // the first-page defaults; strings, nulls, fractions, and out-of-range values are errors.
        if (arguments?.Keys.Any(static name => name is not ("offset" or "limit")) == true)
        {
            throw new McpProtocolException(
                "Arguments may contain only 'offset' and 'limit'.",
                McpErrorCode.InvalidParams);
        }
 
        var offset = 0;
        if (arguments?.TryGetValue("offset", out var offsetElement) == true &&
            (offsetElement.ValueKind != JsonValueKind.Number ||
                !offsetElement.TryGetInt32(out offset) || offset < 0))
        {
            throw new McpProtocolException(
                $"Argument 'offset' must be an integer from 0 through {int.MaxValue}.",
                McpErrorCode.InvalidParams);
        }
 
        var limit = MaxResources;
        if (arguments?.TryGetValue("limit", out var limitElement) == true &&
            (limitElement.ValueKind != JsonValueKind.Number ||
                !limitElement.TryGetInt32(out limit) || limit is < 1 or > MaxResources))
        {
            throw new McpProtocolException(
                $"Argument 'limit' must be an integer from 1 through {MaxResources}.",
                McpErrorCode.InvalidParams);
        }
 
        return (offset, limit);
    }
 
    private static Dictionary<string, McpResourceCommandJson> GetCommandMetadata(ResourceSnapshot snapshot)
    {
        // The general CLI mapper retains non-secret input values. MCP instead exposes only
        // invocation metadata, including disabled API commands but never hidden or UI-only ones.
        // Keep identifiers and choice keys exact, and keep their complete collections: truncating
        // them would advertise a different command or an incomplete invocation contract.
        return snapshot.Commands
            .Where(command => ResourceSnapshotMapper.IsCommandVisibleToApi(command.Visibility) &&
                ResourceSnapshotMapper.IsCommandVisibleToConsumer(command.State, includeDisabledCommands: true))
            .OrderBy(command => command.Name, StringComparer.Ordinal)
            .ToDistinctDictionary(
                command => command.Name,
                command => new McpResourceCommandJson
                {
                    State = string.Equals(command.State, KnownCommandState.Enabled, StringComparison.OrdinalIgnoreCase)
                        ? KnownCommandState.Enabled
                        : KnownCommandState.Disabled,
                    Description = GetBoundedText(command.Description),
                    ArgumentInputs = command.ArgumentInputs.Select(input => new McpResourceCommandArgumentJson
                    {
                        Name = input.Name,
                        InputType = GetBoundedText(input.InputType)!,
                        Description = GetBoundedText(input.Description),
                        Required = input.Required,
                        Options = string.Equals(input.InputType, nameof(InputType.Choice), StringComparison.OrdinalIgnoreCase)
                            ? input.Options?.ToDictionary(option => option.Key, option => GetBoundedText(option.Value))
                            : null,
                        AllowCustomChoice = input.AllowCustomChoice,
                        Disabled = input.Disabled,
                        MaxLength = input.MaxLength,
                        DynamicLoading = ResourceSnapshotMapper.MapDynamicLoading(input.DynamicLoading)
                    }).ToArray()
                });
    }
 
    private static Dictionary<string, (string WaitingForName, string RelationshipName)> CreateResourceIdentityMap(
        IReadOnlyList<ResourceSnapshot> snapshots)
    {
        var displayNameCounts = new Dictionary<string, int>(StringComparers.ResourceName);
        foreach (var snapshot in snapshots)
        {
            if (snapshot.DisplayName is { } displayName)
            {
                displayNameCounts[displayName] = displayNameCounts.GetValueOrDefault(displayName) + 1;
            }
        }
 
        var identities = new Dictionary<string, (string WaitingForName, string RelationshipName)>(
            StringComparers.ResourceName);
 
        // Runtime names take precedence over display names, matching ResolveResources.
        foreach (var snapshot in snapshots)
        {
            var hasDuplicateDisplayName = snapshot.DisplayName is { } displayName &&
                displayNameCounts.GetValueOrDefault(displayName) > 1;
            identities[snapshot.Name] = (
                hasDuplicateDisplayName ? snapshot.Name : snapshot.DisplayName ?? snapshot.Name,
                snapshot.Name);
        }
 
        foreach (var snapshot in snapshots)
        {
            if (snapshot.DisplayName is not { } displayName || identities.ContainsKey(displayName))
            {
                continue;
            }
 
            identities[displayName] = displayNameCounts[displayName] > 1
                ? (displayName, displayName)
                : (displayName, snapshot.Name);
        }
 
        return identities;
    }
 
    private static Dictionary<string, List<string>> CreateRelationshipTargetMap(
        IReadOnlyList<ResourceSnapshot> snapshots)
    {
        var runtimeNames = snapshots
            .Select(snapshot => snapshot.Name)
            .ToHashSet(StringComparers.ResourceName);
        var targets = new Dictionary<string, List<string>>(StringComparers.ResourceName);
 
        // Runtime names always resolve to exactly that snapshot, even when another resource
        // uses the same value as its display name.
        foreach (var snapshot in snapshots)
        {
            targets[snapshot.Name] = [snapshot.Name];
        }
 
        foreach (var snapshot in snapshots)
        {
            if (snapshot.DisplayName is not { } displayName || runtimeNames.Contains(displayName))
            {
                continue;
            }
 
            if (!targets.TryGetValue(displayName, out var displayTargets))
            {
                displayTargets = [];
                targets.Add(displayName, displayTargets);
            }
 
            if (!displayTargets.Contains(snapshot.Name, StringComparers.ResourceName))
            {
                displayTargets.Add(snapshot.Name);
            }
        }
 
        return targets;
    }
 
    private static string[] GetBoundedWaitingFor(
        ResourceSnapshot snapshot,
        IReadOnlyDictionary<string, (string WaitingForName, string RelationshipName)> resourceIdentities)
    {
        var references = snapshot.WaitingFor;
        if (references is not { Length: > 0 } &&
            GetStringProperty(snapshot, KnownProperties.Resource.WaitingFor) is { } waitingForProperty &&
            !string.IsNullOrWhiteSpace(waitingForProperty))
        {
            references = waitingForProperty.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        }
 
        if (references is not { Length: > 0 })
        {
            return [];
        }
 
        var boundedReferences = new List<string>();
        var seenReferences = new HashSet<string>(StringComparers.ResourceName);
 
        foreach (var reference in references)
        {
            if (resourceIdentities.TryGetValue(reference, out var identity))
            {
                var boundedName = GetBoundedText(identity.WaitingForName)!;
                if (!seenReferences.Add(boundedName))
                {
                    continue;
                }
 
                boundedReferences.Add(boundedName);
                if (boundedReferences.Count == MaxWaitingForPerResource)
                {
                    break;
                }
            }
        }
 
        return [.. boundedReferences];
    }
 
    private static McpResourceRelationshipJson[] GetBoundedRelationships(
        ResourceSnapshot snapshot,
        IReadOnlyDictionary<string, List<string>> relationshipTargets)
    {
        var relationships = new List<McpResourceRelationshipJson>();
        var seenRelationships = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var relationship in snapshot.Relationships)
        {
            if (!relationshipTargets.TryGetValue(relationship.ResourceName, out var targets))
            {
                continue;
            }
 
            foreach (var target in targets)
            {
                var boundedType = GetBoundedText(relationship.Type);
                var boundedTarget = GetBoundedText(target);
                if (seenRelationships.Add($"{boundedType}\0{boundedTarget}"))
                {
                    relationships.Add(new McpResourceRelationshipJson
                    {
                        Type = boundedType,
                        ResourceName = boundedTarget
                    });
                    if (relationships.Count == MaxRelationshipsPerResource)
                    {
                        return [.. relationships];
                    }
                }
            }
        }
 
        return [.. relationships];
    }
 
    private static string? GetDashboardUrl(ResourceSnapshot snapshot, string? dashboardBaseUrl)
    {
        if (dashboardBaseUrl is null)
        {
            return null;
        }
 
        var dashboardUrl = DashboardUrls.CombineUrl(dashboardBaseUrl, DashboardUrls.ResourcesUrl(snapshot.Name));
        return GetBoundedText(McpToolHelpers.SanitizeUrl(dashboardUrl));
    }
 
    private static string? GetBoundedSource(ResourceSnapshot snapshot)
    {
        if (snapshot.Properties.TryGetValue(KnownProperties.Resource.Source, out var explicitSourceNode) &&
            explicitSourceNode is JsonValue explicitSourceValue &&
            explicitSourceValue.TryGetValue<string>(out var explicitSource) &&
            explicitSource.Length == 0)
        {
            return null;
        }
 
        if (string.Equals(snapshot.ResourceType, KnownResourceTypes.Project, StringComparisons.ResourceType))
        {
            var projectPath = GetStringProperty(snapshot, KnownProperties.Project.Path);
            return projectPath is null ? null : GetBoundedText(GetCrossPlatformFileName(projectPath));
        }
 
        if (string.Equals(snapshot.ResourceType, KnownResourceTypes.Executable, StringComparisons.ResourceType))
        {
            var executablePath = GetStringProperty(snapshot, KnownProperties.Executable.Path);
            return executablePath is null ? null : GetBoundedText(GetCrossPlatformFileName(executablePath));
        }
 
        if (string.Equals(snapshot.ResourceType, KnownResourceTypes.Container, StringComparisons.ResourceType))
        {
            return GetBoundedText(GetStringProperty(snapshot, KnownProperties.Container.Image));
        }
 
        return null;
    }
 
    private static string GetCrossPlatformFileName(string path)
    {
        var separatorIndex = path.LastIndexOfAny(['/', '\\']);
        return separatorIndex >= 0 ? path[(separatorIndex + 1)..] : path;
    }
 
    private static string? GetStringProperty(ResourceSnapshot snapshot, string propertyName)
    {
        if (snapshot.Properties.TryGetValue(propertyName, out var value) &&
            value is JsonValue jsonValue &&
            jsonValue.TryGetValue<string>(out var stringValue) &&
            !string.IsNullOrEmpty(stringValue))
        {
            return stringValue;
        }
 
        return null;
    }
 
    private static string? GetBoundedText(string? value)
    {
        if (value is null)
        {
            return null;
        }
 
        // The MCP contract bounds model-facing text in Unicode scalar values. Enumerating runes
        // avoids emitting half of a UTF-16 surrogate pair at the truncation boundary.
        var result = new StringBuilder(Math.Min(value.Length, MaxTextLength * 2));
        var runeCount = 0;
        foreach (var rune in value.EnumerateRunes())
        {
            if (runeCount == MaxTextLength)
            {
                break;
            }
 
            result.Append(Rune.IsControl(rune) ? " " : rune.ToString());
            runeCount++;
        }
 
        return result.ToString();
    }
}