diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index beafae19f11..a3dd7ca9a5d 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -28,13 +28,23 @@ namespace Aspire.Cli.Commands; /// internal sealed class AgentMcpCommand : BaseCommand { - private readonly Dictionary _knownTools; + private readonly Dictionary _knownTools = []; private readonly IMcpResourceToolRefreshService _resourceToolRefreshService; private McpServer? _server; private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor; private readonly IMcpTransportFactory _transportFactory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IPackagingService _packagingService; + private readonly IEnvironmentChecker _environmentChecker; + private readonly IDocsSearchService _docsSearchService; + private readonly IDocsIndexService _docsIndexService; + private readonly CliExecutionContext _executionContext; + private bool _dashboardOnlyMode; + + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); /// /// Gets the dictionary of known MCP tools. Exposed for testing purposes. @@ -62,24 +72,16 @@ public AgentMcpCommand( _transportFactory = transportFactory; _loggerFactory = loggerFactory; _logger = logger; + _httpClientFactory = httpClientFactory; + _packagingService = packagingService; + _environmentChecker = environmentChecker; + _docsSearchService = docsSearchService; + _docsIndexService = docsIndexService; + _executionContext = executionContext; _resourceToolRefreshService = new McpResourceToolRefreshService(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()); - _knownTools = new Dictionary - { - [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), - [KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), - [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), - [KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), - [KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), - [KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor), - [KnownMcpTools.Doctor] = new DoctorTool(environmentChecker), - [KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService), - [KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService), - [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService), - [KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService) - }; + + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override bool UpdateNotificationsEnabled => false; @@ -95,6 +97,44 @@ internal Task ExecuteCommandAsync(ParseResult parseResult, CancellationToke protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); + + if (dashboardUrl is not null) + { + if (!UrlHelper.IsHttpUrl(dashboardUrl)) + { + _logger.LogError("Invalid --dashboard-url: {DashboardUrl}", dashboardUrl); + return ExitCodeConstants.InvalidCommand; + } + + _dashboardOnlyMode = true; + var staticProvider = new StaticDashboardInfoProvider(dashboardUrl, apiKey); + + _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + } + else + { + var dashboardInfoProvider = new BackchannelDashboardInfoProvider(_auxiliaryBackchannelMonitor, _logger); + + _knownTools[KnownMcpTools.ListResources] = new ListResourcesTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(_auxiliaryBackchannelMonitor, _executionContext); + _knownTools[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(_auxiliaryBackchannelMonitor, _executionContext); + _knownTools[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(_packagingService, _executionContext, _auxiliaryBackchannelMonitor); + _knownTools[KnownMcpTools.Doctor] = new DoctorTool(_environmentChecker); + _knownTools[KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService); + _knownTools[KnownMcpTools.ListDocs] = new ListDocsTool(_docsIndexService); + _knownTools[KnownMcpTools.SearchDocs] = new SearchDocsTool(_docsSearchService, _docsIndexService); + _knownTools[KnownMcpTools.GetDoc] = new GetDocTool(_docsIndexService); + } + var icons = McpIconHelper.GetAspireIcons(typeof(AgentMcpCommand).Assembly, "Aspire.Cli.Mcp.Resources"); var options = new McpServerOptions @@ -135,32 +175,40 @@ private async ValueTask HandleListToolsAsync(RequestContext(); - tools.AddRange(KnownTools.Values.Select(tool => new Tool + tools.AddRange(KnownTools.Select(tool => new Tool { - Name = tool.Name, - Description = tool.Description, - InputSchema = tool.GetInputSchema() + Name = tool.Value.Name, + Description = tool.Value.Description, + InputSchema = tool.Value.GetInputSchema() })); try { - // Refresh resource tools if needed (e.g., AppHost selection changed or invalidated) - if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap)) + // In dashboard-only mode, skip resource tool discovery + if (_dashboardOnlyMode) { - // Don't send tools/list_changed here — the client already called tools/list - // and will receive the up-to-date result. Sending a notification during the - // list handler would cause the client to call tools/list again, creating an - // infinite loop when tool availability is unstable (e.g., container MCP tools - // oscillating between available/unavailable). - (resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken); + _logger.LogDebug("Dashboard-only mode: skipping resource tool discovery"); } - - tools.AddRange(resourceToolMap.Select(x => new Tool + else { - Name = x.Key, - Description = x.Value.Tool.Description, - InputSchema = x.Value.Tool.InputSchema - })); + // Refresh resource tools if needed (e.g., AppHost selection changed or invalidated) + if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap)) + { + // Don't send tools/list_changed here — the client already called tools/list + // and will receive the up-to-date result. Sending a notification during the + // list handler would cause the client to call tools/list again, creating an + // infinite loop when tool availability is unstable (e.g., container MCP tools + // oscillating between available/unavailable). + (resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken); + } + + tools.AddRange(resourceToolMap.Select(x => new Tool + { + Name = x.Key, + Description = x.Value.Tool.Description, + InputSchema = x.Value.Tool.InputSchema + })); + } } catch (Exception ex) { @@ -179,6 +227,14 @@ private async ValueTask HandleCallToolAsync(RequestContext s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); + private static readonly Argument s_resourceArgument = new("resource") { Description = ExportCommandStrings.ResourceOptionDescription, @@ -65,6 +68,8 @@ public ExportCommand( Arguments.Add(s_resourceArgument); Options.Add(s_appHostOption); Options.Add(s_outputOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -74,35 +79,16 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var resourceName = parseResult.GetValue(s_resourceArgument); var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var outputPath = parseResult.GetValue(s_outputOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); - // Resolve the AppHost connection for backchannel access - var connectionResult = await _connectionResolver.ResolveConnectionAsync( - passedAppHostProjectFile, - SharedCommandStrings.ScanningForRunningAppHosts, - string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ExportCommandStrings.SelectAppHostAction), - SharedCommandStrings.AppHostNotRunning, - cancellationToken); - - if (!connectionResult.Success) + // Validate mutual exclusivity of --apphost and --dashboard-url + if (passedAppHostProjectFile is not null && dashboardUrl is not null) { - _interactionService.DisplayMessage(KnownEmojis.Information, connectionResult.ErrorMessage); - return ExitCodeConstants.Success; - } - - var connection = connectionResult.Connection!; - - // Get dashboard API info for telemetry data - var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken); - var isDashboardAvailable = dashboardInfo?.ApiBaseUrl is not null && dashboardInfo.ApiToken is not null; - - if (!isDashboardAvailable) - { - _interactionService.DisplayMessage(KnownEmojis.Warning, ExportCommandStrings.DashboardNotAvailable); + _interactionService.DisplayError(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive); + return ExitCodeConstants.InvalidCommand; } - var baseUrl = dashboardInfo?.ApiBaseUrl; - var apiToken = dashboardInfo?.ApiToken; - // Default file name if not specified if (string.IsNullOrEmpty(outputPath)) { @@ -117,89 +103,127 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell Directory.CreateDirectory(directory); } + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: false, cancellationToken); + + if (!dashboardApi.Success) + { + return dashboardApi.ExitCode; + } + + if (dashboardApi.BaseUrl is null) + { + _interactionService.DisplayMessage(KnownEmojis.Warning, ExportCommandStrings.DashboardNotAvailable); + } + try { - using var client = isDashboardAvailable ? TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken!) : null; + return await ExportDataAsync(resourceName, dashboardApi.Connection, dashboardApi.BaseUrl, dashboardApi.ApiToken, outputPath, cancellationToken); + } + catch (HttpRequestException ex) when (dashboardUrl is not null) + { + _logger.LogError(ex, "Failed to export telemetry data from dashboard"); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, dashboardApi.BaseUrl!, true, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + return ExitCodeConstants.DashboardFailure; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export telemetry data"); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.FailedToExport, ex.Message)); + return ExitCodeConstants.DashboardFailure; + } + } - // Get telemetry resources and resource snapshots - var (telemetryResources, snapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () => - { - var resources = isDashboardAvailable - ? await TelemetryCommandHelpers.GetAllResourcesAsync(client!, baseUrl!, cancellationToken).ConfigureAwait(false) - : []; - var snaps = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); - return (resources, snaps); - }); + private async Task ExportDataAsync( + string? resourceName, + IAppHostAuxiliaryBackchannel? connection, + string? baseUrl, + string? apiToken, + string outputPath, + CancellationToken cancellationToken) + { + var isDashboardAvailable = baseUrl is not null && apiToken is not null; - // Validate resource name exists (match by Name or DisplayName since users may pass either) - if (resourceName is not null) - { - if (!ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Any()) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ResourceNotFound, resourceName)); - return ExitCodeConstants.InvalidCommand; - } - } - else - { - if (snapshots.Count == 0) - { - _interactionService.DisplayMessage(KnownEmojis.Information, ExportCommandStrings.NoResourcesFound); - return ExitCodeConstants.Success; - } - } + using var client = isDashboardAvailable ? TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken!) : null; - // Resolve which telemetry resources match the filter - List? resolvedTelemetryResources = null; - var hasTelemetryData = true; - if (resourceName is not null) + // Get telemetry resources and resource snapshots + var (telemetryResources, snapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () => + { + var resources = isDashboardAvailable + ? await TelemetryCommandHelpers.GetAllResourcesAsync(client!, baseUrl!, cancellationToken).ConfigureAwait(false) + : []; + IReadOnlyList snaps = connection is not null + ? await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false) + : []; + return (resources, snaps); + }); + + // Validate resource name exists (match by Name or DisplayName since users may pass either) + if (resourceName is not null && snapshots.Count > 0) + { + if (!ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Any()) { - hasTelemetryData = TelemetryCommandHelpers.TryResolveResourceNames(resourceName, telemetryResources, out resolvedTelemetryResources); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ResourceNotFound, resourceName)); + return ExitCodeConstants.InvalidCommand; } + } + else if (resourceName is null && connection is not null && snapshots.Count == 0) + { + _interactionService.DisplayMessage(KnownEmojis.Information, ExportCommandStrings.NoResourcesFound); + return ExitCodeConstants.Success; + } - var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(telemetryResources); + // Resolve which telemetry resources match the filter + List? resolvedTelemetryResources = null; + var hasTelemetryData = true; + if (resourceName is not null) + { + hasTelemetryData = TelemetryCommandHelpers.TryResolveResourceNames(resourceName, telemetryResources, out resolvedTelemetryResources); + } - var exportArchive = new ExportArchive(); + var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(telemetryResources); - // 1. Export resource details (filtered when a resource name is specified) + var exportArchive = new ExportArchive(); + + // Export resource details (filtered when a resource name is specified) + if (snapshots.Count > 0) + { AddResources(exportArchive, snapshots, resourceName); + } - // 2. Export console logs from backchannel + // Export console logs from backchannel + if (connection is not null) + { await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringConsoleLogs, async () => { await AddConsoleLogsAsync(exportArchive, connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); return true; }); + } - if (isDashboardAvailable && hasTelemetryData) + if (isDashboardAvailable && hasTelemetryData) + { + // Export structured logs from Dashboard API + await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringStructuredLogs, async () => { - // 3. Export structured logs from Dashboard API (skip if resource has no telemetry data or dashboard is unavailable) - await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringStructuredLogs, async () => - { - await AddStructuredLogsAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); - return true; - }); + await AddStructuredLogsAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); + return true; + }); - // 4. Export traces from Dashboard API (skip if resource has no telemetry data or dashboard is unavailable) - await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringTraces, async () => - { - await AddTracesAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); - return true; - }); - } + // Export traces from Dashboard API + await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringTraces, async () => + { + await AddTracesAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false); + return true; + }); + } - var fullPath = Path.GetFullPath(outputPath); - exportArchive.WriteToFile(fullPath); + var fullPath = Path.GetFullPath(outputPath); + exportArchive.WriteToFile(fullPath); - _interactionService.DisplayMessage(KnownEmojis.CheckMark, string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ExportComplete, fullPath)); - return ExitCodeConstants.Success; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to export telemetry data"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.FailedToExport, ex.Message)); - return ExitCodeConstants.DashboardFailure; - } + _interactionService.DisplayMessage(KnownEmojis.CheckMark, string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ExportComplete, fullPath)); + return ExitCodeConstants.Success; } private static void AddResources(ExportArchive exportArchive, IReadOnlyList snapshots, string? resourceName) diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 99daad6f807..5206b8e8812 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -3,15 +3,18 @@ using System.CommandLine; using System.Globalization; +using System.Net; using System.Net.Http.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; +using Aspire.Cli.Mcp.Tools; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Aspire.Shared; +using Microsoft.Extensions.Logging; using Spectre.Console; namespace Aspire.Cli.Commands; @@ -84,6 +87,22 @@ internal static class TelemetryCommandHelpers Description = TelemetryCommandStrings.HasErrorOptionDescription }; + /// + /// Dashboard URL option for connecting directly to a standalone dashboard. + /// + internal static Option CreateDashboardUrlOption() => new("--dashboard-url") + { + Description = TelemetryCommandStrings.DashboardUrlOptionDescription + }; + + /// + /// API key option for authenticating with a standalone dashboard. + /// + internal static Option CreateApiKeyOption() => new("--api-key") + { + Description = TelemetryCommandStrings.ApiKeyOptionDescription + }; + #endregion /// @@ -100,13 +119,46 @@ public static bool HasJsonContentType(HttpResponseMessage response) /// /// Resolves an AppHost connection and gets Dashboard API info. /// - /// A tuple with success status, base URL, API token, dashboard UI URL, and exit code if failed. - public static async Task<(bool Success, string? BaseUrl, string? ApiToken, string? DashboardUrl, int ExitCode)> GetDashboardApiAsync( + /// The connection resolver for AppHost discovery. + /// The interaction service for displaying messages. + /// The optional AppHost project file. + /// The optional direct dashboard URL (mutually exclusive with ). + /// The optional API key for dashboard authentication. + /// + /// When true, a missing Dashboard API is a hard error. + /// When false, a missing Dashboard API is non-fatal and the method returns success with null base URL and token. + /// + /// The cancellation token. + /// A with the resolved connection and dashboard API info. + public static async Task GetDashboardApiAsync( AppHostConnectionResolver connectionResolver, IInteractionService interactionService, FileInfo? projectFile, + string? dashboardUrl, + string? apiKey, + bool requireDashboard, CancellationToken cancellationToken) { + // Validate mutual exclusivity of --apphost and --dashboard-url + if (projectFile is not null && dashboardUrl is not null) + { + interactionService.DisplayError(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive); + return DashboardApiResult.Failure(ExitCodeConstants.InvalidCommand); + } + + // Direct dashboard URL mode — bypass AppHost discovery + if (dashboardUrl is not null) + { + if (!UrlHelper.IsHttpUrl(dashboardUrl)) + { + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl)); + return DashboardApiResult.Failure(ExitCodeConstants.InvalidCommand); + } + + var token = apiKey ?? string.Empty; + return new DashboardApiResult(true, null, dashboardUrl, token, dashboardUrl, 0); + } + var result = await connectionResolver.ResolveConnectionAsync( projectFile, SharedCommandStrings.ScanningForRunningAppHosts, @@ -117,36 +169,35 @@ public static bool HasJsonContentType(HttpResponseMessage response) if (!result.Success) { interactionService.DisplayMessage(KnownEmojis.Information, result.ErrorMessage); - return (false, null, null, null, ExitCodeConstants.Success); + return DashboardApiResult.Failure(ExitCodeConstants.Success); } - var dashboardInfo = await result.Connection!.GetDashboardInfoV2Async(cancellationToken); + var connection = result.Connection!; + var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken); if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null) { - interactionService.DisplayError(TelemetryCommandStrings.DashboardApiNotAvailable); - return (false, null, null, null, ExitCodeConstants.DashboardFailure); + if (requireDashboard) + { + interactionService.DisplayError(TelemetryCommandStrings.DashboardApiNotAvailable); + return DashboardApiResult.Failure(ExitCodeConstants.DashboardFailure); + } + + // Dashboard is optional — return success with null API info + return new DashboardApiResult(true, connection, null, null, null, 0); } // Extract dashboard base URL (without /login path) for hyperlinks - var dashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault()); + var extractedDashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault()); - return (true, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, dashboardUrl, 0); + return new DashboardApiResult(true, connection, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, extractedDashboardUrl, 0); } /// - /// Extracts the base URL from a dashboard URL (removes /login?t=... path). + /// Strips the /login path segment from a dashboard URL returned by the AppHost. /// private static string? ExtractDashboardBaseUrl(string? dashboardUrlWithToken) { - if (string.IsNullOrEmpty(dashboardUrlWithToken)) - { - return null; - } - - // Dashboard URLs look like: http://localhost:18888/login?t=abcd1234 - // We want: http://localhost:18888 - var uri = new Uri(dashboardUrlWithToken); - return $"{uri.Scheme}://{uri.Authority}"; + return McpToolHelpers.StripLoginPath(dashboardUrlWithToken); } /// @@ -155,10 +206,80 @@ public static bool HasJsonContentType(HttpResponseMessage response) public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiToken) { var client = factory.CreateClient(); - client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken); + if (!string.IsNullOrEmpty(apiToken)) + { + client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken); + } return client; } + /// + /// Formats an error message for a telemetry HTTP failure, using dashboard-specific diagnostics + /// when a direct dashboard URL was provided, or a generic message otherwise. + /// + public static async Task FormatTelemetryErrorMessageAsync( + HttpRequestException ex, + string baseUrl, + bool dashboardOnly, + IHttpClientFactory httpClientFactory, + ILogger logger, + CancellationToken cancellationToken) + { + if (dashboardOnly) + { + return await GetDashboardApiErrorMessageAsync(ex, baseUrl, httpClientFactory, logger, cancellationToken); + } + + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message); + } + + /// + /// Produces a user-friendly error message for dashboard API failures when using --dashboard-url. + /// + public static async Task GetDashboardApiErrorMessageAsync( + HttpRequestException ex, + string dashboardBaseUrl, + IHttpClientFactory httpClientFactory, + ILogger logger, + CancellationToken cancellationToken) + { + if (ex.StatusCode == HttpStatusCode.Unauthorized) + { + return TelemetryCommandStrings.DashboardAuthFailed; + } + + if (ex.StatusCode == HttpStatusCode.NotFound) + { + // Probe the dashboard base URL to distinguish "wrong URL" from "API not enabled" + try + { + using var probeClient = httpClientFactory.CreateClient(); + var probeResponse = await probeClient.GetAsync(dashboardBaseUrl, cancellationToken).ConfigureAwait(false); + + if (probeResponse.IsSuccessStatusCode) + { + // Dashboard is reachable but the API endpoint returned 404 — API not enabled + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardApiNotEnabled, dashboardBaseUrl); + } + } + catch (Exception probeEx) + { + logger.LogDebug(probeEx, "Dashboard probe failed for {Url}", dashboardBaseUrl); + } + + // Dashboard base URL is also not reachable — wrong URL + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlNotReachable, dashboardBaseUrl); + } + + if (ex.StatusCode is null) + { + // No HTTP status — connection refused or network error + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardConnectionFailed, dashboardBaseUrl); + } + + return string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message); + } + public static bool TryResolveResourceNames( string? resourceName, IList resources, @@ -353,3 +474,27 @@ public static string ResolveResourceName(OtlpResourceJson? resource, IReadOnlyLi return OtlpHelpers.GetResourceName(otlpResource, allResources); } } + +/// +/// Result of resolving the Dashboard API connection via . +/// +/// Whether the resolution succeeded. +/// The AppHost backchannel connection, if resolved via an AppHost. +/// The Dashboard API base URL, or null if the dashboard is unavailable. +/// The Dashboard API authentication token, or null if the dashboard is unavailable. +/// The Dashboard UI base URL for hyperlinks, or null if unavailable. +/// The exit code to return when is false. +internal sealed record DashboardApiResult( + bool Success, + IAppHostAuxiliaryBackchannel? Connection, + string? BaseUrl, + string? ApiToken, + string? DashboardUrl, + int ExitCode) +{ + /// + /// Creates a failed result with the specified exit code. + /// + public static DashboardApiResult Failure(int exitCode) + => new(false, null, null, null, null, exitCode); +} diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index d07af446a8e..de6d4725d5f 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -37,6 +37,8 @@ internal sealed class TelemetryLogsCommand : BaseCommand private static readonly Option s_formatOption = TelemetryCommandHelpers.CreateFormatOption(); private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id"); + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); // Logs-specific option private static readonly Option s_severityOption = new("--severity") { @@ -69,6 +71,8 @@ public TelemetryLogsCommand( Options.Add(s_formatOption); Options.Add(s_limitOption); Options.Add(s_traceIdOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); Options.Add(s_severityOption); } @@ -83,6 +87,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var limit = parseResult.GetValue(s_limitOption); var traceId = parseResult.GetValue(s_traceIdOption); var severity = parseResult.GetValue(s_severityOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -91,15 +97,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, cancellationToken); + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: true, cancellationToken); - if (!success) + if (!dashboardApi.Success) { - return exitCode; + return dashboardApi.ExitCode; } - return await FetchLogsAsync(baseUrl!, apiToken!, resourceName, traceId, severity, limit, follow, format, cancellationToken); + return await FetchLogsAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, traceId, severity, limit, follow, format, dashboardOnly: dashboardUrl is not null, cancellationToken); } private async Task FetchLogsAsync( @@ -111,6 +117,7 @@ private async Task FetchLogsAsync( int? limit, bool follow, OutputFormat format, + bool dashboardOnly, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -160,7 +167,8 @@ private async Task FetchLogsAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch logs from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardOnly, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 7c06a63ede3..e0cb3c51c3a 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -38,6 +38,8 @@ internal sealed class TelemetrySpansCommand : BaseCommand private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id"); private static readonly Option s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption(); + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); public TelemetrySpansCommand( IInteractionService interactionService, @@ -66,6 +68,8 @@ public TelemetrySpansCommand( Options.Add(s_limitOption); Options.Add(s_traceIdOption); Options.Add(s_hasErrorOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -79,6 +83,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var limit = parseResult.GetValue(s_limitOption); var traceId = parseResult.GetValue(s_traceIdOption); var hasError = parseResult.GetValue(s_hasErrorOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -87,15 +93,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, cancellationToken); + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: true, cancellationToken); - if (!success) + if (!dashboardApi.Success) { - return exitCode; + return dashboardApi.ExitCode; } - return await FetchSpansAsync(baseUrl!, apiToken!, resourceName, traceId, hasError, limit, follow, format, cancellationToken); + return await FetchSpansAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, traceId, hasError, limit, follow, format, dashboardOnly: dashboardUrl is not null, cancellationToken); } private async Task FetchSpansAsync( @@ -107,6 +113,7 @@ private async Task FetchSpansAsync( int? limit, bool follow, OutputFormat format, + bool dashboardOnly, CancellationToken cancellationToken) { using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); @@ -161,7 +168,8 @@ private async Task FetchSpansAsync( catch (HttpRequestException ex) { _logger.LogError(ex, "Failed to fetch spans from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, baseUrl, dashboardOnly, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); return ExitCodeConstants.DashboardFailure; } } diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index b97018fd7e5..e1ec700fc8a 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -37,6 +37,8 @@ internal sealed class TelemetryTracesCommand : BaseCommand private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id", "-t"); private static readonly Option s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption(); + private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); + private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); public TelemetryTracesCommand( IInteractionService interactionService, @@ -64,6 +66,8 @@ public TelemetryTracesCommand( Options.Add(s_limitOption); Options.Add(s_traceIdOption); Options.Add(s_hasErrorOption); + Options.Add(s_dashboardUrlOption); + Options.Add(s_apiKeyOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -76,6 +80,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var limit = parseResult.GetValue(s_limitOption); var traceId = parseResult.GetValue(s_traceIdOption); var hasError = parseResult.GetValue(s_hasErrorOption); + var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); + var apiKey = parseResult.GetValue(s_apiKeyOption); // Validate --limit value if (limit.HasValue && limit.Value < 1) @@ -84,21 +90,31 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } - var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( - _connectionResolver, _interactionService, passedAppHostProjectFile, cancellationToken); + var dashboardApi = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, dashboardUrl, apiKey, requireDashboard: true, cancellationToken); - if (!success) + if (!dashboardApi.Success) { - return exitCode; + return dashboardApi.ExitCode; } - if (!string.IsNullOrEmpty(traceId)) + try { - return await FetchSingleTraceAsync(baseUrl!, apiToken!, traceId, format, cancellationToken); + if (!string.IsNullOrEmpty(traceId)) + { + return await FetchSingleTraceAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, traceId, format, cancellationToken); + } + else + { + return await FetchTracesAsync(dashboardApi.BaseUrl!, dashboardApi.ApiToken!, resourceName, hasError, limit, format, cancellationToken); + } } - else + catch (HttpRequestException ex) { - return await FetchTracesAsync(baseUrl!, apiToken!, resourceName, hasError, limit, format, cancellationToken); + _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); + var errorMessage = await TelemetryCommandHelpers.FormatTelemetryErrorMessageAsync(ex, dashboardApi.BaseUrl!, dashboardUrl is not null, _httpClientFactory, _logger, cancellationToken); + _interactionService.DisplayError(errorMessage); + return ExitCodeConstants.DashboardFailure; } } @@ -122,44 +138,35 @@ private async Task FetchSingleTraceAsync( _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url); - try - { - var response = await client.GetAsync(url, cancellationToken); - - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.TraceNotFound, traceId)); - return ExitCodeConstants.InvalidCommand; - } + var response = await client.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.TraceNotFound, traceId)); + return ExitCodeConstants.InvalidCommand; + } - if (!TelemetryCommandHelpers.HasJsonContentType(response)) - { - _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); - return ExitCodeConstants.DashboardFailure; - } + response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(cancellationToken); + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } - if (format == OutputFormat.Json) - { - // Structured output always goes to stdout. - _interactionService.DisplayRawText(json, ConsoleOutput.Standard); - } - else - { - DisplayTraceDetails(json, traceId, allOtlpResources); - } + var json = await response.Content.ReadAsStringAsync(cancellationToken); - return ExitCodeConstants.Success; + if (format == OutputFormat.Json) + { + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } - catch (HttpRequestException ex) + else { - _logger.LogError(ex, "Failed to fetch trace from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); - return ExitCodeConstants.DashboardFailure; + DisplayTraceDetails(json, traceId, allOtlpResources); } + + return ExitCodeConstants.Success; } private async Task FetchTracesAsync( @@ -203,37 +210,28 @@ private async Task FetchTracesAsync( _logger.LogDebug("Fetching traces from {Url}", url); - try - { - var response = await client.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); - - if (!TelemetryCommandHelpers.HasJsonContentType(response)) - { - _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); - return ExitCodeConstants.DashboardFailure; - } + var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(cancellationToken); + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } - if (format == OutputFormat.Json) - { - // Structured output always goes to stdout. - _interactionService.DisplayRawText(json, ConsoleOutput.Standard); - } - else - { - DisplayTracesTable(json, allOtlpResources); - } + var json = await response.Content.ReadAsStringAsync(cancellationToken); - return ExitCodeConstants.Success; + if (format == OutputFormat.Json) + { + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } - catch (HttpRequestException ex) + else { - _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); - return ExitCodeConstants.DashboardFailure; + DisplayTracesTable(json, allOtlpResources); } + + return ExitCodeConstants.Success; } private void DisplayTracesTable(string json, IReadOnlyList allResources) diff --git a/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs b/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs new file mode 100644 index 00000000000..de2c6b215ed --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/IDashboardInfoProvider.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Backchannel; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Mcp.Tools; + +/// +/// Provides dashboard connection info (API token, base URL, dashboard UI URL) for telemetry access. +/// +internal interface IDashboardInfoProvider +{ + /// + /// Whether the dashboard URL was provided directly (e.g. via --dashboard-url) rather than discovered through an AppHost. + /// + bool IsDirectConnection { get; } + + /// + /// Gets dashboard connection info for telemetry API access. + /// + /// A tuple of (apiToken, apiBaseUrl, dashboardBaseUrl). apiToken may be empty for unsecured dashboards. + Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken); +} + +/// +/// Gets dashboard info from the AppHost backchannel (default behavior). +/// +internal sealed class BackchannelDashboardInfoProvider( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + ILogger logger) : IDashboardInfoProvider +{ + public bool IsDirectConnection => false; + + public Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken) + { + return McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken); + } +} + +/// +/// Returns dashboard info from statically-provided URL and optional API key (for standalone dashboards). +/// +internal sealed class StaticDashboardInfoProvider(string dashboardUrl, string? apiKey) : IDashboardInfoProvider +{ + public bool IsDirectConnection => true; + + public Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(CancellationToken cancellationToken) + { + // For unsecured dashboards, apiToken is empty string (no X-API-Key header will be sent) + var apiToken = apiKey ?? string.Empty; + return Task.FromResult((apiToken, dashboardUrl, (string?)dashboardUrl)); + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index 4df3c286b4d..df628bbc7c3 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing structured logs. /// Gets log data directly from the Dashboard telemetry API. /// -internal sealed class ListStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListStructuredLogs; @@ -43,7 +42,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await dashboardInfoProvider.GetDashboardInfoAsync(cancellationToken).ConfigureAwait(false); // Extract resourceName from arguments string? resourceName = null; @@ -101,7 +100,10 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch structured logs from Dashboard API"); - throw new McpProtocolException($"Failed to fetch structured logs: {ex.Message}", McpErrorCode.InternalError); + var errorMessage = dashboardInfoProvider.IsDirectConnection + ? await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken) + : $"Failed to fetch structured logs: {ex.Message}"; + throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 87320996e7f..c066066267e 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing structured logs for a specific distributed trace. /// Gets log data directly from the Dashboard telemetry API. /// -internal sealed class ListTraceStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListTraceStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraceStructuredLogs; @@ -44,7 +43,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await dashboardInfoProvider.GetDashboardInfoAsync(cancellationToken).ConfigureAwait(false); // Extract traceId from arguments (required) string? traceId = null; @@ -101,7 +100,10 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch structured logs for trace from Dashboard API"); - throw new McpProtocolException($"Failed to fetch structured logs for trace: {ex.Message}", McpErrorCode.InternalError); + var errorMessage = dashboardInfoProvider.IsDirectConnection + ? await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken) + : $"Failed to fetch structured logs for trace: {ex.Message}"; + throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 187b8479a7f..586788c05d4 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -19,7 +18,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing distributed traces. /// Gets trace data directly from the Dashboard telemetry API. /// -internal sealed class ListTracesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListTracesTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraces; @@ -43,7 +42,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await dashboardInfoProvider.GetDashboardInfoAsync(cancellationToken).ConfigureAwait(false); // Extract resourceName from arguments string? resourceName = null; @@ -101,7 +100,10 @@ public override async ValueTask CallToolAsync(CallToolContext co catch (HttpRequestException ex) { logger.LogError(ex, "Failed to fetch traces from Dashboard API"); - throw new McpProtocolException($"Failed to fetch traces: {ex.Message}", McpErrorCode.InternalError); + var errorMessage = dashboardInfoProvider.IsDirectConnection + ? await TelemetryCommandHelpers.GetDashboardApiErrorMessageAsync(ex, apiBaseUrl, httpClientFactory, logger, cancellationToken) + : $"Failed to fetch traces: {ex.Message}"; + throw new McpProtocolException(errorMessage, McpErrorCode.InternalError); } } } diff --git a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs index 20f520e173b..77992331918 100644 --- a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs +++ b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs @@ -25,17 +25,16 @@ internal static class McpToolHelpers throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); } - var dashboardBaseUrl = GetBaseUrl(dashboardInfo.DashboardUrls.FirstOrDefault()); + var dashboardBaseUrl = StripLoginPath(dashboardInfo.DashboardUrls.FirstOrDefault()); return (dashboardInfo.ApiToken, dashboardInfo.ApiBaseUrl, dashboardBaseUrl); } /// - /// Extracts the base URL (scheme, host, and port) from a URL, removing any path and query string. + /// Strips the /login path segment (and any query string) from a dashboard URL + /// returned by the AppHost. Other path segments are preserved. /// - /// The full URL that may contain path and query string. - /// The base URL with only scheme, host, and port, or null if the input is null or invalid. - internal static string? GetBaseUrl(string? url) + internal static string? StripLoginPath(string? url) { if (url is null) { @@ -44,7 +43,16 @@ internal static class McpToolHelpers if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) { - return $"{uri.Scheme}://{uri.Authority}"; + // 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; diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index ddb0b6dae7a..8b619fd253f 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; +using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using System.Reflection; @@ -109,8 +110,7 @@ public Task> GetChannelsAsync(CancellationToken canc if (!string.IsNullOrEmpty(overrideFeed)) { // Validate that the override URL is well-formed - if (Uri.TryCreate(overrideFeed, UriKind.Absolute, out var uri) && - (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + if (UrlHelper.IsHttpUrl(overrideFeed)) { return overrideFeed; } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs index 0b3a98e079c..f8b3d5a1e19 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -248,5 +248,53 @@ internal static string HeaderStatus { return ResourceManager.GetString("HeaderStatus", resourceCulture); } } + + internal static string DashboardUrlOptionDescription { + get { + return ResourceManager.GetString("DashboardUrlOptionDescription", resourceCulture); + } + } + + internal static string ApiKeyOptionDescription { + get { + return ResourceManager.GetString("ApiKeyOptionDescription", resourceCulture); + } + } + + internal static string DashboardUrlAndAppHostExclusive { + get { + return ResourceManager.GetString("DashboardUrlAndAppHostExclusive", resourceCulture); + } + } + + internal static string DashboardAuthFailed { + get { + return ResourceManager.GetString("DashboardAuthFailed", resourceCulture); + } + } + + internal static string DashboardUrlNotReachable { + get { + return ResourceManager.GetString("DashboardUrlNotReachable", resourceCulture); + } + } + + internal static string DashboardApiNotEnabled { + get { + return ResourceManager.GetString("DashboardApiNotEnabled", resourceCulture); + } + } + + internal static string DashboardConnectionFailed { + get { + return ResourceManager.GetString("DashboardConnectionFailed", resourceCulture); + } + } + + internal static string DashboardUrlInvalid { + get { + return ResourceManager.GetString("DashboardUrlInvalid", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index e280daac87c..16b2f8b3b73 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -186,4 +186,28 @@ Status + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index f57dbf060b5..f03ef08d914 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Rozhraní API řídicího panelu není k dispozici. Ujistěte se, že hostitel aplikací běží s povoleným řídicím panelem. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Umožňuje zobrazit telemetrická data (protokoly, rozsahy, trasování) ze spuštěné aplikace Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index f206be4c57b..7acd1e53475 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Die Dashboard-API ist nicht verfügbar. Stellen Sie sicher, dass der AppHost mit aktiviertem Dashboard ausgeführt wird. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Zeigen Sie Telemetriedaten (Protokolle, Spans, Traces) einer laufenden Aspire-Anwendung an. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index 137d856362e..d477f78a441 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. La API de panel no está disponible. Asegúrese de que AppHost se esté ejecutando con Panel habilitado. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Ver los datos de telemetría (registros, intervalos, seguimientos) de una aplicación de Aspire en ejecución. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 60461856b4d..d748463c321 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Désolé, l’API du tableau de bord n’est pas disponible. Vérifiez que AppHost fonctionne avec le tableau de bord activé. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Affichez les données de télémétrie (journaux, spans, traces) d’une application Aspire en cours d’exécution. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 9b65868633c..e4fee0ba008 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. L'API del dashboard non è disponibile. Verificare che AppHost sia in esecuzione con il dashboard abilitato. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Visualizza i dati di telemetria (log, span, tracce) da un'applicazione Aspire in esecuzione. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index 2cda145956f..4a99774ed56 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. ダッシュボード API は利用できません。ダッシュボードが有効になっている状態で AppHost が実行されていることを確認します。 + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 実行中の Aspire アプリケーションのテレメトリ データ (ログ、スパン、トレース) を表示します。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 2307913ec36..10136410a86 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. 대시보드 API를 사용할 수 없습니다. AppHost가 대시보드를 활성화한 상태로 실행 중인지 확인하세요. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 실행 중인 Aspire 애플리케이션의 원격 분석 데이터(로그, 범위, 추적)를 확인합니다. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 78ff6c1aac2..28de904b257 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Interfejs API pulpitu nawigacyjnego jest niedostępny. Upewnij się, że host aplikacji działa z włączonym pulpitem nawigacyjnym. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Wyświetl dane telemetryczne (logi, zakresy, śledzenia) z uruchomionej aplikacji Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 3ec8a739d07..528f1251509 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. A API do painel não está disponível. Verifique se o AppHost está em execução com o Painel habilitado. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Veja os dados de telemetria (logs, spans, rastreamentos) de um aplicativo Aspire em execução. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 2c92b98b1a1..7dc472ae6fe 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. API панели мониторинга недоступен. Убедитесь, что AppHost запущен с включенной панелью мониторинга. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Просмотр данных телеметрии (журналы, диапазоны, трассировки) из запущенного приложения Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 61d3d5c0536..56fe7e12163 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. Pano API'si kullanılamıyor. AppHost'un Pano özelliği etkin olarak çalıştığından emin olun. + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost Çalışan bir Aspire uygulamasından telemetri verilerini (günlükler, yayılmalar, izlemeler) görüntüleyin. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index 4e374c36db1..1b525c8145f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. 仪表板 API 不可用。请确保 AppHost 在运行时启用了仪表板。 + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 查看正在运行的 Aspire 应用程序中的遥测数据(日志、跨度、跟踪)。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 52a4ff8a881..3cb6c1b4ee0 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -2,11 +2,51 @@ + + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + API key for authenticating with the dashboard (optional, for dashboards with API key authentication) + + Dashboard API is not available. Ensure the apphost is running with Dashboard enabled. 儀表板 API 無法使用。確保 AppHost 執行中且儀表板功能已啟用。 + + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + The dashboard at '{0}' does not have the telemetry API enabled. Configure the dashboard with Dashboard:Api:Enabled set to true. + + + + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + Authentication denied by the dashboard. Use --api-key to specify a valid API key that matches the dashboard's configured API key (Dashboard:Api:PrimaryApiKey). + + + + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + Could not connect to the dashboard at '{0}'. Verify the dashboard is running and the URL is correct. + + + + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + The --dashboard-url and --apphost options cannot be used together. Specify one or the other. + + + + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + The value '{0}' is not a valid URL. Specify an absolute URL like 'http://localhost:18888'. + + + + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + The specified --dashboard-url '{0}' does not appear to be a valid Aspire Dashboard. Verify the URL is correct. + + + + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + URL of a standalone Aspire Dashboard to connect to directly (bypasses AppHost discovery) + + View OpenTelemetry data (logs, spans, traces) from a running apphost 從執行中的 Aspire 應用程式檢視遙測資料 (記錄、範圍、追蹤)。 diff --git a/src/Aspire.Cli/Utils/UrlHelper.cs b/src/Aspire.Cli/Utils/UrlHelper.cs new file mode 100644 index 00000000000..4586606deb8 --- /dev/null +++ b/src/Aspire.Cli/Utils/UrlHelper.cs @@ -0,0 +1,18 @@ +// 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; + +namespace Aspire.Cli.Utils; + +internal static class UrlHelper +{ + /// + /// Returns when is an absolute HTTP or HTTPS URL. + /// + internal static bool IsHttpUrl([NotNullWhen(true)] string? url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var parsed) && + (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs index 3bf05fa8694..f43e2d34521 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Cli.Mcp; @@ -9,6 +10,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Client; @@ -22,105 +24,81 @@ namespace Aspire.Cli.Tests.Commands; /// without starting a new CLI process. The IO communication between the MCP server /// and test client is abstracted using in-memory pipes via DI. /// -public class AgentMcpCommandTests(ITestOutputHelper outputHelper) : IAsyncLifetime +public class AgentMcpCommandTests(ITestOutputHelper outputHelper) { - private TemporaryWorkspace _workspace = null!; - private ServiceProvider _serviceProvider = null!; - private TestMcpServerTransport _testTransport = null!; - private McpClient _mcpClient = null!; - private AgentMcpCommand _agentMcpCommand = null!; - private Task _serverRunTask = null!; - private CancellationTokenSource _cts = null!; - private ILoggerFactory _loggerFactory = null!; - private TestAuxiliaryBackchannelMonitor _backchannelMonitor = null!; - - public async ValueTask InitializeAsync() + private async Task CreateMcpClientAsync(string? dashboardUrl = null) { - _cts = new CancellationTokenSource(); - _workspace = TemporaryWorkspace.Create(outputHelper); + var cts = new CancellationTokenSource(); + var workspace = TemporaryWorkspace.Create(outputHelper); + var loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); + var testTransport = new TestMcpServerTransport(loggerFactory); + var backchannelMonitor = new TestAuxiliaryBackchannelMonitor(); - // Create the test transport with in-memory pipes - _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); - _testTransport = new TestMcpServerTransport(_loggerFactory); - - // Create a backchannel monitor that we can configure for resource tool tests - _backchannelMonitor = new TestAuxiliaryBackchannelMonitor(); - - // Create services using CliTestHelper with custom MCP transport and test docs service - var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper, options => + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - // Override the MCP transport factory with our test transport (which implements IMcpTransportFactory) - options.McpServerTransportFactory = _ => _testTransport; - // Override the docs index service with a test implementation that doesn't make network calls + options.McpServerTransportFactory = _ => testTransport; options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); - // Override the backchannel monitor with our test implementation - options.AuxiliaryBackchannelMonitorFactory = _ => _backchannelMonitor; + options.AuxiliaryBackchannelMonitorFactory = _ => backchannelMonitor; }); - _serviceProvider = services.BuildServiceProvider(); + if (dashboardUrl is not null) + { + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"data\":{},\"totalCount\":0,\"returnedCount\":0}", System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + } - // Get the AgentMcpCommand from DI and start the server - _agentMcpCommand = _serviceProvider.GetRequiredService(); - var rootCommand = _serviceProvider.GetRequiredService(); - var parseResult = rootCommand.Parse("agent mcp"); + var serviceProvider = services.BuildServiceProvider(); + var agentMcpCommand = serviceProvider.GetRequiredService(); + var rootCommand = serviceProvider.GetRequiredService(); + var commandLine = dashboardUrl is not null + ? $"agent mcp --dashboard-url {dashboardUrl}" + : "agent mcp"; + var parseResult = rootCommand.Parse(commandLine); - // Start the MCP server in the background - _serverRunTask = Task.Run(async () => + var serverRunTask = Task.Run(async () => { try { - await _agentMcpCommand.ExecuteCommandAsync(parseResult, _cts.Token); + await agentMcpCommand.ExecuteCommandAsync(parseResult, cts.Token); } catch (OperationCanceledException) { - // Expected when cancellation is requested } - }, _cts.Token); - - // Create and connect the MCP client using the test transport's client side - _mcpClient = await _testTransport.CreateClientAsync(_loggerFactory, _cts.Token); - } - - public async ValueTask DisposeAsync() - { - if (_mcpClient is not null) - { - await _mcpClient.DisposeAsync(); - } + }, cts.Token); - await _cts.CancelAsync(); - - try - { - if (_serverRunTask is not null) - { - await _serverRunTask.WaitAsync(TimeSpan.FromSeconds(2)); - } - } - catch (OperationCanceledException) - { - // Expected when cancellation is requested - } - catch (TimeoutException) - { - // Server didn't stop in time, but that's OK for tests - } + var mcpClient = await testTransport.CreateClientAsync(loggerFactory, cts.Token); - _testTransport?.Dispose(); - if (_serviceProvider is not null) + return new McpTestContext(mcpClient, cts, workspace, serverRunTask, testTransport, serviceProvider, loggerFactory) { - await _serviceProvider.DisposeAsync(); - } - _workspace?.Dispose(); - _loggerFactory?.Dispose(); - _cts?.Dispose(); + BackchannelMonitor = backchannelMonitor + }; } [Fact] public async Task McpServer_ListTools_ReturnsExpectedTools() { - // Act - var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + await using var ctx = await CreateMcpClientAsync(); + + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(tools); @@ -151,14 +129,15 @@ static void AssertTool(string expectedName, McpClientTool tool) [Fact] public async Task McpServer_ListTools_IncludesResourceMcpTools() { - // Arrange - Create a mock backchannel with a resource that has MCP tools + await using var ctx = await CreateMcpClientAsync(); + var mockBackchannel = new TestAppHostAuxiliaryBackchannel { Hash = "test-apphost-hash", IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -190,14 +169,11 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools() ] }; - // Register the mock backchannel - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // First call refresh_tools to discover the resource tools - await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout(); - // Act - List all tools - var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - Verify resource tools are included Assert.NotNull(tools); @@ -217,7 +193,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools() [Fact] public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() { - // Arrange - Create a mock backchannel with a resource that has MCP tools + await using var ctx = await CreateMcpClientAsync(); + var expectedToolResult = "Tool executed successfully with custom data"; string? callResourceName = null; string? callToolName = null; @@ -228,7 +205,7 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -265,16 +242,13 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() } }; - // Register the mock backchannel - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // First call refresh_tools to discover the resource tools - await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout(); - // Act - Call the resource tool (name format: {resource_name}_{tool_name} with dashes replaced by underscores) - var result = await _mcpClient.CallToolAsync( + var result = await ctx.Client.CallToolAsync( "my_resource_do_something", - cancellationToken: _cts.Token).DefaultTimeout(); + cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(result); @@ -294,7 +268,8 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() [Fact] public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() { - // Arrange - Simulate resource snapshots that use a unique resource id and a logical display name. + await using var ctx = await CreateMcpClientAsync(); + var expectedToolResult = "List schemas completed"; string? callResourceName = null; string? callToolName = null; @@ -305,7 +280,7 @@ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -341,11 +316,10 @@ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() } }; - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout(); - // Act - var result = await _mcpClient.CallToolAsync("db1_mcp_list_schemas", cancellationToken: _cts.Token).DefaultTimeout(); + var result = await ctx.Client.CallToolAsync("db1_mcp_list_schemas", cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(result); @@ -357,10 +331,11 @@ public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() [Fact] public async Task McpServer_CallTool_ListAppHosts_ReturnsResult() { - // Act - var result = await _mcpClient.CallToolAsync( + await using var ctx = await CreateMcpClientAsync(); + + var result = await ctx.Client.CallToolAsync( KnownMcpTools.ListAppHosts, - cancellationToken: _cts.Token).DefaultTimeout(); + cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert Assert.NotNull(result); @@ -376,9 +351,10 @@ public async Task McpServer_CallTool_ListAppHosts_ReturnsResult() [Fact] public async Task McpServer_CallTool_RefreshTools_ReturnsResult() { - // Arrange - Set up a channel to receive the ToolListChanged notification + await using var ctx = await CreateMcpClientAsync(); + var notificationChannel = Channel.CreateUnbounded(); - await using var notificationHandler = _mcpClient.RegisterNotificationHandler( + await using var notificationHandler = ctx.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => { @@ -386,10 +362,9 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() return default; }); - // Act - var result = await _mcpClient.CallToolAsync( + var result = await ctx.Client.CallToolAsync( KnownMcpTools.RefreshTools, - cancellationToken: _cts.Token).DefaultTimeout(); + cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - Verify result Assert.NotNull(result); @@ -404,8 +379,7 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() var expectedToolCount = KnownMcpTools.All.Count; Assert.Equal($"Tools refreshed: {expectedToolCount} tools available", textContent.Text); - // Assert - Verify the ToolListChanged notification was received - var notification = await notificationChannel.Reader.ReadAsync(_cts.Token).AsTask().DefaultTimeout(); + var notification = await notificationChannel.Reader.ReadAsync(ctx.Cts.Token).AsTask().DefaultTimeout(); Assert.NotNull(notification); Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method); } @@ -413,15 +387,15 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() [Fact] public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() { - // Arrange - Create a mock backchannel with a resource that has MCP tools - // This simulates the db-mcp scenario where resource tools become available + await using var ctx = await CreateMcpClientAsync(); + var mockBackchannel = new TestAppHostAuxiliaryBackchannel { Hash = "test-apphost-hash", IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, ResourceSnapshots = @@ -448,12 +422,10 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() ] }; - // Register the mock backchannel so resource tools will be discovered - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // Set up a channel to detect any tools/list_changed notifications var notificationCount = 0; - await using var notificationHandler = _mcpClient.RegisterNotificationHandler( + await using var notificationHandler = ctx.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => { @@ -461,19 +433,16 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() return default; }); - // Act - Call ListTools which should discover the resource tools via refresh - // but should NOT send a tools/list_changed notification (that would cause an infinite loop) - var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - tools should include the resource tool Assert.NotNull(tools); var dbMcpTool = tools.FirstOrDefault(t => t.Name == "db_mcp_query_database"); Assert.NotNull(dbMcpTool); - // Assert - no tools/list_changed notification should have been sent. using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); var notificationChannel = Channel.CreateUnbounded(); - await using var channelHandler = _mcpClient.RegisterNotificationHandler( + await using var channelHandler = ctx.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, (notification, _) => { @@ -499,7 +468,8 @@ public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() [Fact] public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchanged() { - // Arrange - Create a mock backchannel and track how many times GetResourceSnapshotsAsync is called + await using var ctx = await CreateMcpClientAsync(); + var getResourceSnapshotsCallCount = 0; var mockBackchannel = new TestAppHostAuxiliaryBackchannel { @@ -507,7 +477,7 @@ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchan IsInScope = true, AppHostInfo = new AppHostInformation { - AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), ProcessId = 12345 }, GetResourceSnapshotsHandler = (ct) => @@ -538,11 +508,10 @@ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchan } }; - _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + ctx.BackchannelMonitor!.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); - // Act - Call ListTools twice - var tools1 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); - var tools2 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools1 = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); + var tools2 = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); // Assert - Both calls return the resource tool Assert.Contains(tools1, t => t.Name == "db_mcp_query_db"); @@ -559,15 +528,61 @@ public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchan [Fact] public async Task McpServer_CallTool_UnknownTool_ReturnsError() { - // Act & Assert - The MCP client throws McpProtocolException when the server returns an error + await using var ctx = await CreateMcpClientAsync(); + var exception = await Assert.ThrowsAsync(async () => - await _mcpClient.CallToolAsync( + await ctx.Client.CallToolAsync( "nonexistent_tool_that_does_not_exist", - cancellationToken: _cts.Token).DefaultTimeout()); + cancellationToken: ctx.Cts.Token).DefaultTimeout()); Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); } + [Fact] + public async Task McpServer_DashboardOnlyMode_ListTools_ReturnsOnlyTelemetryTools() + { + await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888"); + + var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout(); + + Assert.NotNull(tools); + Assert.Equal(3, tools.Count); + Assert.Collection(tools.OrderBy(t => t.Name), + tool => Assert.Equal(KnownMcpTools.ListStructuredLogs, tool.Name), + tool => Assert.Equal(KnownMcpTools.ListTraceStructuredLogs, tool.Name), + tool => Assert.Equal(KnownMcpTools.ListTraces, tool.Name)); + } + + [Fact] + public async Task McpServer_DashboardOnlyMode_CallNonTelemetryTool_ReturnsError() + { + await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888"); + + var exception = await Assert.ThrowsAsync(async () => + await ctx.Client.CallToolAsync( + KnownMcpTools.ListResources, + cancellationToken: ctx.Cts.Token).DefaultTimeout()); + + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + } + + [Fact] + public async Task McpServer_WithInvalidDashboardUrl_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var serviceProvider = services.BuildServiceProvider(); + await using var _ = serviceProvider; + + var agentMcpCommand = serviceProvider.GetRequiredService(); + var rootCommand = serviceProvider.GetRequiredService(); + var parseResult = rootCommand.Parse("agent mcp --dashboard-url not-a-url"); + + var exitCode = await agentMcpCommand.ExecuteCommandAsync(parseResult, CancellationToken.None).DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + private static string GetResultText(CallToolResult result) { if (result.Content?.FirstOrDefault() is TextContentBlock textContent) @@ -578,3 +593,41 @@ private static string GetResultText(CallToolResult result) return string.Empty; } } + +internal sealed class McpTestContext( + McpClient client, + CancellationTokenSource cts, + TemporaryWorkspace workspace, + Task serverRunTask, + TestMcpServerTransport testTransport, + ServiceProvider serviceProvider, + ILoggerFactory loggerFactory) : IAsyncDisposable +{ + public McpClient Client => client; + public CancellationTokenSource Cts => cts; + public TemporaryWorkspace Workspace => workspace; + public TestAuxiliaryBackchannelMonitor? BackchannelMonitor { get; init; } + + public async ValueTask DisposeAsync() + { + await client.DisposeAsync(); + await cts.CancelAsync(); + + try + { + await serverRunTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (OperationCanceledException) + { + } + catch (TimeoutException) + { + } + + testTransport.Dispose(); + await serviceProvider.DisposeAsync(); + workspace.Dispose(); + loggerFactory.Dispose(); + cts.Dispose(); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index 3f223da2d1c..31a28616245 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -23,7 +24,6 @@ public class ExportCommandTests(ITestOutputHelper outputHelper) public async Task ExportCommand_WritesZipWithExpectedData() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); var resources = new[] @@ -39,7 +39,7 @@ public async Task ExportCommand_WritesZipWithExpectedData() var tracesJson = BuildTracesJson( ("apiservice", null, "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, @@ -112,11 +112,10 @@ public async Task ExportCommand_WritesZipWithExpectedData() public async Task ExportCommand_OutputOption_ConfiguresArchiveOutputLocation() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var customDir = Path.Combine(workspace.WorkspaceRoot.FullName, "custom", "nested"); var outputPath = Path.Combine(customDir, "my-export.zip"); - var provider = CreateExportTestServices(workspace, outputWriter, + var provider = CreateExportTestServices(workspace, resources: [new ResourceInfoJson { Name = "redis", InstanceId = null }], telemetryEndpoints: new Dictionary { @@ -299,7 +298,6 @@ public async Task ExportCommand_SingleInScopeConnection_ExportsCorrectData() public async Task ExportCommand_ReplicaResources_GroupsDataByResolvedResourceName() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); // 3 telemetry resources: redis (singleton) + apiservice with 2 replicas @@ -321,7 +319,7 @@ public async Task ExportCommand_ReplicaResources_GroupsDataByResolvedResourceNam ("apiservice", "abc", "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false), ("apiservice", "def", "span002", "GET /api/orders", s_testTime.AddSeconds(1), s_testTime.AddSeconds(1).AddMilliseconds(80), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, @@ -426,7 +424,6 @@ public async Task ExportCommand_ReplicaResources_GroupsDataByResolvedResourceNam public async Task ExportCommand_ResourceFilter_ExportsOnlyFilteredResource() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); var resources = new[] @@ -442,7 +439,7 @@ public async Task ExportCommand_ResourceFilter_ExportsOnlyFilteredResource() var filteredTracesJson = BuildTracesJson( ("redis", null, "span001", "SET mykey", s_testTime, s_testTime.AddMilliseconds(10), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = filteredLogsJson, @@ -485,7 +482,6 @@ public async Task ExportCommand_ResourceFilter_ExportsOnlyFilteredResource() public async Task ExportCommand_ResourceFilter_NoTelemetryData_SkipsStructuredLogsAndTraces() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); // Telemetry resources do NOT include "webfrontend" - it hasn't sent any telemetry yet @@ -500,7 +496,7 @@ public async Task ExportCommand_ResourceFilter_NoTelemetryData_SkipsStructuredLo var tracesJson = BuildTracesJson( ("apiservice", null, "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = logsJson, @@ -540,7 +536,6 @@ public async Task ExportCommand_ResourceFilter_NoTelemetryData_SkipsStructuredLo public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllReplicas() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); var resources = new[] @@ -559,7 +554,7 @@ public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllR ("apiservice", "abc", "span002", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(50), false), ("apiservice", "def", "span003", "GET /api/orders", s_testTime.AddSeconds(1), s_testTime.AddSeconds(1).AddMilliseconds(80), false)); - var provider = CreateExportTestServices(workspace, outputWriter, resources, + var provider = CreateExportTestServices(workspace, resources, telemetryEndpoints: new Dictionary { ["/api/telemetry/logs"] = filteredLogsJson, @@ -606,10 +601,9 @@ public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllR public async Task ExportCommand_ResourceFilter_NonExistentResource_ReturnsError() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); - var provider = CreateExportTestServices(workspace, outputWriter, + var provider = CreateExportTestServices(workspace, resources: [new ResourceInfoJson { Name = "redis", InstanceId = null }], telemetryEndpoints: new Dictionary { @@ -631,14 +625,68 @@ public async Task ExportCommand_ResourceFilter_NonExistentResource_ReturnsError( Assert.False(File.Exists(outputPath), "No zip should be created when the resource doesn't exist"); } + [Fact] + public async Task ExportCommand_DashboardUrl_ExportsTelemetryData() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); + + var resources = new[] + { + new ResourceInfoJson { Name = "redis", InstanceId = null }, + }; + + var logsJson = BuildLogsJson( + ("redis", null, 9, "Information", "Ready to accept connections", s_testTime)); + + // CreateExportTestServices sets up a backchannel, but --dashboard-url bypasses it entirely + var provider = CreateExportTestServices(workspace, resources, + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = logsJson, + ["/api/telemetry/traces"] = BuildTracesJson(), + }, + resourceSnapshots: + [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + ], + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis is starting" }, + ]); + + var command = provider.GetRequiredService(); + var result = command.Parse($"export --dashboard-url http://localhost:18888 --api-key test-token --output {outputPath}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(outputPath), "Export zip file should be created"); + + using var archive = ZipFile.OpenRead(outputPath); + var entryNames = archive.Entries.Select(e => e.FullName).OrderBy(n => n).ToList(); + + // With --dashboard-url there is no backchannel, so no resources or console logs + Assert.Collection(entryNames, + entry => Assert.Equal("structuredlogs/redis.json", entry)); + + // Verify structured log content + var redisLogs = JsonSerializer.Deserialize( + ReadEntryText(archive, "structuredlogs/redis.json"), + OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); + Assert.NotNull(redisLogs?.ResourceLogs); + Assert.Single(redisLogs.ResourceLogs); + } + [Fact] public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleLogs() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var outputWriter = new TestOutputTextWriter(outputHelper); var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); - var provider = CreateExportTestServices(workspace, outputWriter, + var testInteractionService = new TestInteractionService(); + + var provider = CreateExportTestServices(workspace, resources: [], telemetryEndpoints: new Dictionary(), resourceSnapshots: @@ -651,7 +699,8 @@ public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleL new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis is starting" }, new ResourceLogLine { ResourceName = "apiservice", LineNumber = 1, Content = "Now listening on: https://localhost:5001" }, ], - dashboardAvailable: false); + dashboardAvailable: false, + interactionService: testInteractionService); var command = provider.GetRequiredService(); var result = command.Parse($"export --output {outputPath}"); @@ -676,7 +725,7 @@ public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleL Assert.Contains("Redis is starting", redisConsoleLog); // Verify warning was displayed - Assert.Contains(outputWriter.Logs, line => line.Contains(ExportCommandStrings.DashboardNotAvailable)); + Assert.Contains(testInteractionService.DisplayedMessages, m => m.Message.Contains(ExportCommandStrings.DashboardNotAvailable)); } /// @@ -685,12 +734,12 @@ public async Task ExportCommand_DashboardUnavailable_ExportsResourcesAndConsoleL /// private ServiceProvider CreateExportTestServices( TemporaryWorkspace workspace, - TestOutputTextWriter outputWriter, ResourceInfoJson[] resources, Dictionary telemetryEndpoints, List resourceSnapshots, List logLines, - bool dashboardAvailable = true) + bool dashboardAvailable = true, + IInteractionService? interactionService = null) { var resourcesJson = JsonSerializer.Serialize(resources, OtlpJsonSerializerContext.Default.ResourceInfoJsonArray); @@ -743,7 +792,10 @@ private ServiceProvider CreateExportTestServices( var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - options.OutputTextWriter = outputWriter; + if (interactionService is not null) + { + options.InteractionServiceFactory = _ => interactionService; + } options.DisableAnsi = true; }); diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs index a2fc429c6b5..fa206724a8b 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs @@ -1,13 +1,18 @@ // 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.Net; using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -145,4 +150,267 @@ private static string BuildLogsJson(params (string serviceName, string? instance return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse); } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_FetchesLogsDirectly() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/logs")) + { + var json = BuildLogsJson(("redis", null, 9, "Information", "Ready to accept connections", s_testTime)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + var logLines = outputWriter.Logs.Where(l => l.Contains("redis")).ToList(); + Assert.Single(logLines); + Assert.Contains("Ready to accept connections", logLines[0]); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrlAndApiKey_SendsApiKeyHeader() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + string? capturedApiKey = null; + + var handler = new MockHttpMessageHandler(request => + { + if (request.Headers.TryGetValues("X-API-Key", out var values)) + { + capturedApiKey = values.FirstOrDefault(); + } + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/logs")) + { + var json = BuildLogsJson(("redis", null, 9, "Information", "Connected", s_testTime)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888 --api-key my-secret-key"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Equal("my-secret-key", capturedApiKey); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); + } + + [Fact] + public async Task TelemetryLogsCommand_WithInvalidDashboardUrl_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url not-a-url"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, "not-a-url"), errorMessage); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_404WithReachableBase_DisplaysApiNotEnabledMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/")) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + // Base URL probe returns OK + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardApiNotEnabled, "http://localhost:18888"), errorMessage); + } + + [Fact] + public async Task TelemetryLogsCommand_WithDashboardUrl_ConnectionRefused_DisplaysConnectionFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + // Simulate connection refused (HttpRequestException with no status code) + throw new HttpRequestException("Connection refused"); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardConnectionFailed, "http://localhost:18888"), errorMessage); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs index 2dd7a09d70b..e601bab9883 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs @@ -1,13 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -147,4 +151,112 @@ private static string BuildSpansJson(params (string serviceName, string? instanc return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse); } + + [Fact] + public async Task TelemetrySpansCommand_WithDashboardUrl_FetchesSpansDirectly() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/spans")) + { + var json = BuildSpansJson(("frontend", null, "span001", "GET /index", s_testTime, s_testTime.AddMilliseconds(50), false)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + var spanLines = outputWriter.Logs.Where(l => l.Contains("frontend")).ToList(); + Assert.Single(spanLines); + Assert.Contains("GET /index", spanLines[0]); + } + + [Fact] + public async Task TelemetrySpansCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); + } + + [Fact] + public async Task TelemetrySpansCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs index 5bdebbf32dd..19cbc41678a 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs @@ -1,13 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Resources; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Utils; using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Cli.Tests.Commands; @@ -195,4 +199,112 @@ private static string BuildTracesJson(params (string traceId, string serviceName return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse); } + + [Fact] + public async Task TelemetryTracesCommand_WithDashboardUrl_FetchesTracesDirectly() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + if (url.Contains("/api/telemetry/traces")) + { + var json = BuildTracesJson(("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + var dataRows = ParseTableDataRows(outputWriter.Logs); + Assert.Single(dataRows); + Assert.Contains("frontend", dataRows[0][1]); + } + + [Fact] + public async Task TelemetryTracesCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage); + } + + [Fact] + public async Task TelemetryTracesCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var testInteractionService = new TestInteractionService(); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => testInteractionService; + }); + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces --dashboard-url http://localhost:18888"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); + var errorMessage = Assert.Single(testInteractionService.DisplayedErrors); + Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage); + } } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs index 3a9548f8b64..0f1d975cc32 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs @@ -398,8 +398,10 @@ private static ListStructuredLogsTool CreateTool( TestAuxiliaryBackchannelMonitor? monitor = null, IHttpClientFactory? httpClientFactory = null) { + var actualMonitor = monitor ?? new TestAuxiliaryBackchannelMonitor(); + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(actualMonitor, NullLogger.Instance); return new ListStructuredLogsTool( - monitor ?? new TestAuxiliaryBackchannelMonitor(), + dashboardInfoProvider, httpClientFactory ?? s_httpClientFactory, NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs index 038d929ccec..05c94791874 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs @@ -437,8 +437,10 @@ private static ListTracesTool CreateTool( TestAuxiliaryBackchannelMonitor? monitor = null, IHttpClientFactory? httpClientFactory = null) { + var actualMonitor = monitor ?? new TestAuxiliaryBackchannelMonitor(); + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(actualMonitor, NullLogger.Instance); return new ListTracesTool( - monitor ?? new TestAuxiliaryBackchannelMonitor(), + dashboardInfoProvider, httpClientFactory ?? s_httpClientFactory, NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs index 0480f5d1a21..100ff683dee 100644 --- a/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs @@ -14,11 +14,15 @@ public class McpToolHelpersTests [InlineData("http://localhost:18888/login", "http://localhost:18888")] [InlineData("http://localhost:18888/login?t=authtoken123", "http://localhost:18888")] [InlineData("https://localhost:16319/login?t=d8d8255df4c79aebcb5b7325828ccb20", "https://localhost:16319")] - [InlineData("https://example.com:8080/path/to/resource?param=value", "https://example.com:8080")] + [InlineData("https://example.com:8080/path/to/resource?param=value", "https://example.com:8080/path/to/resource")] + [InlineData("https://example.com:8080/dashboard", "https://example.com:8080/dashboard")] + [InlineData("http://localhost/base/login", "http://localhost/base")] + [InlineData("http://localhost/base/login?t=token123", "http://localhost/base")] + [InlineData("https://example.com:8080/app/deep/login?t=abc", "https://example.com:8080/app/deep")] [InlineData("invalid-url", "invalid-url")] // Falls back to returning the original string - public void GetBaseUrl_ExtractsBaseUrl_RemovingPathAndQueryString(string? input, string? expected) + public void StripLoginPath_RemovesOnlyLoginSegment(string? input, string? expected) { - var result = McpToolHelpers.GetBaseUrl(input); + var result = McpToolHelpers.StripLoginPath(input); Assert.Equal(expected, result); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index befce116ddf..0ff856e2bf3 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -35,6 +35,7 @@ internal sealed class TestInteractionService : IInteractionService public List StringPromptCalls { get; } = []; public List BooleanPromptCalls { get; } = []; public List DisplayedErrors { get; } = []; + public List<(KnownEmoji Emoji, string Message)> DisplayedMessages { get; } = []; // Response queue setup methods public void SetupStringPromptResponse(string response) => _responses.Enqueue((response, ResponseType.String)); @@ -148,6 +149,7 @@ public void DisplayError(string errorMessage) public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { + DisplayedMessages.Add((emoji, message)); } public void DisplaySuccess(string message, bool allowMarkup = false)