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
4 changes: 4 additions & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
<Compile Include="$(SharedDir)Mcp\McpIconHelper.cs" Link="Mcp\McpIconHelper.cs" />
<Compile Include="$(SharedDir)X509Certificate2Extensions.cs" Link="Utils\X509Certificate2Extensions.cs" />
<Compile Include="$(SharedDir)Model\Serialization\ResourceJson.cs" Link="Model\Serialization\ResourceJson.cs" />
<Compile Include="$(SharedDir)Model\KnownProperties.cs" Link="Model\KnownProperties.cs" />
<Compile Include="$(SharedDir)Model\KnownResourceTypes.cs" Link="Model\KnownResourceTypes.cs" />
<Compile Include="$(SharedDir)Model\ResourceSourceViewModel.cs" Link="Model\ResourceSourceViewModel.cs" />
<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" />
</ItemGroup>
Expand Down
97 changes: 97 additions & 0 deletions src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Logging;
using ModelContextProtocol;

namespace Aspire.Cli.Backchannel;

/// <summary>
/// Provides helper methods for working with AppHost connections.
/// </summary>
internal static class AppHostConnectionHelper
{
/// <summary>
/// Gets the appropriate AppHost connection based on the selection logic:
/// 1. If a specific AppHost is selected via select_apphost, use that
/// 2. Otherwise, look for in-scope connections (AppHosts within the working directory)
/// 3. If exactly one in-scope connection exists, use it
/// 4. If multiple in-scope connections exist, throw an error listing them
/// 5. If no in-scope connections exist, fall back to the first available connection
/// </summary>
/// <param name="auxiliaryBackchannelMonitor">The backchannel monitor to get connections from.</param>
/// <param name="logger">Logger for debug output.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The selected connection, or null if none available.</returns>
public static async Task<AppHostAuxiliaryBackchannel?> GetSelectedConnectionAsync(
IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
ILogger logger,
CancellationToken cancellationToken = default)
{
var connections = auxiliaryBackchannelMonitor.Connections.ToList();

if (connections.Count == 0)
{
await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
connections = auxiliaryBackchannelMonitor.Connections.ToList();
if (connections.Count == 0)
{
return null;
}
}

// Check if a specific AppHost was selected
var selectedPath = auxiliaryBackchannelMonitor.SelectedAppHostPath;
if (!string.IsNullOrEmpty(selectedPath))
{
var selectedConnection = connections.FirstOrDefault(c =>
c.AppHostInfo?.AppHostPath != null &&
string.Equals(c.AppHostInfo.AppHostPath, selectedPath, StringComparison.OrdinalIgnoreCase));

if (selectedConnection != null)
{
logger.LogDebug("Using explicitly selected AppHost: {AppHostPath}", selectedPath);
return selectedConnection;
}

logger.LogWarning("Selected AppHost at '{SelectedPath}' is no longer running, falling back to selection logic", selectedPath);
// Clear the selection since the AppHost is no longer available
auxiliaryBackchannelMonitor.SelectedAppHostPath = null;
}

// Get in-scope connections
var inScopeConnections = connections.Where(c => c.IsInScope).ToList();

if (inScopeConnections.Count == 1)
{
logger.LogDebug("Using single in-scope AppHost: {AppHostPath}", inScopeConnections[0].AppHostInfo?.AppHostPath ?? "N/A");
return inScopeConnections[0];
}

if (inScopeConnections.Count > 1)
{
var paths = inScopeConnections
.Where(c => c.AppHostInfo?.AppHostPath != null)
.Select(c => c.AppHostInfo!.AppHostPath)
.ToList();

var pathsList = string.Join("\n", paths.Select(p => $" - {p}"));

throw new McpProtocolException(
$"Multiple Aspire AppHosts are running in the scope of the MCP server's working directory. " +
$"Use the 'select_apphost' tool to specify which AppHost to use.\n\nRunning AppHosts:\n{pathsList}",
McpErrorCode.InternalError);
}

var fallback = connections
.OrderBy(c => c.AppHostInfo?.AppHostPath ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(c => c.AppHostInfo?.ProcessId ?? int.MaxValue)
.FirstOrDefault();

logger.LogDebug(
"No in-scope AppHosts found. Falling back to first available AppHost: {AppHostPath}",
fallback?.AppHostInfo?.AppHostPath ?? "N/A");

return fallback;
}
}
188 changes: 188 additions & 0 deletions src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Dashboard.Utils;
using Aspire.Shared.Model;
using Aspire.Shared.Model.Serialization;

namespace Aspire.Cli.Backchannel;

/// <summary>
/// Maps <see cref="ResourceSnapshot"/> to <see cref="ResourceJson"/> for serialization.
/// </summary>
internal static class ResourceSnapshotMapper
{
/// <summary>
/// Maps a list of <see cref="ResourceSnapshot"/> to a list of <see cref="ResourceJson"/>.
/// </summary>
/// <param name="snapshots">The resource snapshots to map.</param>
/// <param name="dashboardBaseUrl">Optional base URL of the Aspire Dashboard for generating resource URLs.</param>
public static List<ResourceJson> MapToResourceJsonList(IEnumerable<ResourceSnapshot> snapshots, string? dashboardBaseUrl = null)
{
var snapshotList = snapshots.ToList();
return snapshotList.Select(s => MapToResourceJson(s, snapshotList, dashboardBaseUrl)).ToList();
}

/// <summary>
/// Maps a <see cref="ResourceSnapshot"/> to <see cref="ResourceJson"/>.
/// </summary>
/// <param name="snapshot">The resource snapshot to map.</param>
/// <param name="allSnapshots">All resource snapshots for resolving relationships.</param>
/// <param name="dashboardBaseUrl">Optional base URL of the Aspire Dashboard for generating resource URLs.</param>
public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> allSnapshots, string? dashboardBaseUrl = null)
{
var urls = snapshot.Urls
.Select(u => new ResourceUrlJson
{
Name = u.Name,
DisplayName = u.DisplayProperties?.DisplayName,
Url = u.Url,
IsInternal = u.IsInternal
})
.ToArray();

var volumes = snapshot.Volumes
.Select(v => new ResourceVolumeJson
{
Source = v.Source,
Target = v.Target,
MountType = v.MountType,
IsReadOnly = v.IsReadOnly
})
.ToArray();

var healthReports = snapshot.HealthReports
.Select(h => new ResourceHealthReportJson
{
Name = h.Name,
Status = h.Status,
Description = h.Description,
ExceptionMessage = h.ExceptionText
})
.ToArray();

var environment = snapshot.EnvironmentVariables
.Where(e => e.IsFromSpec)
.Select(e => new ResourceEnvironmentVariableJson
{
Name = e.Name,
Value = e.Value
})
.ToArray();

var properties = snapshot.Properties
.Select(p => new ResourcePropertyJson
{
Name = p.Key,
Value = p.Value
})
.ToArray();

// Build relationships by matching DisplayName
var relationships = new List<ResourceRelationshipJson>();
foreach (var relationship in snapshot.Relationships)
{
var matches = allSnapshots
.Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName))
.ToList();

foreach (var match in matches)
{
relationships.Add(new ResourceRelationshipJson
{
Type = relationship.Type,
ResourceName = match.Name
});
}
}

// Only include enabled commands
var commands = snapshot.Commands
.Where(c => string.Equals(c.State, "Enabled", StringComparison.OrdinalIgnoreCase))
.Select(c => new ResourceCommandJson
{
Name = c.Name,
Description = c.Description
})
.ToArray();

// Get source information using the shared ResourceSourceViewModel
var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties);

// Generate dashboard URL for this resource if a base URL is provided
string? dashboardUrl = null;
if (!string.IsNullOrEmpty(dashboardBaseUrl))
{
var resourcePath = DashboardUrls.ResourcesUrl(snapshot.Name);
dashboardUrl = DashboardUrls.CombineUrl(dashboardBaseUrl, resourcePath);
}

return new ResourceJson
{
Name = snapshot.Name,
DisplayName = snapshot.DisplayName,
ResourceType = snapshot.ResourceType,
State = snapshot.State,
StateStyle = snapshot.StateStyle,
HealthStatus = snapshot.HealthStatus,
Source = sourceViewModel?.Value,
ExitCode = snapshot.ExitCode,
CreationTimestamp = snapshot.CreatedAt,
StartTimestamp = snapshot.StartedAt,
StopTimestamp = snapshot.StoppedAt,
DashboardUrl = dashboardUrl,
Urls = urls,
Volumes = volumes,
Environment = environment,
HealthReports = healthReports,
Properties = properties,
Relationships = relationships.ToArray(),
Commands = commands
};
}

/// <summary>
/// Gets the display name for a resource, returning the unique name if there are multiple resources
/// with the same display name (replicas).
/// </summary>
/// <param name="resource">The resource to get the name for.</param>
/// <param name="allResources">All resources to check for duplicates.</param>
/// <returns>The display name if unique, otherwise the unique resource name.</returns>
public static string GetResourceName(ResourceSnapshot resource, IDictionary<string, ResourceSnapshot> allResources)
{
return GetResourceName(resource, allResources.Values);
}

/// <summary>
/// Gets the display name for a resource, returning the unique name if there are multiple resources
/// with the same display name (replicas).
/// </summary>
/// <param name="resource">The resource to get the name for.</param>
/// <param name="allResources">All resources to check for duplicates.</param>
/// <returns>The display name if unique, otherwise the unique resource name.</returns>
public static string GetResourceName(ResourceSnapshot resource, IEnumerable<ResourceSnapshot> allResources)
{
var count = 0;
foreach (var item in allResources)
{
// Skip hidden resources
if (string.Equals(item.State, "Hidden", StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName))
{
count++;
if (count >= 2)
{
// There are multiple resources with the same display name so they're part of a replica set.
// Need to use the name which has a unique ID to tell them apart.
return resource.Name;
}
}
}

return resource.DisplayName ?? resource.Name;
}
}
Loading
Loading