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
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@
<Compile Include="$(SharedDir)DashboardUrls.cs" Link="Utils\DashboardUrls.cs" />
<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\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" />
<Compile Include="$(SharedDir)Otlp\Serialization\OtlpTraceJson.cs" Link="Otlp\OtlpTraceJson.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
131 changes: 77 additions & 54 deletions src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using StreamJsonRpc;
Expand All @@ -20,50 +21,44 @@ internal sealed class AppHostAuxiliaryBackchannel : IDisposable
private readonly ILogger? _logger;
private JsonRpc? _rpc;
private bool _disposed;
private ImmutableHashSet<string> _capabilities = ImmutableHashSet<string>.Empty;
private readonly ImmutableHashSet<string> _capabilities;

/// <summary>
/// Initializes a new instance of the <see cref="AppHostAuxiliaryBackchannel"/> class
/// for an existing connection.
/// Private constructor - use factory methods to create instances.
/// </summary>
/// <param name="hash">The hash identifier for this AppHost instance.</param>
/// <param name="socketPath">The socket path for this connection.</param>
/// <param name="rpc">The JSON-RPC proxy for communicating with the AppHost.</param>
/// <param name="mcpInfo">The MCP connection information for the Dashboard.</param>
/// <param name="appHostInfo">The AppHost information.</param>
/// <param name="isInScope">Whether this AppHost is within the scope of the MCP server's working directory.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
public AppHostAuxiliaryBackchannel(
private AppHostAuxiliaryBackchannel(
string hash,
string socketPath,
JsonRpc rpc,
DashboardMcpConnectionInfo? mcpInfo,
AppHostInformation? appHostInfo,
bool isInScope,
ILogger? logger = null)
ImmutableHashSet<string> capabilities,
ILogger? logger)
{
Hash = hash;
SocketPath = socketPath;
_rpc = rpc;
McpInfo = mcpInfo;
AppHostInfo = appHostInfo;
IsInScope = isInScope;
_capabilities = capabilities;
ConnectedAt = DateTimeOffset.UtcNow;
_logger = logger;
}

/// <summary>
/// Initializes a new instance of the <see cref="AppHostAuxiliaryBackchannel"/> class
/// for a new connection that needs to be established.
/// Internal constructor for testing purposes.
/// </summary>
/// <param name="socketPath">The socket path to connect to.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
private AppHostAuxiliaryBackchannel(string socketPath, ILogger? logger = null)
internal AppHostAuxiliaryBackchannel(
string hash,
string socketPath,
JsonRpc rpc,
DashboardMcpConnectionInfo? mcpInfo,
AppHostInformation? appHostInfo,
bool isInScope)
: this(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope, ImmutableHashSet<string>.Empty, null)
{
SocketPath = socketPath;
Hash = string.Empty;
ConnectedAt = DateTimeOffset.UtcNow;
_logger = logger;
}

/// <summary>
Expand All @@ -89,7 +84,7 @@ private AppHostAuxiliaryBackchannel(string socketPath, ILogger? logger = null)
/// <summary>
/// Gets a value indicating whether this AppHost is within the scope of the MCP server's working directory.
/// </summary>
public bool IsInScope { get; private set; }
public bool IsInScope { get; internal set; }

/// <summary>
/// Gets the timestamp when this connection was established.
Expand Down Expand Up @@ -128,62 +123,88 @@ private JsonRpc EnsureConnected()
/// <param name="logger">Optional logger for diagnostic messages.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A connected AppHostAuxiliaryBackchannel instance.</returns>
public static async Task<AppHostAuxiliaryBackchannel> ConnectAsync(
public static Task<AppHostAuxiliaryBackchannel> ConnectAsync(
string socketPath,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var backchannel = new AppHostAuxiliaryBackchannel(socketPath, logger);
await backchannel.ConnectInternalAsync(cancellationToken).ConfigureAwait(false);
return backchannel;
var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath) ?? string.Empty;
return CreateFromSocketAsync(hash, socketPath, isInScope: true, socket: null, logger, cancellationToken);
}

private async Task ConnectInternalAsync(CancellationToken cancellationToken)
/// <summary>
/// Creates an AppHostAuxiliaryBackchannel by connecting to the specified socket path,
/// or using an already-connected socket if provided.
/// This is the single path for all connection creation, ensuring capabilities are always fetched.
/// </summary>
/// <param name="hash">The AppHost hash identifier.</param>
/// <param name="socketPath">The socket path.</param>
/// <param name="isInScope">Whether this AppHost is within the scope of the working directory.</param>
/// <param name="socket">Optional already-connected socket. If null, a new connection will be established.</param>
/// <param name="logger">Optional logger.</param>
/// <param name="cancellationToken">Cancellation token (only used when socket is null).</param>
/// <returns>A connected AppHostAuxiliaryBackchannel instance.</returns>
internal static async Task<AppHostAuxiliaryBackchannel> CreateFromSocketAsync(
string hash,
string socketPath,
bool isInScope,
Socket? socket = null,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
_logger?.LogDebug("Connecting to auxiliary backchannel at {SocketPath}", SocketPath);

// Connect to the Unix socket
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
var endpoint = new UnixDomainSocketEndPoint(SocketPath);
// Connect if no socket provided
if (socket is null)
{
logger?.LogDebug("Connecting to auxiliary backchannel at {SocketPath}", socketPath);

await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
var endpoint = new UnixDomainSocketEndPoint(socketPath);
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
}

// Create JSON-RPC connection with proper formatter
var stream = new NetworkStream(socket, ownsSocket: true);
_rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
_rpc.StartListening();
var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
rpc.StartListening();

_logger?.LogDebug("Connected to auxiliary backchannel at {SocketPath}", SocketPath);
logger?.LogDebug("Connected to auxiliary backchannel at {SocketPath}", socketPath);

// Fetch capabilities to determine API version support
await FetchCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
// Fetch all connection info
var appHostInfo = await rpc.InvokeAsync<AppHostInformation?>("GetAppHostInformationAsync").ConfigureAwait(false);
var mcpInfo = await rpc.InvokeAsync<DashboardMcpConnectionInfo?>("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false);
var capabilities = await FetchCapabilitiesAsync(rpc, logger).ConfigureAwait(false);

// Get the AppHost information
AppHostInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false);
var capabilitiesSet = capabilities?.ToImmutableHashSet() ?? ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1);

return new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope, capabilitiesSet, logger);
}

/// <summary>
/// Fetches the capabilities from the AppHost to determine supported API versions.
/// Fetches capabilities from an AppHost via RPC.
/// </summary>
private async Task FetchCapabilitiesAsync(CancellationToken cancellationToken)
/// <param name="rpc">The JSON-RPC connection.</param>
/// <param name="logger">Optional logger.</param>
/// <returns>The capabilities array, or null if not supported.</returns>
private static async Task<string[]?> FetchCapabilitiesAsync(JsonRpc rpc, ILogger? logger = null)
{
var rpc = EnsureConnected();

try
{
var response = await rpc.InvokeWithCancellationAsync<GetCapabilitiesResponse>(
"GetCapabilitiesAsync",
[null], // Pass null request
cancellationToken).ConfigureAwait(false);

_capabilities = response?.Capabilities?.ToImmutableHashSet() ?? ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1);
_logger?.LogDebug("AppHost capabilities: {Capabilities}", string.Join(", ", _capabilities));
var response = await rpc.InvokeAsync<GetCapabilitiesResponse?>("GetCapabilitiesAsync", [null]).ConfigureAwait(false);
var capabilities = response?.Capabilities;
logger?.LogDebug("AppHost capabilities: {Capabilities}", capabilities is not null ? string.Join(", ", capabilities) : "null");
return capabilities;
}
catch (RemoteMethodNotFoundException)
{
// Older AppHost without GetCapabilitiesAsync - assume v1 only
_capabilities = ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1);
_logger?.LogDebug("AppHost does not support GetCapabilitiesAsync, assuming v1 only");
logger?.LogDebug("AppHost does not support GetCapabilitiesAsync, assuming v1 only");
return null;
}
catch (Exception ex)
{
// Log any other exception
logger?.LogWarning(ex, "Failed to fetch capabilities from AppHost");
return null;
}
}

Expand Down Expand Up @@ -464,7 +485,7 @@ public async Task<CallToolResult> CallResourceMcpToolAsync(
{
if (!SupportsV2)
{
// Fall back to v1 and combine results
// Fall back to v1 - ApiBaseUrl and ApiToken are only available in v2
var mcpInfo = await GetDashboardMcpConnectionInfoAsync(cancellationToken).ConfigureAwait(false);
var urlsState = await GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);

Expand All @@ -482,6 +503,8 @@ public async Task<CallToolResult> CallResourceMcpToolAsync(
{
McpBaseUrl = mcpInfo?.EndpointUrl,
McpApiToken = mcpInfo?.ApiToken,
ApiBaseUrl = null, // Not available in v1
ApiToken = null, // Not available in v1
DashboardUrls = urls.ToArray(),
IsHealthy = urlsState?.DashboardHealthy ?? false
};
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ internal sealed class AppHostConnectionResult
}

/// <summary>
/// Helper for resolving connections to running AppHosts.
/// Used by commands that need to connect to a running AppHost (stop, resources, logs, etc.).
/// Discovers and resolves connections to running AppHosts when the socket path is not known.
/// Scans for running AppHosts and prompts the user to select one if multiple are found.
/// Used by CLI commands (stop, resources, logs, telemetry) that need to find a running AppHost.
/// For managing a specific instance when the socket path is known, use <see cref="Projects.RunningInstanceManager"/> instead.
/// </summary>
internal sealed class AppHostConnectionResolver(
IAuxiliaryBackchannelMonitor backchannelMonitor,
Expand Down
40 changes: 18 additions & 22 deletions src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;

namespace Aspire.Cli.Backchannel;

Expand Down Expand Up @@ -401,24 +400,19 @@ private async Task TryConnectToSocketAsync(string socketPath, ConcurrentBag<stri

try
{
// Create JSON-RPC connection with proper formatter
var stream = new NetworkStream(socket, ownsSocket: true);
var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
rpc.StartListening();

// Get the AppHost information
var appHostInfo = await rpc.InvokeAsync<AppHostInformation?>("GetAppHostInformationAsync").ConfigureAwait(false);

// Get the MCP connection info
var mcpInfo = await rpc.InvokeAsync<DashboardMcpConnectionInfo?>("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false);

// Determine if this AppHost is in scope of the MCP server's working directory
var isInScope = IsAppHostInScope(appHostInfo?.AppHostPath);
// We need to do a quick check before full connection to avoid unnecessary work
var isInScope = true; // Will be updated after we get appHostInfo

// Use the centralized factory to create the connection
// This ensures capabilities are always fetched
var connection = await AppHostAuxiliaryBackchannel.CreateFromSocketAsync(hash, socketPath, isInScope, socket, logger, cancellationToken).ConfigureAwait(false);

var connection = new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope, logger);
// Update isInScope based on actual appHostInfo now that we have it
connection.IsInScope = IsAppHostInScope(connection.AppHostInfo?.AppHostPath);

// Set up disconnect handler
rpc.Disconnected += (sender, args) =>
connection.Rpc!.Disconnected += (sender, args) =>
{
logger.LogInformation("Disconnected from AppHost at {SocketPath}: {Reason}", socketPath, args.Reason);
if (_connectionsByHash.TryGetValue(hash, out var connectionsForHash) &&
Expand Down Expand Up @@ -447,15 +441,17 @@ private async Task TryConnectToSocketAsync(string socketPath, ConcurrentBag<stri
"CLI PID: {CliPid}, " +
"Dashboard URL: {DashboardUrl}, " +
"Dashboard Token: {DashboardToken}, " +
"In Scope: {InScope}",
"In Scope: {InScope}, " +
"Supports V2: {SupportsV2}",
socketPath,
hash,
appHostInfo?.AppHostPath ?? "N/A",
appHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A",
appHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A",
mcpInfo?.EndpointUrl ?? "N/A",
mcpInfo?.ApiToken is not null ? "***" + mcpInfo.ApiToken[^4..] : "N/A",
isInScope);
connection.AppHostInfo?.AppHostPath ?? "N/A",
connection.AppHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A",
connection.AppHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A",
connection.McpInfo?.EndpointUrl ?? "N/A",
connection.McpInfo?.ApiToken is not null ? "***" + connection.McpInfo.ApiToken[^4..] : "N/A",
connection.IsInScope,
connection.SupportsV2);
}
else
{
Expand Down
44 changes: 29 additions & 15 deletions src/Aspire.Cli/Commands/ResourcesCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Aspire.Cli.Utils;
using Aspire.Shared.Model.Serialization;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Commands;

Expand Down Expand Up @@ -235,20 +236,14 @@ private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots)
// Get display names for all resources
var orderedItems = snapshots.Select(s => (Snapshot: s, DisplayName: ResourceSnapshotMapper.GetResourceName(s, snapshots)))
.OrderBy(x => x.DisplayName)
.ToList();;
.ToList();

// Calculate column widths based on data
var nameWidth = Math.Max("NAME".Length, orderedItems.Max(i => i.DisplayName.Length));
var typeWidth = Math.Max("TYPE".Length, orderedItems.Max(i => i.Snapshot.ResourceType?.Length ?? 0));
var stateWidth = Math.Max("STATE".Length, orderedItems.Max(i => i.Snapshot.State?.Length ?? "Unknown".Length));
var healthWidth = Math.Max("HEALTH".Length, orderedItems.Max(i => i.Snapshot.HealthStatus?.Length ?? 1));

var totalWidth = nameWidth + typeWidth + stateWidth + healthWidth + 12 + 20; // 12 for spacing, 20 for endpoints min

// Header
_interactionService.DisplayPlainText("");
_interactionService.DisplayPlainText($"{"NAME".PadRight(nameWidth)} {"TYPE".PadRight(typeWidth)} {"STATE".PadRight(stateWidth)} {"HEALTH".PadRight(healthWidth)} {"ENDPOINTS"}");
_interactionService.DisplayPlainText(new string('-', totalWidth));
var table = new Table();
table.AddColumn("Name");
table.AddColumn("Type");
table.AddColumn("State");
table.AddColumn("Health");
table.AddColumn("Endpoints");

foreach (var (snapshot, displayName) in orderedItems)
{
Expand All @@ -260,10 +255,29 @@ private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots)
var state = snapshot.State ?? "Unknown";
var health = snapshot.HealthStatus ?? "-";

_interactionService.DisplayPlainText($"{displayName.PadRight(nameWidth)} {type.PadRight(typeWidth)} {state.PadRight(stateWidth)} {health.PadRight(healthWidth)} {endpoints}");
// Color the state based on value
var stateText = state.ToUpperInvariant() switch
{
"RUNNING" => $"[green]{state}[/]",
"FINISHED" or "EXITED" => $"[grey]{state}[/]",
"FAILEDTOSTART" or "FAILED" => $"[red]{state}[/]",
"STARTING" or "WAITING" => $"[yellow]{state}[/]",
_ => state
};

// Color the health based on value
var healthText = health.ToUpperInvariant() switch
{
"HEALTHY" => $"[green]{health}[/]",
"UNHEALTHY" => $"[red]{health}[/]",
"DEGRADED" => $"[yellow]{health}[/]",
_ => health
};

table.AddRow(displayName, type, stateText, healthText, endpoints);
}

_interactionService.DisplayPlainText("");
AnsiConsole.Write(table);
}

private void DisplayResourceUpdate(ResourceSnapshot snapshot, IDictionary<string, ResourceSnapshot> allResources)
Expand Down
Loading
Loading