File: Mcp\Tools\McpToolHelpers.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.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json.Nodes;
using System.Web;
using Aspire.Cli.Backchannel;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Mcp.Tools;
 
internal static class McpToolHelpers
{
    private const int MaxExceptionTypeNameLength = 128;
 
    private static readonly HashSet<string> s_sensitiveQueryKeys = new(StringComparer.OrdinalIgnoreCase)
    {
        "token",
        "access_token",
        "credential",
        "password",
        "key",
        "api_key",
        "apikey",
        "accountkey",
        "primarykey",
        "secondarykey",
        "sharedaccesskey",
        "subscriptionkey",
        "clientkey",
        "accesskey",
        "authkey",
        "devicekey",
        "secret",
        "sig",
        "signature",
        "code",
        "client_secret",
        "pwd",
        "passwd",
        "auth",
        "authorization",
        "jwt",
        "bearer",
        "sessionid",
        "sas"
    };
 
    public static async Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger, CancellationToken cancellationToken)
    {
        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);
        }
 
        var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken).ConfigureAwait(false);
        if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null)
        {
            logger.LogWarning("Dashboard API is not available");
            throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError);
        }
 
        var apiBaseUrl = DashboardUrls.NormalizeDashboardRequestUrl(dashboardInfo.ApiBaseUrl, stripLoginPath: false) ?? string.Empty;
        var dashboardBaseUrl = StripLoginPath(dashboardInfo.DashboardUrls.FirstOrDefault());
 
        return (dashboardInfo.ApiToken, apiBaseUrl, dashboardBaseUrl);
    }
 
    /// <summary>
    /// Strips the <c>/login</c> path segment and credentials from a dashboard URL
    /// returned by the AppHost. Other path segments and non-sensitive query values are preserved.
    /// </summary>
    internal static string? StripLoginPath(string? url) =>
        SanitizeUrl(
            url,
            stripLoginPath: true,
            normalizeLocalhost: false,
            removeDashboardLoginToken: true);
 
    /// <summary>
    /// Removes credentials and replaces AppHost-scoped <c>*.localhost</c> dashboard hostnames with <c>localhost</c>.
    /// </summary>
    /// <remarks>
    /// DNS resolvers typically don't implement RFC 6761 for localhost subdomains, so hosts
    /// like <c>dashboard.dev.localhost</c> fail to resolve when making HTTP requests.
    /// Rewriting to <c>localhost</c> ensures the CLI can reach the dashboard API.
    /// </remarks>
    internal static string NormalizeDashboardUrl(string url) =>
        SanitizeUrl(
            url,
            stripLoginPath: false,
            normalizeLocalhost: true,
            removeDashboardLoginToken: false) ?? string.Empty;
 
    /// <summary>
    /// Removes credentials from an absolute URL before it is returned by an MCP tool.
    /// </summary>
    internal static string? SanitizeUrl(string? url) =>
        SanitizeUrl(
            url,
            stripLoginPath: false,
            normalizeLocalhost: false,
            removeDashboardLoginToken: false,
            allowResourceSchemes: false);
 
    /// <summary>
    /// Sanitizes an absolute resource endpoint URI while preserving its non-file scheme.
    /// </summary>
    internal static string? SanitizeResourceUrl(string? url) =>
        SanitizeUrl(
            url,
            stripLoginPath: false,
            normalizeLocalhost: false,
            removeDashboardLoginToken: false,
            allowResourceSchemes: true);
 
    /// <summary>
    /// Removes credentials and dashboard login tokens before an outbound request URL is logged.
    /// </summary>
    internal static string? SanitizeDashboardRequestUrl(string? url) =>
        SanitizeUrl(
            url,
            stripLoginPath: false,
            normalizeLocalhost: false,
            removeDashboardLoginToken: true);
 
    /// <summary>
    /// Returns bounded exception metadata that is safe to include in diagnostics.
    /// </summary>
    internal static string GetBoundedExceptionDiagnostic(Exception exception)
    {
        var exceptionType = exception.GetType().Name;
        if (exceptionType.Length > MaxExceptionTypeNameLength)
        {
            exceptionType = exceptionType[..MaxExceptionTypeNameLength];
        }
 
        return exception is HttpRequestException { StatusCode: { } statusCode }
            ? $"{exceptionType}; HTTP {(int)statusCode} ({statusCode})"
            : exceptionType;
    }
 
    /// <summary>
    /// Maps a runtime resource state to the finite vocabulary exposed through MCP tools.
    /// </summary>
    internal static string? MapResourceState(string? state)
    {
        return state switch
        {
            null => null,
            "Active" or
            "Building" or
            "Exited" or
            "FailedToStart" or
            "Finished" or
            "NotStarted" or
            "Running" or
            "RuntimeUnhealthy" or
            "Starting" or
            "Stopping" or
            "ValueMissing" or
            "Waiting" => state,
            _ => "unknown"
        };
    }
 
    private static string? SanitizeUrl(
        string? url,
        bool stripLoginPath,
        bool normalizeLocalhost,
        bool removeDashboardLoginToken,
        bool allowResourceSchemes = false)
    {
        if (!(allowResourceSchemes
            ? TryCreateResourceUri(url, out var uri)
            : TryCreateHttpUri(url, out uri)))
        {
            return null;
        }
 
        // Dashboard login URLs look like:
        //   https://user:password@dashboard.localhost:18888/base/login?t=token&view=resources
        // User-info and sensitive query values must never cross the MCP boundary, while the
        // non-sensitive path and query values remain useful to clients.
        var isLoginPath = uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase);
        var path = stripLoginPath && isLoginPath
            ? uri.AbsolutePath[..^"/login".Length]
            : uri.AbsolutePath;
        var builder = new UriBuilder(uri)
        {
            Host = normalizeLocalhost && IsLocalhostTld(uri.Host) ? "localhost" : uri.Host,
            Path = path,
            Query = SanitizeQuery(
                uri.Query,
                removeDashboardLoginToken: removeDashboardLoginToken || stripLoginPath || isLoginPath),
            Fragment = string.Empty,
            UserName = string.Empty,
            Password = string.Empty
        };
 
        var sanitizedUri = builder.Uri;
        if (sanitizedUri.AbsolutePath == "/")
        {
            return sanitizedUri.GetLeftPart(UriPartial.Authority) + sanitizedUri.Query + sanitizedUri.Fragment;
        }
 
        return sanitizedUri.AbsoluteUri;
    }
 
    private static bool TryCreateHttpUri(string? value, [NotNullWhen(true)] out Uri? uri)
    {
        return Uri.TryCreate(value, UriKind.Absolute, out uri) &&
            (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) &&
            !string.IsNullOrEmpty(uri.Host);
    }
 
    private static bool TryCreateResourceUri(string? value, [NotNullWhen(true)] out Uri? uri)
    {
        return Uri.TryCreate(value, UriKind.Absolute, out uri) &&
            uri.Scheme != Uri.UriSchemeFile &&
            value.StartsWith($"{uri.Scheme}://", StringComparison.OrdinalIgnoreCase) &&
            !string.IsNullOrEmpty(uri.Host) &&
            Uri.CheckHostName(uri.Host) != UriHostNameType.Unknown;
    }
 
    private static string SanitizeQuery(string query, bool removeDashboardLoginToken)
    {
        if (string.IsNullOrEmpty(query))
        {
            return string.Empty;
        }
 
        var sanitizedQuery = new StringBuilder(query.Length);
        var queryWithoutPrefix = query.AsSpan(1);
        var groupStart = 0;
 
        // Preserve '&' as the original query-group boundary. For example:
        //   ?apikey=AB;cd=ef&view=resources
        // If any parameter-shaped semicolon segment is sensitive, retaining another part of
        // that group could expose a secret tail, so the whole "apikey=AB;cd=ef" group is removed.
        for (var index = 0; index <= queryWithoutPrefix.Length; index++)
        {
            if (index < queryWithoutPrefix.Length && queryWithoutPrefix[index] != '&')
            {
                continue;
            }
 
            var group = queryWithoutPrefix[groupStart..index];
            if (!ContainsSensitiveQueryParameter(group, removeDashboardLoginToken))
            {
                if (sanitizedQuery.Length > 0)
                {
                    sanitizedQuery.Append('&');
                }
 
                sanitizedQuery.Append(group);
            }
 
            groupStart = index + 1;
        }
 
        return sanitizedQuery.ToString();
    }
 
    private static bool ContainsSensitiveQueryParameter(
        ReadOnlySpan<char> group,
        bool removeDashboardLoginToken)
    {
        if (IsSensitiveQueryParameter(group, removeDashboardLoginToken))
        {
            return true;
        }
 
        for (var index = 0; index < group.Length; index++)
        {
            if (group[index] == ';' &&
                StartsQueryParameter(group, index + 1) &&
                IsSensitiveQueryParameter(group[(index + 1)..], removeDashboardLoginToken))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static bool IsSensitiveQueryParameter(
        ReadOnlySpan<char> parameter,
        bool removeDashboardLoginToken)
    {
        var parameterEnd = parameter.IndexOf(';');
        if (parameterEnd >= 0)
        {
            parameter = parameter[..parameterEnd];
        }
 
        var separatorIndex = parameter.IndexOf('=');
        var encodedKey = separatorIndex >= 0 ? parameter[..separatorIndex] : parameter;
        var key = HttpUtility.UrlDecode(encodedKey.ToString());
        return IsSensitiveQueryKey(key) ||
            (removeDashboardLoginToken && string.Equals(key, "t", StringComparison.OrdinalIgnoreCase));
    }
 
    private static bool StartsQueryParameter(ReadOnlySpan<char> queryGroup, int startIndex)
    {
        var remaining = queryGroup[startIndex..];
        var endIndex = remaining.IndexOf(';');
        if (endIndex >= 0)
        {
            remaining = remaining[..endIndex];
        }
 
        var equalsIndex = remaining.IndexOf('=');
        return equalsIndex > 0;
    }
 
    private static bool IsSensitiveQueryKey(string? key)
    {
        if (key is null)
        {
            return false;
        }
 
        var normalizedKey = key.Replace('-', '_');
        return s_sensitiveQueryKeys.Contains(normalizedKey) ||
            normalizedKey.EndsWith("_token", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("Token", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("_credential", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("Credential", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("_password", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("Password", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("_key", StringComparison.OrdinalIgnoreCase) ||
            HasSensitiveCamelCaseSuffix(normalizedKey, "Key") ||
            normalizedKey.EndsWith("_secret", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("Secret", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("_sig", StringComparison.OrdinalIgnoreCase) ||
            HasSensitiveCamelCaseSuffix(normalizedKey, "Sig") ||
            normalizedKey.EndsWith("_signature", StringComparison.OrdinalIgnoreCase) ||
            normalizedKey.EndsWith("Signature", StringComparison.OrdinalIgnoreCase);
    }
 
    private static bool HasSensitiveCamelCaseSuffix(string key, string suffix)
    {
        if (!key.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }
 
        var suffixStart = key.Length - suffix.Length;
        return suffixStart == 0 || key[suffixStart - 1] == '_' || char.IsUpper(key[suffixStart]);
    }
 
    private static bool IsLocalhostTld(string host)
    {
        return host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);
    }
 
    /// <summary>
    /// Checks whether a resource snapshot has the <c>resource.excludeFromMcp</c> property set to true.
    /// Resources with this property should be excluded from all MCP tool results.
    /// </summary>
    internal static bool IsExcludedFromMcp(ResourceSnapshot snapshot)
    {
        if (snapshot.Properties.TryGetValue(KnownProperties.Resource.ExcludeFromMcp, out var value) && value is not null)
        {
            if (value is JsonValue jsonValue)
            {
                if (jsonValue.TryGetValue<bool>(out var boolValue))
                {
                    return boolValue;
                }
 
                if (jsonValue.TryGetValue<string>(out var stringValue) && bool.TryParse(stringValue, out var parsedBool))
                {
                    return parsedBool;
                }
            }
        }
 
        return false;
    }
 
    /// <summary>
    /// Gets the error message text for a resource that is excluded from MCP.
    /// </summary>
    internal static string GetResourceNotAvailableMessage(string resourceName) =>
        $"Resource '{resourceName}' is not available.";
 
    /// <summary>
    /// Gets resource snapshots from the backchannel and checks whether the specified resource is excluded from MCP.
    /// Returns an error <see cref="CallToolResult"/> if the resource is excluded, or <c>null</c> if it is not excluded.
    /// </summary>
    internal static async Task<CallToolResult?> CheckResourceExcludedAsync(
        IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
        string resourceName,
        CancellationToken cancellationToken)
    {
        var excludedNames = await GetExcludedResourceNamesAsync(auxiliaryBackchannelMonitor, cancellationToken).ConfigureAwait(false);
        return CreateExcludedResult(excludedNames, resourceName);
    }
 
    /// <summary>
    /// Checks whether the specified resource is excluded from MCP using an existing connection.
    /// Returns an error <see cref="CallToolResult"/> if the resource is excluded, or <c>null</c> if it is not excluded.
    /// </summary>
    internal static async Task<CallToolResult?> CheckResourceExcludedAsync(
        IAppHostAuxiliaryBackchannel connection,
        string resourceName,
        CancellationToken cancellationToken)
    {
        var excludedNames = await GetExcludedResourceNamesAsync(connection, cancellationToken).ConfigureAwait(false);
        return CreateExcludedResult(excludedNames, resourceName);
    }
 
    private static CallToolResult? CreateExcludedResult(HashSet<string> excludedNames, string resourceName)
    {
        if (excludedNames.Contains(resourceName))
        {
            return new CallToolResult
            {
                Content = [new TextContentBlock { Text = GetResourceNotAvailableMessage(resourceName) }],
                IsError = true
            };
        }
 
        return null;
    }
 
    /// <summary>
    /// Gets the set of resource names that are excluded from MCP.
    /// </summary>
    internal static async Task<HashSet<string>> GetExcludedResourceNamesAsync(
        IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
        CancellationToken cancellationToken)
    {
        var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, NullLogger.Instance, cancellationToken).ConfigureAwait(false);
        if (connection is null)
        {
            return [];
        }
 
        return await GetExcludedResourceNamesAsync(connection, cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Gets the set of resource names that are excluded from MCP using an existing connection.
    /// </summary>
    internal static async Task<HashSet<string>> GetExcludedResourceNamesAsync(
        IAppHostAuxiliaryBackchannel connection,
        CancellationToken cancellationToken)
    {
        var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);
        var excludedNames = new HashSet<string>(StringComparers.ResourceName);
 
        foreach (var snapshot in snapshots)
        {
            if (IsExcludedFromMcp(snapshot))
            {
                excludedNames.Add(snapshot.Name);
                if (snapshot.DisplayName is not null)
                {
                    excludedNames.Add(snapshot.DisplayName);
                }
            }
        }
 
        return excludedNames;
    }
}