| 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.Globalization; using System.Text.Json.Nodes; using System.Web; using Aspire.Cli.Backchannel; using Aspire.Dashboard.Model; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; internal static class McpToolHelpers { 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 = NormalizeDashboardUrl(dashboardInfo.ApiBaseUrl); var dashboardBaseUrl = StripLoginPath(dashboardInfo.DashboardUrls.FirstOrDefault()); return (dashboardInfo.ApiToken, apiBaseUrl, dashboardBaseUrl); } /// <summary> /// Strips the <c>/login</c> path segment (and any query string) from a dashboard URL /// returned by the AppHost. Other path segments are preserved. /// </summary> internal static string? StripLoginPath(string? url) { if (url is null) { return null; } if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) { // Dashboard URLs from the AppHost look like: http://localhost:18888/login?t=abcd1234 // or with a base path: http://localhost:18888/base/login?t=abcd1234 // Strip the trailing /login segment but preserve any other path components. var path = uri.AbsolutePath; if (path.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) { path = path[..^"/login".Length]; } return $"{uri.Scheme}://{uri.Authority}{path.TrimEnd('/')}"; } return url; } /// <summary> /// 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) { if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && IsLocalhostTld(uri.Host)) { var port = uri.IsDefaultPort ? string.Empty : ":" + uri.Port.ToString(CultureInfo.InvariantCulture); var pathAndQuery = uri.PathAndQuery == "/" ? string.Empty : uri.PathAndQuery; return $"{uri.Scheme}://localhost{port}{pathAndQuery}{uri.Fragment}"; } return url; } private static bool IsLocalhostTld(string host) { return host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase); } /// <summary> /// Extracts the browser token (<c>t</c> query parameter) from a dashboard login URL. /// Returns <c>null</c> if the URL does not contain a login token. /// </summary> internal static string? ExtractLoginToken(string? url) { if (url is null) { return null; } if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) { // Parse query string to find 't' parameter var queryParams = HttpUtility.ParseQueryString(uri.Query); var token = queryParams["t"]; if (!string.IsNullOrEmpty(token)) { return token; } } return null; } /// <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; } }