diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index fa4d3683a6f..8d8ed84f6f3 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -75,6 +75,7 @@
+
@@ -84,6 +85,7 @@
+
diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
index 36df5b2a4dd..bb239d4ee48 100644
--- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs
+++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
@@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
-using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
@@ -18,7 +17,6 @@
using Aspire.Shared.Mcp;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
-using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -56,6 +54,7 @@ public AgentMcpCommand(
IEnvironmentChecker environmentChecker,
IDocsSearchService docsSearchService,
IDocsIndexService docsIndexService,
+ IHttpClientFactory httpClientFactory,
AspireCliTelemetry telemetry)
: base("mcp", AgentCommandStrings.McpCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
@@ -69,9 +68,9 @@ public AgentMcpCommand(
[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(),
- [KnownMcpTools.ListTraces] = new ListTracesTool(),
- [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(),
+ [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),
@@ -176,33 +175,17 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext CallDashboardToolAsync(
- string toolName,
- CliMcpTool tool,
- ProgressToken? progressToken,
- IReadOnlyDictionary? arguments,
- CancellationToken cancellationToken)
- {
- var connection = await GetSelectedConnectionAsync(cancellationToken).ConfigureAwait(false);
- if (connection is null)
- {
- _logger.LogWarning("No Aspire AppHost is currently running");
- throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError);
- }
-
- if (connection.McpInfo is null)
- {
- _logger.LogWarning("Dashboard is not available in the running AppHost");
- throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError);
- }
-
- _logger.LogInformation(
- "Connecting to dashboard MCP server. " +
- "Dashboard URL: {EndpointUrl}, " +
- "AppHost Path: {AppHostPath}, " +
- "AppHost PID: {AppHostPid}, " +
- "CLI PID: {CliPid}",
- connection.McpInfo.EndpointUrl,
- connection.AppHostInfo?.AppHostPath ?? "N/A",
- connection.AppHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A",
- connection.AppHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A");
-
- var transportOptions = new HttpClientTransportOptions
- {
- Endpoint = new Uri(connection.McpInfo.EndpointUrl),
- AdditionalHeaders = new Dictionary
- {
- ["x-mcp-api-key"] = connection.McpInfo.ApiToken
- }
- };
-
- using var httpClient = new HttpClient();
- await using var transport = new HttpClientTransport(transportOptions, httpClient, _loggerFactory, ownsHttpClient: true);
-
- // Create MCP client to communicate with the dashboard
- await using var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken);
-
- _logger.LogDebug("Calling tool {ToolName} on dashboard MCP server", toolName);
-
- try
- {
- _logger.LogDebug("Invoking CallToolAsync for tool {ToolName} with arguments: {Arguments}", toolName, arguments);
- var context = new CallToolContext
- {
- Notifier = new McpServerNotifier(_server!),
- McpClient = mcpClient,
- Arguments = arguments,
- ProgressToken = progressToken
- };
- var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false);
- _logger.LogDebug("Tool {ToolName} completed successfully", toolName);
- return result;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error occurred while calling tool {ToolName}", toolName);
- throw;
- }
- }
-
///
/// Gets the appropriate AppHost connection based on the selection logic.
///
diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
index 9d4e8241e53..27287908a2b 100644
--- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
+++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
@@ -3,7 +3,7 @@
using System.CommandLine;
using System.Globalization;
-using System.Text.Json;
+using System.Net.Http.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Otlp;
@@ -165,61 +165,58 @@ public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiT
return client;
}
- ///
- /// Fetches available resources from the Dashboard API and resolves a resource name to specific instances.
- /// If the resource name matches a base name with multiple replicas, returns all matching replica names.
- ///
- /// The HTTP client configured for Dashboard API access.
- /// The Dashboard API base URL.
- /// The resource name to resolve (can be base name or full instance name).
- /// Cancellation token.
- /// A list of resolved resource display names to query, or null if resource not found.
- public static async Task?> ResolveResourceNamesAsync(
- HttpClient client,
- string baseUrl,
+ public static bool TryResolveResourceNames(
string? resourceName,
- CancellationToken cancellationToken)
+ IList resources,
+ out List? resolvedResources)
{
if (string.IsNullOrEmpty(resourceName))
{
- // No filter - return null to indicate no resource filter
- return null;
+ // No filter - return true to indicate success
+ resolvedResources = null;
+ return true;
}
- // Fetch available resources
- var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl);
- var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
- var resources = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray);
-
- if (resources is null || resources.Length == 0)
+ if (resources is null || resources.Count == 0)
{
- return null;
+ resolvedResources = null;
+ return false;
}
// First, try exact match on display name (full instance name like "catalogservice-abc123")
var exactMatch = resources.FirstOrDefault(r =>
- string.Equals(r.DisplayName, resourceName, StringComparison.OrdinalIgnoreCase));
+ string.Equals(r.GetCompositeName(), resourceName, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
- return [exactMatch.DisplayName];
+ resolvedResources = [exactMatch.GetCompositeName()];
+ return true;
}
// Then, try matching by base name to find all replicas
var matchingReplicas = resources
.Where(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase))
- .Select(r => r.DisplayName)
+ .Select(r => r.GetCompositeName())
.ToList();
if (matchingReplicas.Count > 0)
{
- return matchingReplicas;
+ resolvedResources = matchingReplicas;
+ return true;
}
// No match found
- return [];
+ resolvedResources = null;
+ return false;
+ }
+
+ public static async Task GetAllResourcesAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
+ {
+ var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl);
+ var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false);
+ return resources!;
}
///
diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
index 46afd08b45b..c363581dbb7 100644
--- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
@@ -111,11 +111,10 @@ private async Task FetchLogsAsync(
using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken);
// Resolve resource name to specific instances (handles replicas)
- var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync(
- client, baseUrl, resource, cancellationToken);
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
- // If a resource was specified but not found, show error
- if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0)
+ // If a resource was specified but not found, return error
+ if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources))
{
_interactionService.DisplayError($"Resource '{resource}' not found.");
return ExitCodeConstants.InvalidCommand;
diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
index 4f884ee9a65..a82e6163976 100644
--- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
@@ -107,11 +107,10 @@ private async Task FetchSpansAsync(
using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken);
// Resolve resource name to specific instances (handles replicas)
- var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync(
- client, baseUrl, resource, cancellationToken);
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
- // If a resource was specified but not found, show error
- if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0)
+ // If a resource was specified but not found, return error
+ if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources))
{
_interactionService.DisplayError($"Resource '{resource}' not found.");
return ExitCodeConstants.InvalidCommand;
diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
index efadfc7efcc..442d664a383 100644
--- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
@@ -160,11 +160,10 @@ private async Task FetchTracesAsync(
using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken);
// Resolve resource name to specific instances (handles replicas)
- var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync(
- client, baseUrl, resource, cancellationToken);
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
- // If a resource was specified but not found, show error
- if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0)
+ // If a resource was specified but not found, return error
+ if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources))
{
_interactionService.DisplayError($"Resource '{resource}' not found.");
return ExitCodeConstants.InvalidCommand;
diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs
index de9276b63c0..346211b7846 100644
--- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs
+++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs
@@ -14,7 +14,6 @@ internal static class KnownMcpTools
internal const string ListStructuredLogs = "list_structured_logs";
internal const string ListTraces = "list_traces";
internal const string ListTraceStructuredLogs = "list_trace_structured_logs";
-
internal const string SelectAppHost = "select_apphost";
internal const string ListAppHosts = "list_apphosts";
internal const string ListIntegrations = "list_integrations";
@@ -45,21 +44,4 @@ internal static class KnownMcpTools
GetDoc
];
- public static bool IsLocalTool(string toolName) => toolName is
- SelectAppHost or
- ListAppHosts or
- ListIntegrations or
- Doctor or
- RefreshTools or
- ListDocs or
- SearchDocs or
- GetDoc or
- ListResources or
- ListConsoleLogs or
- ExecuteResourceCommand;
-
- public static bool IsDashboardTool(string toolName) => toolName is
- ListStructuredLogs or
- ListTraces or
- ListTraceStructuredLogs;
}
diff --git a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs
index 846fbea7715..3b09c3e0583 100644
--- a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs
@@ -30,9 +30,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // This tool does not use the MCP client or arguments
- _ = context;
-
try
{
// Run all environment checks
diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
index b369d343c56..4e8ec36df79 100644
--- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
@@ -44,7 +44,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
var arguments = context.Arguments;
-
if (arguments is null ||
!arguments.TryGetValue("resourceName", out var resourceNameElement) ||
!arguments.TryGetValue("commandName", out var commandNameElement))
diff --git a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs
index 0e21d6b3e11..aa6cfda5e32 100644
--- a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs
@@ -44,9 +44,7 @@ public override JsonElement GetInputSchema()
""").RootElement;
}
- public override async ValueTask CallToolAsync(
- CallToolContext context,
- CancellationToken cancellationToken)
+ public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
var arguments = context.Arguments;
diff --git a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs
index 14ac11b57a9..6e906ae8f96 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs
@@ -37,9 +37,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // This tool does not use the MCP client as it operates locally
- _ = context;
-
// Trigger an immediate scan to ensure we have the latest AppHost connections
await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs
index 56fa8aade4f..e5e2cc06975 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs
@@ -72,15 +72,15 @@ public override async ValueTask CallToolAsync(CallToolContext co
var entries = logEntries.GetEntries().ToList();
var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber;
- var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary(
+ var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary(
entries,
totalLogsCount,
SharedAIHelpers.ConsoleLogsLimit,
"console log",
"console logs",
SharedAIHelpers.SerializeLogEntry,
- logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry));
- var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList());
+ SharedAIHelpers.EstimateTokenCount);
+ var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems);
var consoleLogsData = $"""
{limitMessage}
diff --git a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs
index 6969f0925cd..14387d20a05 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs
@@ -38,9 +38,7 @@ public override JsonElement GetInputSchema()
""").RootElement;
}
- public override async ValueTask CallToolAsync(
- CallToolContext context,
- CancellationToken cancellationToken)
+ public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false);
diff --git a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs
index 443702cb971..9e74b3d46b3 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs
@@ -69,9 +69,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // This tool does not use the MCP client as it operates locally
- _ = context;
-
try
{
// Get all channels
diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
index 8e579c8d115..1a55f716492 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
@@ -57,9 +57,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // This tool does not use the MCP client as it operates via backchannel
- _ = context;
-
var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
if (connection is null)
{
diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
index 17eab72cfe7..ffe2e8163e2 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
@@ -1,13 +1,25 @@
// 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.Http.Json;
using System.Text.Json;
+using Aspire.Cli.Backchannel;
+using Aspire.Cli.Commands;
+using Aspire.Cli.Otlp;
+using Aspire.Dashboard.Otlp.Model;
+using Aspire.Dashboard.Utils;
+using Aspire.Shared.ConsoleLogs;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
namespace Aspire.Cli.Mcp.Tools;
-internal sealed class ListStructuredLogsTool : CliMcpTool
+///
+/// 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
{
public override string Name => KnownMcpTools.ListStructuredLogs;
@@ -30,22 +42,66 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // Convert JsonElement arguments to Dictionary
- Dictionary? convertedArgs = null;
- if (context.Arguments != null)
+ var arguments = context.Arguments;
+ var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
+
+ // Extract resourceName from arguments
+ string? resourceName = null;
+ if (arguments?.TryGetValue("resourceName", out var resourceNameElement) == true &&
+ resourceNameElement.ValueKind == JsonValueKind.String)
{
- convertedArgs = new Dictionary();
- foreach (var kvp in context.Arguments)
+ resourceName = resourceNameElement.GetString();
+ }
+
+ try
+ {
+ using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken);
+
+ // Resolve resource name to specific instances (handles replicas)
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false);
+
+ // If a resource was specified but not found, return error
+ if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources))
{
- convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value;
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = $"Resource '{resourceName}' not found." }],
+ IsError = true
+ };
}
- }
- // Forward the call to the dashboard's MCP server
- return await context.McpClient!.CallToolAsync(
- Name,
- convertedArgs,
- serializerOptions: McpJsonUtilities.DefaultOptions,
- cancellationToken: cancellationToken);
+ var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources);
+
+ logger.LogDebug("Fetching structured logs from {Url}", url);
+
+ var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false);
+ var resourceLogs = apiResponse?.Data?.ResourceLogs;
+
+ var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson(
+ resourceLogs,
+ getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()),
+ dashboardBaseUrl: dashboardBaseUrl);
+
+ var text = $"""
+ {limitMessage}
+
+ # STRUCTURED LOGS DATA
+
+ {logsData}
+ """;
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = text }]
+ };
+ }
+ 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);
+ }
}
}
diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
index 7127c546758..05e55e55b2e 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
@@ -1,13 +1,25 @@
// 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.Http.Json;
using System.Text.Json;
+using Aspire.Cli.Backchannel;
+using Aspire.Cli.Commands;
+using Aspire.Cli.Otlp;
+using Aspire.Dashboard.Otlp.Model;
+using Aspire.Dashboard.Utils;
+using Aspire.Shared.ConsoleLogs;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
namespace Aspire.Cli.Mcp.Tools;
-internal sealed class ListTraceStructuredLogsTool : CliMcpTool
+///
+/// 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
{
public override string Name => KnownMcpTools.ListTraceStructuredLogs;
@@ -31,22 +43,65 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // Convert JsonElement arguments to Dictionary
- Dictionary? convertedArgs = null;
- if (context.Arguments != null)
+ var arguments = context.Arguments;
+ var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
+
+ // Extract traceId from arguments (required)
+ string? traceId = null;
+ if (arguments?.TryGetValue("traceId", out var traceIdElement) == true &&
+ traceIdElement.ValueKind == JsonValueKind.String)
{
- convertedArgs = new Dictionary();
- foreach (var kvp in context.Arguments)
+ traceId = traceIdElement.GetString();
+ }
+
+ if (string.IsNullOrEmpty(traceId))
+ {
+ return new CallToolResult
{
- convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value;
- }
+ Content = [new TextContentBlock { Text = "The 'traceId' parameter is required." }],
+ IsError = true
+ };
}
- // Forward the call to the dashboard's MCP server
- return await context.McpClient!.CallToolAsync(
- Name,
- convertedArgs,
- serializerOptions: McpJsonUtilities.DefaultOptions,
- cancellationToken: cancellationToken);
+ try
+ {
+ using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken);
+
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false);
+
+ // Build the logs API URL with traceId filter
+ var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resources: null, ("traceId", traceId));
+
+ logger.LogDebug("Fetching structured logs from {Url}", url);
+
+ var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false);
+ var resourceLogs = apiResponse?.Data?.ResourceLogs;
+
+ var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson(
+ resourceLogs,
+ getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()),
+ dashboardBaseUrl: dashboardBaseUrl);
+
+ var text = $"""
+ {limitMessage}
+
+ # STRUCTURED LOGS DATA
+
+ {logsData}
+ """;
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = text }]
+ };
+ }
+ 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);
+ }
}
}
diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
index 4e589bcffba..c9b032366c5 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
@@ -1,13 +1,25 @@
// 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.Http.Json;
using System.Text.Json;
+using Aspire.Cli.Backchannel;
+using Aspire.Cli.Commands;
+using Aspire.Cli.Otlp;
+using Aspire.Dashboard.Otlp.Model;
+using Aspire.Dashboard.Utils;
+using Aspire.Shared.ConsoleLogs;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
namespace Aspire.Cli.Mcp.Tools;
-internal sealed class ListTracesTool : CliMcpTool
+///
+/// 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
{
public override string Name => KnownMcpTools.ListTraces;
@@ -30,22 +42,66 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // Convert JsonElement arguments to Dictionary
- Dictionary? convertedArgs = null;
- if (context.Arguments != null)
+ var arguments = context.Arguments;
+ var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
+
+ // Extract resourceName from arguments
+ string? resourceName = null;
+ if (arguments?.TryGetValue("resourceName", out var resourceNameElement) == true &&
+ resourceNameElement.ValueKind == JsonValueKind.String)
{
- convertedArgs = new Dictionary();
- foreach (var kvp in context.Arguments)
+ resourceName = resourceNameElement.GetString();
+ }
+
+ try
+ {
+ using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken);
+
+ // Resolve resource name to specific instances (handles replicas)
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false);
+
+ // If a resource was specified but not found, return error
+ if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources))
{
- convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value;
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = $"Resource '{resourceName}' not found." }],
+ IsError = true
+ };
}
- }
- // Forward the call to the dashboard's MCP server
- return await context.McpClient!.CallToolAsync(
- Name,
- convertedArgs,
- serializerOptions: McpJsonUtilities.DefaultOptions,
- cancellationToken: cancellationToken);
+ var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources);
+
+ logger.LogDebug("Fetching traces from {Url}", url);
+
+ var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false);
+ var resourceSpans = apiResponse?.Data?.ResourceSpans;
+
+ var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson(
+ resourceSpans,
+ getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()),
+ dashboardBaseUrl: dashboardBaseUrl);
+
+ var text = $"""
+ {limitMessage}
+
+ # TRACES DATA
+
+ {tracesData}
+ """;
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = text }]
+ };
+ }
+ catch (HttpRequestException ex)
+ {
+ logger.LogError(ex, "Failed to fetch traces from Dashboard API");
+ throw new McpProtocolException($"Failed to fetch traces: {ex.Message}", McpErrorCode.InternalError);
+ }
}
}
diff --git a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs
new file mode 100644
index 00000000000..20f520e173b
--- /dev/null
+++ b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs
@@ -0,0 +1,52 @@
+// 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;
+using ModelContextProtocol;
+
+namespace Aspire.Cli.Mcp.Tools;
+
+internal static class McpToolHelpers
+{
+ public static async Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger, CancellationToken cancellationToken)
+ {
+ var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
+ if (connection is null)
+ {
+ logger.LogWarning("No Aspire AppHost is currently running");
+ throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError);
+ }
+
+ var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken).ConfigureAwait(false);
+ if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null)
+ {
+ logger.LogWarning("Dashboard API is not available");
+ throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError);
+ }
+
+ var dashboardBaseUrl = GetBaseUrl(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.
+ ///
+ /// 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)
+ {
+ if (url is null)
+ {
+ return null;
+ }
+
+ if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ {
+ return $"{uri.Scheme}://{uri.Authority}";
+ }
+
+ return url;
+ }
+}
diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
index 502f00251a3..c8d3d5c4fef 100644
--- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
@@ -19,8 +19,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- _ = context;
-
var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs
index 8b1524bf9c7..0dd23d75ccf 100644
--- a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs
@@ -48,12 +48,9 @@ public override JsonElement GetInputSchema()
""").RootElement;
}
- public override async ValueTask CallToolAsync(
- CallToolContext context,
- CancellationToken cancellationToken)
+ public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
var arguments = context.Arguments;
-
if (arguments is null || !arguments.TryGetValue("query", out var queryElement))
{
return new CallToolResult
diff --git a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs
index 1024bcc8331..a151b4b8cf7 100644
--- a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs
@@ -34,9 +34,6 @@ public override JsonElement GetInputSchema()
public override ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
- // This tool does not use the MCP client as it operates locally
- _ = cancellationToken;
-
var arguments = context.Arguments;
if (arguments == null || !arguments.TryGetValue("appHostPath", out var appHostPathElement))
diff --git a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs
index 4f5a76f7bbd..ed8cfc4e697 100644
--- a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs
+++ b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs
@@ -47,18 +47,11 @@ internal sealed class ResourceInfoJson
public string Name { get; set; } = "";
///
- /// The instance ID if this is a replica (e.g., "abc123"), or null if single instance.
+ /// The instance ID (e.g., "abc123").
///
[JsonPropertyName("instanceId")]
public string? InstanceId { get; set; }
- ///
- /// The full display name including instance ID (e.g., "catalogservice-abc123" or "catalogservice").
- /// Use this when querying the telemetry API.
- ///
- [JsonPropertyName("displayName")]
- public string DisplayName { get; set; } = "";
-
///
/// Whether this resource has structured logs.
///
@@ -76,6 +69,20 @@ internal sealed class ResourceInfoJson
///
[JsonPropertyName("hasMetrics")]
public bool HasMetrics { get; set; }
+
+ ///
+ /// Gets the full display name by combining Name and InstanceId.
+ ///
+ /// The full display name (e.g., "catalogservice-abc123" or "catalogservice").
+ public string GetCompositeName()
+ {
+ if (InstanceId is null)
+ {
+ return Name;
+ }
+
+ return $"{Name}-{InstanceId}";
+ }
}
///
diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs
index 38fd9574f01..4f636ff25ef 100644
--- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs
+++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs
@@ -16,12 +16,15 @@ namespace Aspire.Dashboard.Api;
/// Handles telemetry API requests, returning data in OTLP JSON format.
///
internal sealed class TelemetryApiService(
- TelemetryRepository telemetryRepository)
+ TelemetryRepository telemetryRepository,
+ IEnumerable outgoingPeerResolvers)
{
private const int DefaultLimit = 200;
private const int DefaultTraceLimit = 100;
private const int MaxQueryCount = 10000;
+ private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray();
+
///
/// Gets spans in OTLP JSON format.
/// Returns null if resource filter is specified but not found.
@@ -83,7 +86,7 @@ internal sealed class TelemetryApiService(
spans = spans.Skip(spans.Count - effectiveLimit).ToList();
}
- var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans);
+ var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers);
return new TelemetryApiResponse
{
@@ -148,7 +151,7 @@ internal sealed class TelemetryApiService(
// Get all spans from filtered traces
var spans = traces.SelectMany(t => t.Spans).ToList();
- var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans);
+ var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers);
return new TelemetryApiResponse
{
@@ -181,7 +184,7 @@ internal sealed class TelemetryApiService(
var spans = trace.Spans.ToList();
- var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans);
+ var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers);
return new TelemetryApiResponse
{
@@ -315,7 +318,7 @@ public async IAsyncEnumerable FollowSpansAsync(
}
// Use compact JSON for NDJSON streaming (no indentation)
- yield return TelemetryExportService.ConvertSpanToJson(span, logs: null, indent: false);
+ yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false);
}
}
diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj
index 79511ee4e99..fa1447d8f10 100644
--- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj
+++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj
@@ -300,6 +300,7 @@
+
@@ -309,6 +310,7 @@
+
diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs
index fe541a121bb..96a92c20eec 100644
--- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs
+++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs
@@ -341,7 +341,7 @@ private void OnToggleExpand(ResourceDataRow resourceRow)
private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName);
- private string GetOtlpResourceName(OtlpResource resource) => OtlpResource.GetResourceName(resource, TelemetryRepository.GetResources());
+ private string GetOtlpResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, TelemetryRepository.GetResources());
private string GetDataTypeDisplayName(AspireDataType dataType) => dataType switch
{
diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
index 151b4ac1d62..d287a8f7c92 100644
--- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs
@@ -414,7 +414,7 @@ private async Task HandleAfterFilterBindAsync()
await ClearSelectedLogEntryAsync();
}
- private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app.Resource, _resources);
+ private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources);
private string GetRowClass(OtlpLogEntry entry)
{
diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
index c9dafd543c6..6e48f33524e 100644
--- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs
@@ -248,8 +248,8 @@ private async Task HandleAfterFilterBindAsync()
await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
}
- private string GetResourceName(OtlpResource app) => OtlpResource.GetResourceName(app, _resources);
- private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app, _resources);
+ private string GetResourceName(OtlpResource app) => OtlpHelpers.GetResourceName(app, _resources);
+ private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources);
private static string GetRowClass(OtlpTrace entry)
{
diff --git a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs
index 5e81a195940..b38155710be 100644
--- a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs
+++ b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs
@@ -115,15 +115,15 @@ public async Task ListConsoleLogsAsync(
var entries = logEntries.GetEntries().ToList();
var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber;
- var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary(
+ var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary(
entries,
totalLogsCount,
AIHelpers.ConsoleLogsLimit,
"console log",
"console logs",
SharedAIHelpers.SerializeLogEntry,
- logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry));
- var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList());
+ SharedAIHelpers.EstimateTokenCount);
+ var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems);
var consoleLogsData = $"""
{limitMessage}
diff --git a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs
index f3cef5d4b83..b94a37944c4 100644
--- a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs
+++ b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs
@@ -9,6 +9,7 @@
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
+using Aspire.Shared.ConsoleLogs;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
@@ -20,7 +21,7 @@ namespace Aspire.Dashboard.Mcp;
internal sealed class AspireTelemetryMcpTools
{
private readonly TelemetryRepository _telemetryRepository;
- private readonly IEnumerable _outgoingPeerResolvers;
+ private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers;
private readonly IOptionsMonitor _dashboardOptions;
private readonly IDashboardClient _dashboardClient;
private readonly ILogger _logger;
@@ -32,7 +33,7 @@ public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository,
ILogger logger)
{
_telemetryRepository = telemetryRepository;
- _outgoingPeerResolvers = outgoingPeerResolvers;
+ _outgoingPeerResolvers = outgoingPeerResolvers.ToArray();
_dashboardOptions = dashboardOptions;
_dashboardClient = dashboardClient;
_logger = logger;
@@ -70,13 +71,13 @@ public string ListStructuredLogs(
}
}
+ var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs);
var resources = _telemetryRepository.GetResources();
-
var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(
- logs,
+ otlpData,
_dashboardOptions.CurrentValue,
includeDashboardUrl: true,
- getResourceName: r => OtlpResource.GetResourceName(r, resources));
+ getResourceName: r => OtlpHelpers.GetResourceName(r, resources));
var response = $"""
Always format log_id in the response as code like this: `log_id: 123`.
@@ -123,12 +124,10 @@ public string ListTraces(
var resources = _telemetryRepository.GetResources();
- var (tracesData, limitMessage) = AIHelpers.GetTracesJson(
- traces,
- _outgoingPeerResolvers,
- _dashboardOptions.CurrentValue,
- includeDashboardUrl: true,
- getResourceName: r => OtlpResource.GetResourceName(r, resources));
+ var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson(
+ TelemetryExportService.ConvertTracesToOtlpJson(traces, _outgoingPeerResolvers).ResourceSpans,
+ getResourceName: r => OtlpHelpers.GetResourceName(r, resources),
+ AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue));
var response = $"""
{limitMessage}
@@ -165,13 +164,13 @@ public string ListTraceStructuredLogs(
Filters = [traceIdFilter]
});
+ var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items);
var resources = _telemetryRepository.GetResources();
-
var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(
- logs.Items,
+ otlpData,
_dashboardOptions.CurrentValue,
includeDashboardUrl: true,
- getResourceName: r => OtlpResource.GetResourceName(r, resources));
+ getResourceName: r => OtlpHelpers.GetResourceName(r, resources));
var response = $"""
{limitMessage}
diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs
index 47cc68b30a8..434b10965ba 100644
--- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs
+++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs
@@ -3,12 +3,13 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Json;
+using System.Text.Json.Nodes;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Otlp.Model;
+using Aspire.Dashboard.Otlp.Model.Serialization;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
@@ -22,16 +23,16 @@ internal static class AIHelpers
{
public const int TracesLimit = 200;
public const int StructuredLogsLimit = 200;
- public const int ConsoleLogsLimit = SharedAIHelpers.ConsoleLogsLimit;
+ public const int ConsoleLogsLimit = 500;
// There is currently a 64K token limit in VS.
// Limit the result from individual token calls to a smaller number so multiple results can live inside the context.
- public const int MaximumListTokenLength = SharedAIHelpers.MaximumListTokenLength;
+ public const int MaximumListTokenLength = 8192;
// This value is chosen to balance:
// - Providing enough data to the model for it to provide accurate answers.
// - Providing too much data and exceeding length limits.
- public const int MaximumStringLength = SharedAIHelpers.MaximumStringLength;
+ public const int MaximumStringLength = 2048;
// Always pass English translations to AI
private static readonly IStringLocalizer s_columnsLoc = new InvariantStringLocalizer();
@@ -41,177 +42,101 @@ internal static class AIHelpers
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions
{
- PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
- internal static object GetTraceDto(OtlpTrace trace, IEnumerable outgoingPeerResolvers, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null)
+ internal static string GetResponseGraphJson(List resources, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false)
{
- var spanData = trace.Spans.Select(s => new
- {
- span_id = OtlpHelpers.ToShortenedId(s.SpanId),
- parent_span_id = s.ParentSpanId is { } id ? OtlpHelpers.ToShortenedId(id) : null,
- kind = s.Kind.ToString(),
- name = context.AddValue(s.Name, id => $@"Duplicate of ""name"" for span {OtlpHelpers.ToShortenedId(id)}", s.SpanId),
- status = s.Status != OtlpSpanStatusCode.Unset ? s.Status.ToString() : null,
- status_message = context.AddValue(s.StatusMessage, id => $@"Duplicate of ""status_message"" for span {OtlpHelpers.ToShortenedId(id)}", s.SpanId),
- source = getResourceName?.Invoke(s.Source.Resource) ?? s.Source.ResourceKey.GetCompositeName(),
- destination = GetDestination(s, outgoingPeerResolvers),
- duration_ms = ConvertToMilliseconds(s.Duration),
- attributes = s.Attributes
- .ToDictionary(a => a.Key, a => context.AddValue(MapOtelAttributeValue(a), id => $@"Duplicate of attribute ""{id.Key}"" for span {OtlpHelpers.ToShortenedId(id.SpanId)}", (s.SpanId, a.Key))),
- links = s.Links.Select(l => new { trace_id = OtlpHelpers.ToShortenedId(l.TraceId), span_id = OtlpHelpers.ToShortenedId(l.SpanId) }).ToList(),
- back_links = s.BackLinks.Select(l => new { source_trace_id = OtlpHelpers.ToShortenedId(l.SourceTraceId), source_span_id = OtlpHelpers.ToShortenedId(l.SourceSpanId) }).ToList()
- }).ToList();
-
- var traceId = OtlpHelpers.ToShortenedId(trace.TraceId);
- var traceData = new Dictionary
- {
- ["trace_id"] = traceId,
- ["duration_ms"] = ConvertToMilliseconds(trace.Duration),
- ["title"] = trace.RootOrFirstSpan.Name,
- ["spans"] = spanData,
- ["has_error"] = trace.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error),
- ["timestamp"] = trace.TimeStamp,
- };
-
- if (includeDashboardUrl)
- {
- traceData["dashboard_link"] = GetDashboardLink(options, DashboardUrls.TraceDetailUrl(traceId), traceId);
- }
-
- return traceData;
+ var dashboardBaseUrl = includeDashboardUrl ? GetDashboardUrl(options) : null;
+ return GetResponseGraphJson(resources, dashboardBaseUrl, includeDashboardUrl, getResourceName, includeEnvironmentVariables);
}
- private static string MapOtelAttributeValue(KeyValuePair attribute)
+ internal static string GetResponseGraphJson(List resources, string? dashboardBaseUrl, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false)
{
- switch (attribute.Key)
+ var dataArray = new JsonArray();
+
+ foreach (var resource in resources.Where(resource => !resource.IsResourceHidden(false)))
{
- case "http.response.status_code":
+ var resourceName = getResourceName?.Invoke(resource) ?? resource.Name;
+
+ var endpointUrlsArray = new JsonArray();
+ foreach (var u in resource.Urls.Where(u => !u.IsInternal))
+ {
+ var urlObj = new JsonObject
{
- if (int.TryParse(attribute.Value, CultureInfo.InvariantCulture, out var value))
- {
- return OtelAttributeHelpers.GetHttpStatusName(value);
- }
- goto default;
- }
- case "rpc.grpc.status_code":
+ ["name"] = u.EndpointName,
+ ["url"] = u.Url.ToString()
+ };
+ if (!string.IsNullOrEmpty(u.DisplayProperties.DisplayName))
{
- if (int.TryParse(attribute.Value, CultureInfo.InvariantCulture, out var value))
- {
- return OtelAttributeHelpers.GetGrpcStatusName(value);
- }
- goto default;
+ urlObj["display_name"] = u.DisplayProperties.DisplayName;
}
- default:
- return attribute.Value;
- }
- }
-
- private static int ConvertToMilliseconds(TimeSpan duration)
- {
- return (int)Math.Round(duration.TotalMilliseconds, 0, MidpointRounding.AwayFromZero);
- }
-
- public static (string json, string limitMessage) GetTracesJson(List traces, IEnumerable outgoingPeerResolvers, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null)
- {
- var promptContext = new PromptContext();
- var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary(
- traces,
- TracesLimit,
- "trace",
- "traces",
- trace => GetTraceDto(trace, outgoingPeerResolvers, promptContext, options, includeDashboardUrl, getResourceName),
- EstimateSerializedJsonTokenSize);
- var tracesData = SerializeJson(trimmedItems);
-
- return (tracesData, limitMessage);
- }
-
- internal static string GetTraceJson(OtlpTrace trace, IEnumerable outgoingPeerResolvers, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null)
- {
- var dto = GetTraceDto(trace, outgoingPeerResolvers, context, options, includeDashboardUrl, getResourceName);
-
- var json = SerializeJson(dto);
- return json;
- }
-
- private static string? GetDestination(OtlpSpan s, IEnumerable outgoingPeerResolvers)
- {
- return ResolveUninstrumentedPeerName(s, outgoingPeerResolvers);
- }
+ endpointUrlsArray.Add(urlObj);
+ }
- private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IEnumerable outgoingPeerResolvers)
- {
- // Attempt to resolve uninstrumented peer to a friendly name from the span.
- foreach (var resolver in outgoingPeerResolvers)
- {
- if (resolver.TryResolvePeer(span.Attributes, out var name, out _))
+ var healthReportsArray = new JsonArray();
+ foreach (var report in resource.HealthReports)
{
- return name;
+ healthReportsArray.Add(new JsonObject
+ {
+ ["name"] = report.Name,
+ ["health_status"] = GetReportHealthStatus(resource, report),
+ ["exception"] = report.ExceptionText
+ });
}
- }
- // Fallback to the peer address.
- return span.Attributes.GetPeerAddress();
- }
+ var healthObj = new JsonObject
+ {
+ ["resource_health_status"] = GetResourceHealthStatus(resource),
+ ["health_reports"] = healthReportsArray
+ };
- internal static string GetResponseGraphJson(List resources, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false)
- {
- var data = resources.Where(resource => !resource.IsResourceHidden(false)).Select(resource =>
- {
- var resourceName = getResourceName?.Invoke(resource) ?? resource.Name;
+ var commandsArray = new JsonArray();
+ foreach (var cmd in resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled))
+ {
+ commandsArray.Add(new JsonObject
+ {
+ ["name"] = cmd.Name,
+ ["description"] = cmd.GetDisplayDescription()
+ });
+ }
- var resourceObj = new Dictionary
+ var resourceObj = new JsonObject
{
["resource_name"] = resourceName,
["type"] = resource.ResourceType,
["state"] = resource.State,
["state_description"] = ResourceStateViewModel.GetResourceStateTooltip(resource, s_columnsLoc),
- ["relationships"] = GetResourceRelationships(resources, resource, getResourceName),
- ["endpoint_urls"] = resource.Urls.Where(u => !u.IsInternal).Select(u => new
- {
- name = u.EndpointName,
- url = u.Url,
- display_name = !string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? u.DisplayProperties.DisplayName : null,
- }).ToList(),
- ["health"] = new
- {
- resource_health_status = GetResourceHealthStatus(resource),
- health_reports = resource.HealthReports.Select(report => new
- {
- name = report.Name,
- health_status = GetReportHealthStatus(resource, report),
- exception = report.ExceptionText
- }).ToList()
- },
+ ["relationships"] = GetResourceRelationshipsJson(resources, resource, getResourceName),
+ ["endpoint_urls"] = endpointUrlsArray,
+ ["health"] = healthObj,
["source"] = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value,
- ["commands"] = resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled).Select(cmd => new
- {
- name = cmd.Name,
- description = cmd.GetDisplayDescription()
- }).ToList()
+ ["commands"] = commandsArray
};
- if (includeDashboardUrl)
+ if (includeDashboardUrl && dashboardBaseUrl != null)
{
- resourceObj["dashboard_link"] = GetDashboardLink(options, DashboardUrls.ResourcesUrl(resource: resource.Name), resourceName);
+ resourceObj["dashboard_link"] = SharedAIHelpers.GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.ResourcesUrl(resource: resource.Name), resourceName);
}
if (includeEnvironmentVariables)
{
- resourceObj["environment_variables"] = resource.Environment.Where(e => e.FromSpec).Select(e => e.Name).ToList();
+ var envVarsArray = new JsonArray();
+ foreach (var e in resource.Environment.Where(e => e.FromSpec))
+ {
+ envVarsArray.Add(JsonValue.Create(e.Name));
+ }
+ resourceObj["environment_variables"] = envVarsArray;
}
- return resourceObj;
- }).ToList();
+ dataArray.Add(resourceObj);
+ }
- var resourceGraphData = SerializeJson(data);
- return resourceGraphData;
+ return dataArray.ToJsonString(s_jsonSerializerOptions);
- static List