Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
<Compile Include="$(SharedDir)UserSecrets\UserSecretsPathHelper.cs" Link="Utils\UserSecretsPathHelper.cs" />
<Compile Include="$(SharedDir)UserSecrets\IsolatedUserSecretsHelper.cs" Link="Utils\IsolatedUserSecretsHelper.cs" />
<Compile Include="$(SharedDir)Otlp\OtlpHelpers.cs" Link="Otlp\OtlpHelpers.cs" />
<Compile Include="$(SharedDir)Otlp\IOtlpResource.cs" Link="Otlp\IOtlpResource.cs" />
<Compile Include="$(SharedDir)Otlp\Serialization\OtlpCommonJson.cs" Link="Otlp\OtlpCommonJson.cs" />
<Compile Include="$(SharedDir)Otlp\Serialization\OtlpResourceJson.cs" Link="Otlp\OtlpResourceJson.cs" />
<Compile Include="$(SharedDir)Otlp\Serialization\OtlpLogsJson.cs" Link="Otlp\OtlpLogsJson.cs" />
Expand All @@ -84,6 +85,7 @@
<Compile Include="$(SharedDir)ConsoleLogs\LogEntry.cs" Link="ConsoleLogs\LogEntry.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogParser.cs" Link="ConsoleLogs\LogParser.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogPauseViewModel.cs" Link="ConsoleLogs\LogPauseViewModel.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\PromptContext.cs" Link="ConsoleLogs\PromptContext.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\SharedAIHelpers.cs" Link="ConsoleLogs\SharedAIHelpers.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\TimestampParser.cs" Link="ConsoleLogs\TimestampParser.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\UrlParser.cs" Link="ConsoleLogs\UrlParser.cs" />
Expand Down
110 changes: 12 additions & 98 deletions src/Aspire.Cli/Commands/AgentMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +17,6 @@
using Aspire.Shared.Mcp;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

Expand Down Expand Up @@ -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)
{
Expand All @@ -69,9 +68,9 @@ public AgentMcpCommand(
[KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ListResourcesTool>()),
[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ListConsoleLogsTool>()),
[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ExecuteResourceCommandTool>()),
[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(),
[KnownMcpTools.ListTraces] = new ListTracesTool(),
[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(),
[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListStructuredLogsTool>()),
[KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListTracesTool>()),
[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListTraceStructuredLogsTool>()),
[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext),
[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext),
[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor),
Expand Down Expand Up @@ -176,33 +175,17 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT

_logger.LogDebug("MCP CallTool request received for tool: {ToolName}", toolName);

// Known tools?
if (KnownTools.TryGetValue(toolName, out var tool))
{
// Handle tools that don't need an MCP connection to the AppHost
if (KnownMcpTools.IsLocalTool(toolName))
{
var args = request.Params?.Arguments;
var context = new CallToolContext
{
Notifier = new McpServerNotifier(_server!),
McpClient = null,
Arguments = args,
ProgressToken = request.Params?.ProgressToken
};
return await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false);
}

if (KnownMcpTools.IsDashboardTool(toolName))
var args = request.Params?.Arguments;
var context = new CallToolContext
{
var args = request.Params?.Arguments;
return await CallDashboardToolAsync(toolName, tool, request.Params?.ProgressToken, args, cancellationToken).ConfigureAwait(false);
}

// If a tool is registered in _tools, it must be classified as either local or dashboard-backed.
throw new McpProtocolException(
$"Tool '{toolName}' is not classified as local or dashboard-backed.",
McpErrorCode.InternalError);
Notifier = new McpServerNotifier(_server!),
McpClient = null,
Arguments = args,
ProgressToken = request.Params?.ProgressToken
};
return await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false);
}

var toolsRefreshed = false;
Expand Down Expand Up @@ -255,75 +238,6 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT
throw new McpProtocolException($"Unknown tool: '{toolName}'", McpErrorCode.MethodNotFound);
}

private async ValueTask<CallToolResult> CallDashboardToolAsync(
string toolName,
CliMcpTool tool,
ProgressToken? progressToken,
IReadOnlyDictionary<string, JsonElement>? 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<string, string>
{
["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;
}
}

/// <summary>
/// Gets the appropriate AppHost connection based on the selection logic.
/// </summary>
Expand Down
59 changes: 28 additions & 31 deletions src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -165,61 +165,58 @@ public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiT
return client;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="client">The HTTP client configured for Dashboard API access.</param>
/// <param name="baseUrl">The Dashboard API base URL.</param>
/// <param name="resourceName">The resource name to resolve (can be base name or full instance name).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of resolved resource display names to query, or null if resource not found.</returns>
public static async Task<List<string>?> ResolveResourceNamesAsync(
HttpClient client,
string baseUrl,
public static bool TryResolveResourceNames(
string? resourceName,
CancellationToken cancellationToken)
IList<ResourceInfoJson> resources,
out List<string>? 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<ResourceInfoJson[]> 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!;
}

/// <summary>
Expand Down
7 changes: 3 additions & 4 deletions src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,10 @@ private async Task<int> 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;
Expand Down
7 changes: 3 additions & 4 deletions src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,10 @@ private async Task<int> 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;
Expand Down
7 changes: 3 additions & 4 deletions src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,10 @@ private async Task<int> 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;
Expand Down
18 changes: 0 additions & 18 deletions src/Aspire.Cli/Mcp/KnownMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
3 changes: 0 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/DoctorTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ public override JsonElement GetInputSchema()

public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
// This tool does not use the MCP client or arguments
_ = context;

try
{
// Run all environment checks
Expand Down
1 change: 0 additions & 1 deletion src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ public override JsonElement GetInputSchema()
public override async ValueTask<CallToolResult> 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))
Expand Down
4 changes: 1 addition & 3 deletions src/Aspire.Cli/Mcp/Tools/GetDocTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ public override JsonElement GetInputSchema()
""").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(
CallToolContext context,
CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
var arguments = context.Arguments;

Expand Down
3 changes: 0 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ public override JsonElement GetInputSchema()

public override async ValueTask<CallToolResult> 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);

Expand Down
Loading
Loading