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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,45 @@
</plugin>
</plugins>
</build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>9.4.57.v20241219</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http2</artifactId>
<version>4.1.124.Final</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.2</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-core</artifactId>
<version>1.0.39</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-http</artifactId>
<version>1.0.39</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
<version>4.1.118.Final</version>
</dependency>
</dependencies>
</dependencyManagement>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
Expand Down
16 changes: 12 additions & 4 deletions src/Aspire.Cli/Commands/AgentMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,12 @@ private async ValueTask<ListToolsResult> HandleListToolsAsync(RequestContext<Lis
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
{
resourceToolMap = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
await _resourceToolRefreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
// Don't send tools/list_changed here — the client already called tools/list
// and will receive the up-to-date result. Sending a notification during the
// list handler would cause the client to call tools/list again, creating an
// infinite loop when tool availability is unstable (e.g., container MCP tools
// oscillating between available/unavailable).
(resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
}

tools.AddRange(resourceToolMap.Select(x => new Tool
Expand Down Expand Up @@ -193,8 +197,12 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
{
resourceToolMap = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
await _resourceToolRefreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
bool changed;
(resourceToolMap, changed) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
if (changed)
{
await _resourceToolRefreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);
}
toolsRefreshed = true;
}

Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ internal interface IMcpResourceToolRefreshService
/// Refreshes the resource tool map by discovering MCP tools from connected resources.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The refreshed resource tool map.</returns>
Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourceToolMapAsync(CancellationToken cancellationToken);
/// <returns>A tuple containing the refreshed resource tool map and a flag indicating whether the tool set changed.</returns>
Task<(IReadOnlyDictionary<string, ResourceToolEntry> ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken);

/// <summary>
/// Sends a tools list changed notification to connected MCP clients.
Expand Down
41 changes: 37 additions & 4 deletions src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel
}

/// <inheritdoc/>
public async Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
public async Task<(IReadOnlyDictionary<string, ResourceToolEntry> ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Refreshing resource tool map.");

Expand All @@ -95,10 +95,15 @@ public async Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourc
{
Debug.Assert(resource.McpServer is not null);

// Use DisplayName (the app-model name, e.g. "db1-mcp") rather than Name
// (the DCP runtime ID, e.g. "db1-mcp-ypnvhwvw") because the AppHost resolves
// resources by their app-model name in CallResourceMcpToolAsync.
var routedResourceName = resource.DisplayName ?? resource.Name;

foreach (var tool in resource.McpServer.Tools)
{
var exposedName = $"{resource.Name.Replace("-", "_")}_{tool.Name}";
refreshedMap[exposedName] = new ResourceToolEntry(resource.Name, tool);
var exposedName = $"{routedResourceName.Replace("-", "_")}_{tool.Name}";
refreshedMap[exposedName] = new ResourceToolEntry(routedResourceName, tool);

_logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description);
}
Expand All @@ -117,10 +122,38 @@ public async Task<IReadOnlyDictionary<string, ResourceToolEntry>> RefreshResourc

lock (_lock)
{
var changed = _resourceToolMap.Count != refreshedMap.Count;
if (!changed)
{
// Check for deleted tools (in old but not in new).
foreach (var key in _resourceToolMap.Keys)
{
if (!refreshedMap.ContainsKey(key))
{
changed = true;
break;
}
}

// Check for new tools (in new but not in old).
if (!changed)
{
foreach (var key in refreshedMap.Keys)
{
if (!_resourceToolMap.ContainsKey(key))
{
changed = true;
break;
}
}
}
}

_resourceToolMap = refreshedMap;
_selectedAppHostPath = selectedAppHostPath;
_invalidated = false;
return _resourceToolMap;
return (_resourceToolMap, changed);
}
}

}
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public override JsonElement GetInputSchema()

public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
var (resourceToolMap, _) = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false);
await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false);

var totalToolCount = KnownMcpTools.All.Count + resourceToolMap.Count;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ await context.ReportingStep.CompleteAsync(
}
internal bool UseAzdNamingConvention { get; set; }

internal bool UseCompactResourceNaming { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the Aspire dashboard should be included in the container app environment.
/// Default is true.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public static IResourceBuilder<AzureContainerAppEnvironmentResource> AddAzureCon
infra.Add(tags);

ProvisioningVariable? resourceToken = null;
if (appEnvResource.UseAzdNamingConvention)
if (appEnvResource.UseAzdNamingConvention || appEnvResource.UseCompactResourceNaming)
{
resourceToken = new ProvisioningVariable("resourceToken", typeof(string))
{
Expand Down Expand Up @@ -256,6 +256,30 @@ public static IResourceBuilder<AzureContainerAppEnvironmentResource> AddAzureCon
$"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"),
32);
}
else if (appEnvResource.UseCompactResourceNaming)
{
Debug.Assert(resourceToken is not null);

var volumeName = output.volume.Type switch
{
ContainerMountType.BindMount => $"bm{output.index}",
ContainerMountType.Volume => output.volume.Source ?? $"v{output.index}",
_ => throw new NotSupportedException()
};

// Remove '.' and '-' characters from volumeName
volumeName = volumeName.Replace(".", "").Replace("-", "");

share.Name = BicepFunction.Take(
BicepFunction.Interpolate(
$"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"),
60);

containerAppStorage.Name = BicepFunction.Take(
BicepFunction.Interpolate(
$"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}-{resourceToken}"),
32);
}
}
}

Expand Down Expand Up @@ -292,6 +316,26 @@ public static IResourceBuilder<AzureContainerAppEnvironmentResource> AddAzureCon
storageVolume.Name = BicepFunction.Interpolate($"vol{resourceToken}");
}
}
else if (appEnvResource.UseCompactResourceNaming)
{
Debug.Assert(resourceToken is not null);

if (storageVolume is not null)
{
// Sanitize env name for storage accounts: lowercase alphanumeric only.
// Reserve 2 chars for "sv" prefix + 13 for uniqueString = 15, leaving 9 for the env name.
var sanitizedPrefix = new string(appEnvResource.Name.ToLowerInvariant()
.Where(c => char.IsLetterOrDigit(c)).ToArray());
if (sanitizedPrefix.Length > 9)
{
sanitizedPrefix = sanitizedPrefix[..9];
}

storageVolume.Name = BicepFunction.Take(
BicepFunction.Interpolate($"{sanitizedPrefix}sv{resourceToken}"),
24);
}
}

// Exposed so that callers reference the LA workspace in other bicep modules
infra.Add(new ProvisioningOutput("AZURE_LOG_ANALYTICS_WORKSPACE_NAME", typeof(string))
Expand Down Expand Up @@ -370,6 +414,35 @@ public static IResourceBuilder<AzureContainerAppEnvironmentResource> WithAzdReso
return builder;
}

/// <summary>
/// Configures the container app environment to use compact resource naming that maximally preserves
/// the <c>uniqueString</c> suffix for length-constrained Azure resources such as storage accounts.
/// </summary>
/// <param name="builder">The <see cref="AzureContainerAppEnvironmentResource"/> to configure.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// <para>
/// By default, the generated Azure resource names use long static suffixes (e.g. <c>storageVolume</c>,
/// <c>managedStorage</c>) that can consume most of the 24-character storage account name limit, truncating
/// the <c>uniqueString(resourceGroup().id)</c> portion that provides cross-deployment uniqueness.
/// </para>
/// <para>
/// When enabled, this method shortens the static portions of generated names so the full 13-character
/// <c>uniqueString</c> is preserved. This prevents naming collisions when deploying multiple environments
/// to different resource groups.
/// </para>
/// <para>
/// This option only affects volume-related storage resources. It does not change the naming of the
/// container app environment, container registry, log analytics workspace, or managed identity.
/// Use <see cref="WithAzdResourceNaming"/> to change those names as well.
/// </para>
/// </remarks>
public static IResourceBuilder<AzureContainerAppEnvironmentResource> WithCompactResourceNaming(this IResourceBuilder<AzureContainerAppEnvironmentResource> builder)
{
builder.Resource.UseCompactResourceNaming = true;
return builder;
}

/// <summary>
/// Configures whether the Aspire dashboard should be included in the container app environment.
/// </summary>
Expand Down
7 changes: 5 additions & 2 deletions src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,13 @@ private async Task BuildProjectImage(PipelineStepContext ctx)
// Add COPY --from: statements for each source
stage.AddContainerFiles(this, containerWorkingDir, logger);

// Get the directory service to create temp Dockerfile
var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!;

// Create a unique temporary Dockerfile path for this resource using the directory service.
// Passing a file name causes CreateTempFile to create the file in a new, empty subdirectory,
// which avoids Docker/buildx scanning the entire temporary directory.
var directoryService = ctx.Services.GetRequiredService<IFileSystemService>();
var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile().Path;
var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path;

var builtSuccessfully = false;
try
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,11 @@ public static IResourceBuilder<T> WithDockerfileFactory<T>(this IResourceBuilder
var fullyQualifiedContextPath = Path.GetFullPath(contextPath, builder.ApplicationBuilder.AppHostDirectory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

// Create a unique temporary Dockerfile path for this resource using the directory service
// Create a unique temporary Dockerfile path for this resource using the directory service.
// Passing a file name causes CreateTempFile to create the file in a new, empty subdirectory,
// which avoids Docker/buildx scanning the entire temporary directory.
var directoryService = builder.ApplicationBuilder.FileSystemService;
var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile().Path;
var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path;

var imageName = ImageNameGenerator.GenerateImageName(builder);
var imageTag = ImageNameGenerator.GenerateImageTag(builder);
Expand Down
Loading
Loading