From 6e1c3a0848a8112d8804a2b87e06cb378905703e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:13:00 +0000 Subject: [PATCH 01/16] Initial plan From 671d49102b4976e947d373308b7b647894ac8eb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:24:49 +0000 Subject: [PATCH 02/16] Add list_integrations and get_aspire_docs MCP tools Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/McpCommand.cs | 6 +- src/Aspire.Cli/Commands/McpStartCommand.cs | 11 ++- src/Aspire.Cli/Mcp/GetAspireDocsTool.cs | 66 ++++++++++++++ src/Aspire.Cli/Mcp/ListIntegrationsTool.cs | 101 +++++++++++++++++++++ 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/GetAspireDocsTool.cs create mode 100644 src/Aspire.Cli/Mcp/ListIntegrationsTool.cs diff --git a/src/Aspire.Cli/Commands/McpCommand.cs b/src/Aspire.Cli/Commands/McpCommand.cs index 189a8e36720..041399479f2 100644 --- a/src/Aspire.Cli/Commands/McpCommand.cs +++ b/src/Aspire.Cli/Commands/McpCommand.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Git; using Aspire.Cli.Interaction; +using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -25,12 +26,13 @@ public McpCommand( ILoggerFactory loggerFactory, ILogger logger, IAgentEnvironmentDetector agentEnvironmentDetector, - IGitRepository gitRepository) + IGitRepository gitRepository, + IPackagingService packagingService) : base("mcp", McpCommandStrings.Description, features, updateNotifier, executionContext, interactionService) { ArgumentNullException.ThrowIfNull(interactionService); - var startCommand = new McpStartCommand(interactionService, features, updateNotifier, executionContext, auxiliaryBackchannelMonitor, loggerFactory, logger); + var startCommand = new McpStartCommand(interactionService, features, updateNotifier, executionContext, auxiliaryBackchannelMonitor, loggerFactory, logger, packagingService); Subcommands.Add(startCommand); var initCommand = new McpInitCommand(interactionService, features, updateNotifier, executionContext, agentEnvironmentDetector, gitRepository); diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index 3138d5ba557..c210af4ef3c 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Mcp; +using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -25,7 +26,7 @@ internal sealed class McpStartCommand : BaseCommand private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - public McpStartCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILoggerFactory loggerFactory, ILogger logger) + public McpStartCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILoggerFactory loggerFactory, ILogger logger, IPackagingService packagingService) : base("start", McpCommandStrings.StartCommand_Description, features, updateNotifier, executionContext, interactionService) { _auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor; @@ -41,7 +42,9 @@ public McpStartCommand(IInteractionService interactionService, IFeatures feature ["list_traces"] = new ListTracesTool(), ["list_trace_structured_logs"] = new ListTraceStructuredLogsTool(), ["select_apphost"] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), - ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext) + ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), + ["list_integrations"] = new ListIntegrationsTool(packagingService, executionContext), + ["get_aspire_docs"] = new GetAspireDocsTool() }; } @@ -100,8 +103,8 @@ private async ValueTask HandleCallToolAsync(RequestContext +/// MCP tool for fetching Aspire documentation from aspire.dev/llms.txt. +/// +internal sealed class GetAspireDocsTool : CliMcpTool +{ + private const string AspireDocsUrl = "https://aspire.dev/llms.txt"; + + public override string Name => "get_aspire_docs"; + + public override string Description => "Get Aspire documentation content from aspire.dev/llms.txt. This provides a comprehensive overview of .NET Aspire documentation optimized for LLMs. This tool does not require a running AppHost."; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; + } + + public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + // This tool does not use the MCP client as it operates locally + _ = mcpClient; + _ = arguments; + + try + { + using var httpClient = new HttpClient(); + var content = await httpClient.GetStringAsync(AspireDocsUrl, cancellationToken); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = content }] + }; + } + catch (HttpRequestException ex) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Failed to fetch Aspire documentation from {AspireDocsUrl}: {ex.Message}" }] + }; + } + catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "Request to fetch Aspire documentation was cancelled." }] + }; + } + catch (Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"An unexpected error occurred while fetching Aspire documentation: {ex.Message}" }] + }; + } + } +} diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs new file mode 100644 index 00000000000..5f53ce4ecbc --- /dev/null +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Packaging; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp; + +/// +/// MCP tool for listing available Aspire hosting integrations. +/// +internal sealed class ListIntegrationsTool(IPackagingService packagingService, CliExecutionContext executionContext) : CliMcpTool +{ + public override string Name => "list_integrations"; + + public override string Description => "List available Aspire hosting integrations. These are NuGet packages that can be added to an Aspire AppHost project to integrate with various services like databases, message brokers, and cloud services. This tool does not require a running AppHost."; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; + } + + public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + // This tool does not use the MCP client as it operates locally + _ = mcpClient; + _ = arguments; + + try + { + var allPackages = new List<(string FriendlyName, string PackageId, string Version, string Channel)>(); + + var packageChannels = await packagingService.GetChannelsAsync(cancellationToken); + + foreach (var channel in packageChannels) + { + var integrationPackages = await channel.GetIntegrationPackagesAsync(executionContext.WorkingDirectory, cancellationToken); + + foreach (var package in integrationPackages) + { + // Extract friendly name from package ID (e.g., "Aspire.Hosting.Redis" -> "Redis") + var friendlyName = GetFriendlyName(package.Id); + allPackages.Add((friendlyName, package.Id, package.Version, channel.Name)); + } + } + + if (allPackages.Count == 0) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = "No Aspire hosting integrations found." }] + }; + } + + // Group by package ID and take the latest version + var distinctPackages = allPackages + .GroupBy(p => p.PackageId) + .Select(g => g.OrderByDescending(p => p.Version).First()) + .OrderBy(p => p.FriendlyName) + .ToList(); + + var resultText = $"Found {distinctPackages.Count} Aspire hosting integrations:\n\n"; + + foreach (var package in distinctPackages) + { + resultText += $"- {package.FriendlyName} ({package.PackageId}) - v{package.Version}\n"; + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = resultText }] + }; + } + catch (Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Failed to list integrations: {ex.Message}" }] + }; + } + } + + private static string GetFriendlyName(string packageId) + { + // Handle CommunityToolkit packages + if (packageId.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.Ordinal)) + { + return packageId["CommunityToolkit.Aspire.Hosting.".Length..]; + } + + // Handle Aspire.Hosting packages + if (packageId.StartsWith("Aspire.Hosting.", StringComparison.Ordinal)) + { + return packageId["Aspire.Hosting.".Length..]; + } + + return packageId; + } +} From 9184ea33f5518c03caed8a6f577e4b5c1359931f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:31:41 +0000 Subject: [PATCH 03/16] Add unit tests for list_integrations and get_aspire_docs MCP tools Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../Mcp/GetAspireDocsToolTests.cs | 40 ++++++++++ .../Mcp/ListIntegrationsToolTests.cs | 78 +++++++++++++++++++ .../Mcp/MockPackagingService.cs | 59 ++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs diff --git a/tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs new file mode 100644 index 00000000000..7b24994ddfc --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Mcp; + +namespace Aspire.Cli.Tests.Mcp; + +public class GetAspireDocsToolTests +{ + [Fact] + public void GetAspireDocsTool_HasCorrectName() + { + var tool = new GetAspireDocsTool(); + + Assert.Equal("get_aspire_docs", tool.Name); + } + + [Fact] + public void GetAspireDocsTool_HasCorrectDescription() + { + var tool = new GetAspireDocsTool(); + + Assert.Contains("aspire.dev/llms.txt", tool.Description); + Assert.Contains("This tool does not require a running AppHost", tool.Description); + } + + [Fact] + public void GetAspireDocsTool_GetInputSchema_ReturnsValidSchema() + { + var tool = new GetAspireDocsTool(); + var schema = tool.GetInputSchema(); + + Assert.Equal(JsonValueKind.Object, schema.ValueKind); + Assert.True(schema.TryGetProperty("type", out var typeElement)); + Assert.Equal("object", typeElement.GetString()); + Assert.True(schema.TryGetProperty("properties", out var propsElement)); + Assert.Equal(JsonValueKind.Object, propsElement.ValueKind); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs new file mode 100644 index 00000000000..48dbfd17927 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Mcp; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListIntegrationsToolTests +{ + [Fact] + public void ListIntegrationsTool_HasCorrectName() + { + var tool = new ListIntegrationsTool(new MockPackagingService(), TestExecutionContextFactory.CreateTestContext()); + + Assert.Equal("list_integrations", tool.Name); + } + + [Fact] + public void ListIntegrationsTool_HasCorrectDescription() + { + var tool = new ListIntegrationsTool(new MockPackagingService(), TestExecutionContextFactory.CreateTestContext()); + + Assert.Contains("List available Aspire hosting integrations", tool.Description); + Assert.Contains("This tool does not require a running AppHost", tool.Description); + } + + [Fact] + public void ListIntegrationsTool_GetInputSchema_ReturnsValidSchema() + { + var tool = new ListIntegrationsTool(new MockPackagingService(), TestExecutionContextFactory.CreateTestContext()); + var schema = tool.GetInputSchema(); + + Assert.Equal(JsonValueKind.Object, schema.ValueKind); + Assert.True(schema.TryGetProperty("type", out var typeElement)); + Assert.Equal("object", typeElement.GetString()); + Assert.True(schema.TryGetProperty("properties", out var propsElement)); + Assert.Equal(JsonValueKind.Object, propsElement.ValueKind); + } + + [Fact] + public async Task ListIntegrationsTool_CallToolAsync_ReturnsNoPackagesMessage_WhenNoPackagesFound() + { + var mockPackagingService = new MockPackagingService(); + var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext()); + + var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("No Aspire hosting integrations found", textContent.Text); + } + + [Fact] + public async Task ListIntegrationsTool_CallToolAsync_ReturnsPackageList_WhenPackagesFound() + { + var mockPackagingService = new MockPackagingService(new[] + { + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.0.0" }, + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.PostgreSQL", Version = "9.0.0" } + }); + var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext()); + + var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Found 2 Aspire hosting integrations", textContent.Text); + Assert.Contains("Redis", textContent.Text); + Assert.Contains("PostgreSQL", textContent.Text); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs new file mode 100644 index 00000000000..83587f7d2f8 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -0,0 +1,59 @@ +// 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.NuGet; +using Aspire.Cli.Packaging; +using Aspire.Shared; + +namespace Aspire.Cli.Tests.Mcp; + +internal sealed class MockPackagingService : IPackagingService +{ + private readonly NuGetPackageCli[] _packages; + + public MockPackagingService(NuGetPackageCli[]? packages = null) + { + _packages = packages ?? []; + } + + public Task> GetChannelsAsync(CancellationToken cancellationToken = default) + { + var nugetCache = new MockNuGetPackageCache(_packages); + var channels = new[] { PackageChannel.CreateImplicitChannel(nugetCache) }; + return Task.FromResult>(channels); + } +} + +internal sealed class MockNuGetPackageCache : INuGetPackageCache +{ + private readonly NuGetPackageCli[] _packages; + + public MockNuGetPackageCache(NuGetPackageCli[]? packages = null) + { + _packages = packages ?? []; + } + + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>(_packages); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); +} + +internal static class TestExecutionContextFactory +{ + public static CliExecutionContext CreateTestContext() + { + return new CliExecutionContext( + new DirectoryInfo(Path.GetTempPath()), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks"))); + } +} From fd68e7749eaf9be84f1326d313704569ba1e4cbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:34:35 +0000 Subject: [PATCH 04/16] Fix HttpClient usage to avoid port exhaustion Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Mcp/GetAspireDocsTool.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs index 1be8c3a7d29..76c972fd929 100644 --- a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs +++ b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs @@ -13,6 +13,9 @@ internal sealed class GetAspireDocsTool : CliMcpTool { private const string AspireDocsUrl = "https://aspire.dev/llms.txt"; + // Use a static HttpClient to avoid port exhaustion and improve performance + private static readonly HttpClient s_httpClient = new(); + public override string Name => "get_aspire_docs"; public override string Description => "Get Aspire documentation content from aspire.dev/llms.txt. This provides a comprehensive overview of .NET Aspire documentation optimized for LLMs. This tool does not require a running AppHost."; @@ -30,8 +33,7 @@ public override async ValueTask CallToolAsync(ModelContextProtoc try { - using var httpClient = new HttpClient(); - var content = await httpClient.GetStringAsync(AspireDocsUrl, cancellationToken); + var content = await s_httpClient.GetStringAsync(AspireDocsUrl, cancellationToken); return new CallToolResult { From d0816ce2c2d573c3b4c35dac3e05131a6ea6c7ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:39:27 +0000 Subject: [PATCH 05/16] Address code review feedback: use semantic versioning and configure HttpClient timeout Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Mcp/GetAspireDocsTool.cs | 15 ++++++++++++++- src/Aspire.Cli/Mcp/ListIntegrationsTool.cs | 6 ++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs index 76c972fd929..a7dcd5de4ee 100644 --- a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs +++ b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs @@ -14,7 +14,11 @@ internal sealed class GetAspireDocsTool : CliMcpTool private const string AspireDocsUrl = "https://aspire.dev/llms.txt"; // Use a static HttpClient to avoid port exhaustion and improve performance - private static readonly HttpClient s_httpClient = new(); + // Configure with a reasonable timeout for fetching documentation + private static readonly HttpClient s_httpClient = new() + { + Timeout = TimeSpan.FromSeconds(30) + }; public override string Name => "get_aspire_docs"; @@ -56,6 +60,15 @@ public override async ValueTask CallToolAsync(ModelContextProtoc Content = [new TextContentBlock { Text = "Request to fetch Aspire documentation was cancelled." }] }; } + catch (TaskCanceledException) + { + // Timeout occurred + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Request to fetch Aspire documentation timed out after {s_httpClient.Timeout.TotalSeconds} seconds." }] + }; + } catch (Exception ex) { return new CallToolResult diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs index 5f53ce4ecbc..6c277976a81 100644 --- a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Aspire.Cli.Packaging; using ModelContextProtocol.Protocol; +using Semver; namespace Aspire.Cli.Mcp; @@ -53,10 +54,11 @@ public override async ValueTask CallToolAsync(ModelContextProtoc }; } - // Group by package ID and take the latest version + // Group by package ID and take the latest version using semantic version comparison var distinctPackages = allPackages + .Where(p => SemVersion.TryParse(p.Version, SemVersionStyles.Any, out _)) .GroupBy(p => p.PackageId) - .Select(g => g.OrderByDescending(p => p.Version).First()) + .Select(g => g.OrderByDescending(p => SemVersion.Parse(p.Version, SemVersionStyles.Any), SemVersion.PrecedenceComparer).First()) .OrderBy(p => p.FriendlyName) .ToList(); From 913594aa271490bfc19dfa046b559a2da31cc60e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:42:48 +0000 Subject: [PATCH 06/16] Optimize version parsing to avoid redundant operations Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Mcp/ListIntegrationsTool.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs index 6c277976a81..2be87c66901 100644 --- a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -55,10 +55,15 @@ public override async ValueTask CallToolAsync(ModelContextProtoc } // Group by package ID and take the latest version using semantic version comparison - var distinctPackages = allPackages - .Where(p => SemVersion.TryParse(p.Version, SemVersionStyles.Any, out _)) + // Parse version once and include it in the result to avoid redundant parsing + var packagesWithParsedVersions = allPackages + .Select(p => (p.FriendlyName, p.PackageId, p.Version, p.Channel, ParsedVersion: SemVersion.TryParse(p.Version, SemVersionStyles.Any, out var v) ? v : null)) + .Where(p => p.ParsedVersion is not null) + .ToList(); + + var distinctPackages = packagesWithParsedVersions .GroupBy(p => p.PackageId) - .Select(g => g.OrderByDescending(p => SemVersion.Parse(p.Version, SemVersionStyles.Any), SemVersion.PrecedenceComparer).First()) + .Select(g => g.OrderByDescending(p => p.ParsedVersion!, SemVersion.PrecedenceComparer).First()) .OrderBy(p => p.FriendlyName) .ToList(); From 51d198c063230ae64d7d40e814abf6f6aabccdd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:41:08 +0000 Subject: [PATCH 07/16] Address PR feedback: use Aspire branding, add schema for integrations, return JSON Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/JsonSourceGenerationContext.cs | 3 + src/Aspire.Cli/Mcp/GetAspireDocsTool.cs | 2 +- src/Aspire.Cli/Mcp/ListIntegrationsTool.cs | 70 +++++++++++++++---- .../Mcp/ListIntegrationsToolTests.cs | 35 ++++++++-- 4 files changed, 89 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index 6e6987d1a59..c69b5bf3b14 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -3,12 +3,15 @@ using System.Text.Json.Serialization; using System.Text.Json.Nodes; +using Aspire.Cli.Mcp; namespace Aspire.Cli; [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(CliSettings))] [JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(ListIntegrationsResponse))] +[JsonSerializable(typeof(Integration))] internal partial class JsonSourceGenerationContext : JsonSerializerContext { } diff --git a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs index a7dcd5de4ee..2b0c83fb169 100644 --- a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs +++ b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs @@ -22,7 +22,7 @@ internal sealed class GetAspireDocsTool : CliMcpTool public override string Name => "get_aspire_docs"; - public override string Description => "Get Aspire documentation content from aspire.dev/llms.txt. This provides a comprehensive overview of .NET Aspire documentation optimized for LLMs. This tool does not require a running AppHost."; + public override string Description => "Get Aspire documentation content from aspire.dev/llms.txt. This provides a comprehensive overview of Aspire documentation optimized for LLMs. This tool does not require a running AppHost."; public override JsonElement GetInputSchema() { diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs index 2be87c66901..a998eef59b5 100644 --- a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -2,12 +2,49 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using System.Text.Json.Serialization; using Aspire.Cli.Packaging; using ModelContextProtocol.Protocol; using Semver; namespace Aspire.Cli.Mcp; +/// +/// Represents an Aspire hosting integration package. +/// +internal sealed class Integration +{ + /// + /// Gets or sets the friendly name of the integration (e.g., "Redis", "PostgreSQL"). + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the NuGet package ID. + /// + [JsonPropertyName("packageId")] + public required string PackageId { get; set; } + + /// + /// Gets or sets the package version. + /// + [JsonPropertyName("version")] + public required string Version { get; set; } +} + +/// +/// Represents the response from the list_integrations tool. +/// +internal sealed class ListIntegrationsResponse +{ + /// + /// Gets or sets the list of available integrations. + /// + [JsonPropertyName("integrations")] + public required List Integrations { get; set; } +} + /// /// MCP tool for listing available Aspire hosting integrations. /// @@ -19,7 +56,13 @@ internal sealed class ListIntegrationsTool(IPackagingService packagingService, C public override JsonElement GetInputSchema() { - return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; + return JsonDocument.Parse(""" + { + "type": "object", + "properties": {}, + "description": "No input parameters required. Returns a list of available Aspire hosting integrations." + } + """).RootElement; } public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) @@ -46,14 +89,6 @@ public override async ValueTask CallToolAsync(ModelContextProtoc } } - if (allPackages.Count == 0) - { - return new CallToolResult - { - Content = [new TextContentBlock { Text = "No Aspire hosting integrations found." }] - }; - } - // Group by package ID and take the latest version using semantic version comparison // Parse version once and include it in the result to avoid redundant parsing var packagesWithParsedVersions = allPackages @@ -67,16 +102,23 @@ public override async ValueTask CallToolAsync(ModelContextProtoc .OrderBy(p => p.FriendlyName) .ToList(); - var resultText = $"Found {distinctPackages.Count} Aspire hosting integrations:\n\n"; + var integrations = distinctPackages.Select(p => new Integration + { + Name = p.FriendlyName, + PackageId = p.PackageId, + Version = p.Version + }).ToList(); - foreach (var package in distinctPackages) + var response = new ListIntegrationsResponse { - resultText += $"- {package.FriendlyName} ({package.PackageId}) - v{package.Version}\n"; - } + Integrations = integrations + }; + + var jsonContent = JsonSerializer.Serialize(response, JsonSourceGenerationContext.Default.ListIntegrationsResponse); return new CallToolResult { - Content = [new TextContentBlock { Text = resultText }] + Content = [new TextContentBlock { Text = jsonContent }] }; } catch (Exception ex) diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs index 48dbfd17927..6ace7cf98d3 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -36,10 +36,12 @@ public void ListIntegrationsTool_GetInputSchema_ReturnsValidSchema() Assert.Equal("object", typeElement.GetString()); Assert.True(schema.TryGetProperty("properties", out var propsElement)); Assert.Equal(JsonValueKind.Object, propsElement.ValueKind); + Assert.True(schema.TryGetProperty("description", out var descElement)); + Assert.Contains("No input parameters required", descElement.GetString()); } [Fact] - public async Task ListIntegrationsTool_CallToolAsync_ReturnsNoPackagesMessage_WhenNoPackagesFound() + public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenNoPackagesFound() { var mockPackagingService = new MockPackagingService(); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext()); @@ -51,11 +53,16 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsNoPackagesMessage_Wh Assert.Single(result.Content); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); - Assert.Contains("No Aspire hosting integrations found", textContent.Text); + + // Verify it's valid JSON with empty integrations array + var json = JsonDocument.Parse(textContent.Text); + Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations)); + Assert.Equal(JsonValueKind.Array, integrations.ValueKind); + Assert.Equal(0, integrations.GetArrayLength()); } [Fact] - public async Task ListIntegrationsTool_CallToolAsync_ReturnsPackageList_WhenPackagesFound() + public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_WhenPackagesFound() { var mockPackagingService = new MockPackagingService(new[] { @@ -71,8 +78,24 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsPackageList_WhenPack Assert.Single(result.Content); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); - Assert.Contains("Found 2 Aspire hosting integrations", textContent.Text); - Assert.Contains("Redis", textContent.Text); - Assert.Contains("PostgreSQL", textContent.Text); + + // Verify it's valid JSON with proper structure + var json = JsonDocument.Parse(textContent.Text); + Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations)); + Assert.Equal(JsonValueKind.Array, integrations.ValueKind); + Assert.Equal(2, integrations.GetArrayLength()); + + // Verify the first integration has the expected properties + var firstIntegration = integrations[0]; + Assert.True(firstIntegration.TryGetProperty("name", out _)); + Assert.True(firstIntegration.TryGetProperty("packageId", out _)); + Assert.True(firstIntegration.TryGetProperty("version", out _)); + + // Check that the packages are included (order may vary) + var packageIds = integrations.EnumerateArray() + .Select(e => e.GetProperty("packageId").GetString()) + .ToList(); + Assert.Contains("Aspire.Hosting.Redis", packageIds); + Assert.Contains("Aspire.Hosting.PostgreSQL", packageIds); } } From ad3edc0cd94bd5de5463498ac5ebe999eb818ad9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:44:02 +0000 Subject: [PATCH 08/16] Add using statement to JsonDocument in tests Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs index 6ace7cf98d3..7e2b99e61a3 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -55,7 +55,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenN Assert.NotNull(textContent); // Verify it's valid JSON with empty integrations array - var json = JsonDocument.Parse(textContent.Text); + using var json = JsonDocument.Parse(textContent.Text); Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations)); Assert.Equal(JsonValueKind.Array, integrations.ValueKind); Assert.Equal(0, integrations.GetArrayLength()); @@ -80,7 +80,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_Whe Assert.NotNull(textContent); // Verify it's valid JSON with proper structure - var json = JsonDocument.Parse(textContent.Text); + using var json = JsonDocument.Parse(textContent.Text); Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations)); Assert.Equal(JsonValueKind.Array, integrations.ValueKind); Assert.Equal(2, integrations.GetArrayLength()); From 9b1fde78c1fdcac811bacdc71c5c021fa00c41bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:54:16 +0000 Subject: [PATCH 09/16] Improve tool schemas with better descriptions and explain how to add integrations Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Mcp/GetAspireDocsTool.cs | 9 ++++++++- src/Aspire.Cli/Mcp/ListIntegrationsTool.cs | 5 +++-- tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs index 2b0c83fb169..9e3c7c07d4c 100644 --- a/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs +++ b/src/Aspire.Cli/Mcp/GetAspireDocsTool.cs @@ -26,7 +26,14 @@ internal sealed class GetAspireDocsTool : CliMcpTool public override JsonElement GetInputSchema() { - return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; + return JsonDocument.Parse(""" + { + "type": "object", + "properties": {}, + "additionalProperties": false, + "description": "This tool takes no input parameters. It fetches and returns Aspire documentation content from aspire.dev/llms.txt." + } + """).RootElement; } public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs index a998eef59b5..d3717f32453 100644 --- a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -52,7 +52,7 @@ internal sealed class ListIntegrationsTool(IPackagingService packagingService, C { public override string Name => "list_integrations"; - public override string Description => "List available Aspire hosting integrations. These are NuGet packages that can be added to an Aspire AppHost project to integrate with various services like databases, message brokers, and cloud services. This tool does not require a running AppHost."; + public override string Description => "List available Aspire hosting integrations. These are NuGet packages that can be added to an Aspire AppHost project to integrate with various services like databases, message brokers, and cloud services. Use 'aspire add ' to add an integration to your AppHost project. This tool does not require a running AppHost."; public override JsonElement GetInputSchema() { @@ -60,7 +60,8 @@ public override JsonElement GetInputSchema() { "type": "object", "properties": {}, - "description": "No input parameters required. Returns a list of available Aspire hosting integrations." + "additionalProperties": false, + "description": "This tool takes no input parameters. It returns a list of available Aspire hosting integrations with their short name, full package ID, and version." } """).RootElement; } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs index 7e2b99e61a3..379d3b75993 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -37,7 +37,9 @@ public void ListIntegrationsTool_GetInputSchema_ReturnsValidSchema() Assert.True(schema.TryGetProperty("properties", out var propsElement)); Assert.Equal(JsonValueKind.Object, propsElement.ValueKind); Assert.True(schema.TryGetProperty("description", out var descElement)); - Assert.Contains("No input parameters required", descElement.GetString()); + Assert.Contains("This tool takes no input parameters", descElement.GetString()); + Assert.True(schema.TryGetProperty("additionalProperties", out var additionalPropsElement)); + Assert.False(additionalPropsElement.GetBoolean()); } [Fact] From 361470885bc7dae76cc0ebdaa879d9444cf2aab3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 04:48:35 +0000 Subject: [PATCH 10/16] Replace GetAspireDocsTool with WithAgentContent and get_resource_content MCP tool Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- .../AuxiliaryBackchannelMonitor.cs | 80 +++++++++++++ .../IAuxiliaryBackchannelMonitor.cs | 13 ++ src/Aspire.Cli/Commands/McpStartCommand.cs | 4 +- src/Aspire.Cli/Mcp/GetAspireDocsTool.cs | 88 -------------- src/Aspire.Cli/Mcp/GetResourceContentTool.cs | 111 ++++++++++++++++++ .../AgentContentAnnotation.cs | 103 ++++++++++++++++ .../AuxiliaryBackchannelRpcTarget.cs | 48 ++++++++ .../ResourceBuilderExtensions.cs | 29 +++++ .../Mcp/GetAspireDocsToolTests.cs | 40 ------- .../TestAuxiliaryBackchannelMonitor.cs | 63 ++++++++++ 10 files changed, 449 insertions(+), 130 deletions(-) delete mode 100644 src/Aspire.Cli/Mcp/GetAspireDocsTool.cs create mode 100644 src/Aspire.Cli/Mcp/GetResourceContentTool.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs delete mode 100644 tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index 37b56596bed..b9885e57cf1 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -37,6 +37,75 @@ internal sealed class AuxiliaryBackchannelMonitor( /// public string? SelectedAppHostPath { get; set; } + /// + /// Gets the currently selected AppHost connection based on the selection logic. + /// + public AppHostConnection? SelectedConnection + { + get + { + var connections = _connections.Values.ToList(); + + if (connections.Count == 0) + { + return null; + } + + // Check if a specific AppHost was selected + if (!string.IsNullOrEmpty(SelectedAppHostPath)) + { + var selectedConnection = connections.FirstOrDefault(c => + c.AppHostInfo?.AppHostPath != null && + string.Equals(Path.GetFullPath(c.AppHostInfo.AppHostPath), Path.GetFullPath(SelectedAppHostPath), StringComparison.OrdinalIgnoreCase)); + + if (selectedConnection != null) + { + return selectedConnection; + } + + // Clear the selection since the AppHost is no longer available + SelectedAppHostPath = null; + } + + // Look for in-scope connections + var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); + + if (inScopeConnections.Count == 1) + { + return inScopeConnections[0]; + } + + // Fall back to the first available connection + return connections.FirstOrDefault(); + } + } + + /// + /// Gets all connections that are within the scope of the specified working directory. + /// + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + { + return _connections.Values + .Where(c => IsAppHostInScopeOfDirectory(c.AppHostInfo?.AppHostPath, workingDirectory.FullName)) + .ToList(); + } + + private static bool IsAppHostInScopeOfDirectory(string? appHostPath, string workingDirectory) + { + if (string.IsNullOrEmpty(appHostPath)) + { + return false; + } + + // Normalize the paths for comparison + var normalizedWorkingDirectory = Path.GetFullPath(workingDirectory); + var normalizedAppHostPath = Path.GetFullPath(appHostPath); + + // Check if the AppHost path is within the working directory + var relativePath = Path.GetRelativePath(normalizedWorkingDirectory, normalizedAppHostPath); + return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try @@ -365,4 +434,15 @@ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardM /// Gets the timestamp when this connection was established. /// public DateTimeOffset ConnectedAt { get; } + + /// + /// Gets agent content for a specific resource in the AppHost. + /// + /// The name of the resource. + /// A cancellation token. + /// The agent content text, or null if the resource doesn't have agent content configured. + public async Task GetResourceAgentContentAsync(string resourceName, CancellationToken cancellationToken = default) + { + return await Rpc.InvokeWithCancellationAsync("GetResourceAgentContentAsync", [resourceName], cancellationToken).ConfigureAwait(false); + } } diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs index 9a7b18efec7..6d59ef95e3c 100644 --- a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs @@ -17,4 +17,17 @@ internal interface IAuxiliaryBackchannelMonitor /// Gets or sets the path to the selected AppHost. When set, this AppHost will be used for MCP operations. /// string? SelectedAppHostPath { get; set; } + + /// + /// Gets the currently selected AppHost connection based on the selection logic. + /// Returns the explicitly selected AppHost, or the single in-scope AppHost, or null if none available. + /// + AppHostConnection? SelectedConnection { get; } + + /// + /// Gets all connections that are within the scope of the specified working directory. + /// + /// The working directory to check against. + /// A list of in-scope connections. + IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory); } diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index c210af4ef3c..16a3da0a080 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -44,7 +44,7 @@ public McpStartCommand(IInteractionService interactionService, IFeatures feature ["select_apphost"] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), ["list_integrations"] = new ListIntegrationsTool(packagingService, executionContext), - ["get_aspire_docs"] = new GetAspireDocsTool() + ["get_resource_content"] = new GetResourceContentTool(auxiliaryBackchannelMonitor, executionContext) }; } @@ -104,7 +104,7 @@ private async ValueTask HandleCallToolAsync(RequestContext -/// MCP tool for fetching Aspire documentation from aspire.dev/llms.txt. -/// -internal sealed class GetAspireDocsTool : CliMcpTool -{ - private const string AspireDocsUrl = "https://aspire.dev/llms.txt"; - - // Use a static HttpClient to avoid port exhaustion and improve performance - // Configure with a reasonable timeout for fetching documentation - private static readonly HttpClient s_httpClient = new() - { - Timeout = TimeSpan.FromSeconds(30) - }; - - public override string Name => "get_aspire_docs"; - - public override string Description => "Get Aspire documentation content from aspire.dev/llms.txt. This provides a comprehensive overview of Aspire documentation optimized for LLMs. This tool does not require a running AppHost."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": {}, - "additionalProperties": false, - "description": "This tool takes no input parameters. It fetches and returns Aspire documentation content from aspire.dev/llms.txt." - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // This tool does not use the MCP client as it operates locally - _ = mcpClient; - _ = arguments; - - try - { - var content = await s_httpClient.GetStringAsync(AspireDocsUrl, cancellationToken); - - return new CallToolResult - { - Content = [new TextContentBlock { Text = content }] - }; - } - catch (HttpRequestException ex) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"Failed to fetch Aspire documentation from {AspireDocsUrl}: {ex.Message}" }] - }; - } - catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = "Request to fetch Aspire documentation was cancelled." }] - }; - } - catch (TaskCanceledException) - { - // Timeout occurred - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"Request to fetch Aspire documentation timed out after {s_httpClient.Timeout.TotalSeconds} seconds." }] - }; - } - catch (Exception ex) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"An unexpected error occurred while fetching Aspire documentation: {ex.Message}" }] - }; - } - } -} diff --git a/src/Aspire.Cli/Mcp/GetResourceContentTool.cs b/src/Aspire.Cli/Mcp/GetResourceContentTool.cs new file mode 100644 index 00000000000..fb5cb26207a --- /dev/null +++ b/src/Aspire.Cli/Mcp/GetResourceContentTool.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Backchannel; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp; + +/// +/// MCP tool for getting agent content from a resource in the AppHost. +/// +internal sealed class GetResourceContentTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, CliExecutionContext executionContext) : CliMcpTool +{ + public override string Name => "get_resource_content"; + + public override string Description => "Get agent content from a resource in the AppHost. Resources can expose content specifically for AI agents via the WithAgentContent API. This tool invokes the callback on the resource to retrieve contextual information."; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "resourceName": { + "type": "string", + "description": "The name of the resource to get content from." + } + }, + "required": ["resourceName"], + "additionalProperties": false, + "description": "Gets agent content from a specific resource. The resource must have been configured with WithAgentContent in the AppHost." + } + """).RootElement; + } + + public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + // This tool does not use the MCP client as it operates through the auxiliary backchannel + _ = mcpClient; + + if (arguments == null || !arguments.TryGetValue("resourceName", out var resourceNameElement)) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'resourceName' parameter is required." }] + }; + } + + var resourceName = resourceNameElement.GetString(); + if (string.IsNullOrEmpty(resourceName)) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'resourceName' parameter cannot be empty." }] + }; + } + + try + { + // Find all connections from AppHosts that were started within our working directory + var connections = auxiliaryBackchannelMonitor.GetConnectionsForWorkingDirectory(executionContext.WorkingDirectory); + + if (connections.Count == 0) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "No Aspire AppHost is currently running. Start an Aspire application with 'aspire run' first." }] + }; + } + + // Get the selected connection + var selectedConnection = auxiliaryBackchannelMonitor.SelectedConnection; + if (selectedConnection == null) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "No AppHost is selected. Use select_apphost to select an AppHost first." }] + }; + } + + // Call the RPC method to get agent content from the resource + var content = await selectedConnection.GetResourceAgentContentAsync(resourceName, cancellationToken); + + if (content == null) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Resource '{resourceName}' does not have any agent content configured." }] + }; + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = content }] + }; + } + catch (Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Failed to get resource content: {ex.Message}" }] + }; + } + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs new file mode 100644 index 00000000000..8b6f7f4bd58 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a context for generating agent content from a resource. +/// +[Experimental("ASPIREAGENTCONTENT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class AgentContentContext +{ + private readonly List _contentParts = []; + + /// + /// Initializes a new instance of the class. + /// + /// The resource for which agent content is being generated. + /// The service provider for accessing application services. + /// A cancellation token. + public AgentContentContext(IResource resource, IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + Resource = resource; + ServiceProvider = serviceProvider; + CancellationToken = cancellationToken; + } + + /// + /// Gets the resource for which agent content is being generated. + /// + public IResource Resource { get; } + + /// + /// Gets the service provider for accessing application services. + /// + public IServiceProvider ServiceProvider { get; } + + /// + /// Gets the cancellation token. + /// + public CancellationToken CancellationToken { get; } + + /// + /// Appends text content to the agent content. + /// + /// The text to append. + public void AppendText(string text) + { + ArgumentNullException.ThrowIfNull(text); + _contentParts.Add(text); + } + + /// + /// Appends a line of text content to the agent content. + /// + /// The text to append. + public void AppendLine(string text) + { + ArgumentNullException.ThrowIfNull(text); + _contentParts.Add(text + Environment.NewLine); + } + + /// + /// Appends an empty line to the agent content. + /// + public void AppendLine() + { + _contentParts.Add(Environment.NewLine); + } + + /// + /// Gets the combined agent content text. + /// + /// The combined content as a single string. + internal string GetContent() + { + return string.Concat(_contentParts); + } +} + +/// +/// Represents an annotation that provides agent content for a resource. +/// Agent content is text that can be retrieved by AI agents to understand the resource. +/// +[Experimental("ASPIREAGENTCONTENT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class AgentContentAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The callback to invoke to generate agent content. + public AgentContentAnnotation(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + Callback = callback; + } + + /// + /// Gets the callback that generates agent content for the resource. + /// + public Func Callback { get; } +} diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 3526676d504..17c0c910aa9 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -126,4 +126,52 @@ public Task GetAppHostInformationAsync(CancellationToken can ApiToken = mcpApiKey }; } + + /// + /// Gets agent content for a specific resource in the AppHost. + /// + /// The name of the resource. + /// A cancellation token. + /// The agent content text, or null if the resource doesn't have agent content configured. + public async Task GetResourceAgentContentAsync(string resourceName, CancellationToken cancellationToken = default) + { + var appModel = serviceProvider.GetService(); + if (appModel is null) + { + logger.LogWarning("Application model not found."); + return null; + } + + // Find the resource by name + var resource = appModel.Resources.FirstOrDefault(r => + string.Equals(r.Name, resourceName, StringComparisons.ResourceName)); + + if (resource is null) + { + logger.LogDebug("Resource '{ResourceName}' not found in application model.", resourceName); + return null; + } + + // Check if the resource has an AgentContentAnnotation +#pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + if (!resource.TryGetLastAnnotation(out var annotation)) + { + logger.LogDebug("Resource '{ResourceName}' does not have agent content configured.", resourceName); + return null; + } + + try + { + // Create the context and invoke the callback + var context = new AgentContentContext(resource, serviceProvider, cancellationToken); + await annotation.Callback(context).ConfigureAwait(false); + return context.GetContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting agent content for resource '{ResourceName}'.", resourceName); + return null; + } +#pragma warning restore ASPIREAGENTCONTENT001 + } } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index d28fcdd0fb5..501da7ca3fc 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3182,4 +3182,33 @@ public static IResourceBuilder WithRemoteImageTag( context.Options.RemoteImageTag = remoteImageTag; }); } + + /// + /// Configures agent content for the resource. Agent content is text that can be retrieved by AI agents + /// to understand the resource and its configuration. + /// + /// The resource type. + /// The resource builder. + /// A callback that generates agent content using the provided context. + /// The . + /// + /// + /// var postgres = builder.AddPostgres("pg") + /// .WithAgentContent(async context => + /// { + /// context.AppendLine("This is a PostgreSQL database used for user data."); + /// context.AppendLine("Connection string format: Host=...;Database=...;Username=...;Password=..."); + /// }); + /// + /// + [Experimental("ASPIREAGENTCONTENT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithAgentContent(this IResourceBuilder builder, Func callback) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(callback); + +#pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + return builder.WithAnnotation(new AgentContentAnnotation(callback)); +#pragma warning restore ASPIREAGENTCONTENT001 + } } diff --git a/tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs deleted file mode 100644 index 7b24994ddfc..00000000000 --- a/tests/Aspire.Cli.Tests/Mcp/GetAspireDocsToolTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using Aspire.Cli.Mcp; - -namespace Aspire.Cli.Tests.Mcp; - -public class GetAspireDocsToolTests -{ - [Fact] - public void GetAspireDocsTool_HasCorrectName() - { - var tool = new GetAspireDocsTool(); - - Assert.Equal("get_aspire_docs", tool.Name); - } - - [Fact] - public void GetAspireDocsTool_HasCorrectDescription() - { - var tool = new GetAspireDocsTool(); - - Assert.Contains("aspire.dev/llms.txt", tool.Description); - Assert.Contains("This tool does not require a running AppHost", tool.Description); - } - - [Fact] - public void GetAspireDocsTool_GetInputSchema_ReturnsValidSchema() - { - var tool = new GetAspireDocsTool(); - var schema = tool.GetInputSchema(); - - Assert.Equal(JsonValueKind.Object, schema.ValueKind); - Assert.True(schema.TryGetProperty("type", out var typeElement)); - Assert.Equal("object", typeElement.GetString()); - Assert.True(schema.TryGetProperty("properties", out var propsElement)); - Assert.Equal(JsonValueKind.Object, propsElement.ValueKind); - } -} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs index 1dd68f4479c..9abbca56d9a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs @@ -13,6 +13,69 @@ internal sealed class TestAuxiliaryBackchannelMonitor : IAuxiliaryBackchannelMon public string? SelectedAppHostPath { get; set; } + public AppHostConnection? SelectedConnection + { + get + { + var connections = _connections.Values.ToList(); + + if (connections.Count == 0) + { + return null; + } + + // Check if a specific AppHost was selected + if (!string.IsNullOrEmpty(SelectedAppHostPath)) + { + var selectedConnection = connections.FirstOrDefault(c => + c.AppHostInfo?.AppHostPath != null && + string.Equals(Path.GetFullPath(c.AppHostInfo.AppHostPath), Path.GetFullPath(SelectedAppHostPath), StringComparison.OrdinalIgnoreCase)); + + if (selectedConnection != null) + { + return selectedConnection; + } + + // Clear the selection since the AppHost is no longer available + SelectedAppHostPath = null; + } + + // Look for in-scope connections + var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); + + if (inScopeConnections.Count == 1) + { + return inScopeConnections[0]; + } + + // Fall back to the first available connection + return connections.FirstOrDefault(); + } + } + + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + { + return _connections.Values + .Where(c => IsAppHostInScopeOfDirectory(c.AppHostInfo?.AppHostPath, workingDirectory.FullName)) + .ToList(); + } + + private static bool IsAppHostInScopeOfDirectory(string? appHostPath, string workingDirectory) + { + if (string.IsNullOrEmpty(appHostPath)) + { + return false; + } + + // Normalize the paths for comparison + var normalizedWorkingDirectory = Path.GetFullPath(workingDirectory); + var normalizedAppHostPath = Path.GetFullPath(appHostPath); + + // Check if the AppHost path is within the working directory + var relativePath = Path.GetRelativePath(normalizedWorkingDirectory, normalizedAppHostPath); + return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); + } + public void AddConnection(string hash, AppHostConnection connection) { _connections[hash] = connection; From c09b1d920014929ac3ee5f1ab3ac71b7896415ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 04:50:46 +0000 Subject: [PATCH 11/16] Set annotation behavior to Replace for AgentContentAnnotation Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- src/Aspire.Hosting/ResourceBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 501da7ca3fc..7fce50f86b4 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3208,7 +3208,7 @@ public static IResourceBuilder WithAgentContent(this IResourceBuilder b ArgumentNullException.ThrowIfNull(callback); #pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - return builder.WithAnnotation(new AgentContentAnnotation(callback)); + return builder.WithAnnotation(new AgentContentAnnotation(callback), ResourceAnnotationMutationBehavior.Replace); #pragma warning restore ASPIREAGENTCONTENT001 } } From 8ac275f3b18c768d747015348b0d814b8e99f718 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 1 Dec 2025 18:04:05 +1100 Subject: [PATCH 12/16] Add some boilerplate postgres content. --- .../PostgresBuilderExtensions.cs | 130 +++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index b2f6c216297..217a2a18864 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -116,7 +116,8 @@ public static IResourceBuilder AddPostgres(this IDistrib context.EnvironmentVariables[UserEnvVarName] = postgresServer.UserNameReference; context.EnvironmentVariables[PasswordEnvVarName] = postgresServer.PasswordParameter; }) - .WithHealthCheck(healthCheckKey); + .WithHealthCheck(healthCheckKey) + .WithAgentContent(); } /// @@ -600,4 +601,131 @@ private static async Task CreateDatabaseAsync(NpgsqlConnection npgsqlConnection, logger.LogError(e, "Failed to create database '{DatabaseName}'", npgsqlDatabase.DatabaseName); } } + + private static IResourceBuilder WithAgentContent(this IResourceBuilder builder) + { +#pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + return builder.WithAgentContent((context => + { + if (context.Resource is not PostgresServerResource resource) + { + return Task.CompletedTask; + } + + context.AppendText( + $$""" + + # {{resource.Name}} information + + The '{{resource.Name}}' is a Postgres database server. It is managed by Aspire and is running + as a container application. The following information relates to this specific instance of the + resource. From time to time the model should refresh this information especially if previous + information does not seem accurate anymore. This information can only be fetched + if the apphost is running. + """ + ); + + context.AppendLine("\n## Databases"); + if (resource.Databases.Any()) + { + foreach (var db in resource.Databases) + { + context.AppendText($"- `{db.Key} (resource name in app model: {db.Value})`"); + } + } + else + { + context.AppendText("No databases have been defined on this resource yet."); + } + + context.AppendLine("\n## Bind Mounts"); + var bindMounts = resource.Annotations.OfType() + .Where(m => m.Type == ContainerMountType.BindMount); + if (bindMounts.Any()) + { + foreach (var mount in bindMounts) + { + context.AppendText($"- Source: `{mount.Source}` -> Target: `{mount.Target}` (ReadOnly: {mount.IsReadOnly})"); + } + } + else + { + context.AppendText("No bind mounts have been defined on this resource."); + } + + context.AppendLine("\n## Data Volumes"); + var volumes = resource.Annotations.OfType() + .Where(m => m.Type == ContainerMountType.Volume); + if (volumes.Any()) + { + foreach (var volume in volumes) + { + context.AppendText($"- Name: `{volume.Source}` -> Target: `{volume.Target}` (ReadOnly: {volume.IsReadOnly})"); + } + } + else + { + context.AppendText("No data volumes have been defined on this resource."); + } + + context.AppendLine("\n## Development guidance"); + + context.AppendText( + $$""" + The following guidance provides useful information for connecting to the {{resource.Name}} Postgres server + from application code running within Aspire. + + ### Scripting databases + + ### .NET - client connectivity + To connect to the Postgres server from a .NET application add a reference to the .NET application + in the app model using the `WithReference(...)` extension method. This will automatically inject + connection strings into the app at runtime. + + + Aspire includes specific client libraries which simplify the configuration of database connections + to Postgres. To use these libraries install the `Aspire.Npgsql` package an use the following code + in the application entrypoint (assuming the use of the host builder). + + ```csharp + builder.AddNpgsqlDatasource("{{resource.Name}}"); + ``` + + ### Python - client connectivity + The most popular PostgreSQL client driver for Python is `psycopg2` (or `psycopg` for the newer version 3). + Install it using pip: + + ```bash + pip install psycopg2-binary + ``` + + Example usage: + + ```python + import os + import psycopg2 + + conn = psycopg2.connect( + host=os.environ["{{resource.Name}}_HOST"], + port=os.environ["{{resource.Name}}_PORT"], + user=os.environ["{{resource.Name}}_USERNAME"], + password=os.environ["{{resource.Name}}_PASSWORD"], + database="your_database_name" + ) + + cursor = conn.cursor() + cursor.execute("SELECT * FROM your_table") + results = cursor.fetchall() + cursor.close() + conn.close() + ``` + """ + ); + + return Task.CompletedTask; + })); +#pragma warning restore ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + return builder; + } } From 9ccf80f7013acb963a3394892fb35540765114ef Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 1 Dec 2025 18:26:26 +1100 Subject: [PATCH 13/16] Compile error --- src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 217a2a18864..870823cd1be 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -725,7 +725,5 @@ import psycopg2 return Task.CompletedTask; })); #pragma warning restore ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - return builder; } } From 2589ac10396371006692252a2fdc19569a2a9129 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 1 Dec 2025 18:44:45 +1100 Subject: [PATCH 14/16] WIP --- .../AuxiliaryBackchannelMonitor.cs | 8 +- src/Aspire.Cli/Commands/McpStartCommand.cs | 4 +- ...eContentTool.cs => GetResourceDocsTool.cs} | 21 ++--- .../AuxiliaryBackchannelRpcTarget.cs | 81 +++++++++++++++---- 4 files changed, 83 insertions(+), 31 deletions(-) rename src/Aspire.Cli/Mcp/{GetResourceContentTool.cs => GetResourceDocsTool.cs} (71%) diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index b9885e57cf1..d20494471ad 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -436,13 +436,13 @@ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardM public DateTimeOffset ConnectedAt { get; } /// - /// Gets agent content for a specific resource in the AppHost. + /// Gets documentation for a specific resource in the AppHost. /// /// The name of the resource. /// A cancellation token. - /// The agent content text, or null if the resource doesn't have agent content configured. - public async Task GetResourceAgentContentAsync(string resourceName, CancellationToken cancellationToken = default) + /// The documentation text for the resource, or null if the resource doesn't exist. + public async Task GetResourceDocsAsync(string resourceName, CancellationToken cancellationToken = default) { - return await Rpc.InvokeWithCancellationAsync("GetResourceAgentContentAsync", [resourceName], cancellationToken).ConfigureAwait(false); + return await Rpc.InvokeWithCancellationAsync("GetResourceDocsAsync", [resourceName], cancellationToken).ConfigureAwait(false); } } diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index 16a3da0a080..d87295b279e 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -44,7 +44,7 @@ public McpStartCommand(IInteractionService interactionService, IFeatures feature ["select_apphost"] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), ["list_integrations"] = new ListIntegrationsTool(packagingService, executionContext), - ["get_resource_content"] = new GetResourceContentTool(auxiliaryBackchannelMonitor, executionContext) + ["get_resource_docs"] = new GetResourceDocsTool(auxiliaryBackchannelMonitor, executionContext) }; } @@ -104,7 +104,7 @@ private async ValueTask HandleCallToolAsync(RequestContext -/// MCP tool for getting agent content from a resource in the AppHost. +/// MCP tool for getting documentation and guidance for a specific resource in the AppHost. /// -internal sealed class GetResourceContentTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, CliExecutionContext executionContext) : CliMcpTool +internal sealed class GetResourceDocsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, CliExecutionContext executionContext) : CliMcpTool { - public override string Name => "get_resource_content"; + public override string Name => "get_resource_docs"; - public override string Description => "Get agent content from a resource in the AppHost. Resources can expose content specifically for AI agents via the WithAgentContent API. This tool invokes the callback on the resource to retrieve contextual information."; + public override string Description => "IMPORTANT: Before writing ANY Aspire code that interacts with a specific resource (such as configuring, connecting to, or using a resource), you MUST call this tool first to get resource-specific documentation and guidance. This tool retrieves essential information about how to properly work with the resource, including connection patterns, configuration options, and best practices. Failing to consult this documentation may result in incorrect or suboptimal code."; public override JsonElement GetInputSchema() { @@ -24,12 +24,12 @@ public override JsonElement GetInputSchema() "properties": { "resourceName": { "type": "string", - "description": "The name of the resource to get content from." + "description": "The name of the resource to get documentation for." } }, "required": ["resourceName"], "additionalProperties": false, - "description": "Gets agent content from a specific resource. The resource must have been configured with WithAgentContent in the AppHost." + "description": "Gets documentation and guidance for a specific resource. MUST be called before writing any code that interacts with the resource." } """).RootElement; } @@ -83,14 +83,15 @@ public override async ValueTask CallToolAsync(ModelContextProtoc }; } - // Call the RPC method to get agent content from the resource - var content = await selectedConnection.GetResourceAgentContentAsync(resourceName, cancellationToken); + // Call the RPC method to get documentation for the resource + var content = await selectedConnection.GetResourceDocsAsync(resourceName, cancellationToken); if (content == null) { return new CallToolResult { - Content = [new TextContentBlock { Text = $"Resource '{resourceName}' does not have any agent content configured." }] + IsError = true, + Content = [new TextContentBlock { Text = $"Resource '{resourceName}' was not found in the application model." }] }; } @@ -104,7 +105,7 @@ public override async ValueTask CallToolAsync(ModelContextProtoc return new CallToolResult { IsError = true, - Content = [new TextContentBlock { Text = $"Failed to get resource content: {ex.Message}" }] + Content = [new TextContentBlock { Text = $"Failed to get resource documentation: {ex.Message}" }] }; } } diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 17c0c910aa9..741d7ae9d44 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Reflection; +using System.Text; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; using Microsoft.Extensions.Configuration; @@ -128,12 +130,12 @@ public Task GetAppHostInformationAsync(CancellationToken can } /// - /// Gets agent content for a specific resource in the AppHost. + /// Gets documentation for a specific resource in the AppHost. /// /// The name of the resource. /// A cancellation token. - /// The agent content text, or null if the resource doesn't have agent content configured. - public async Task GetResourceAgentContentAsync(string resourceName, CancellationToken cancellationToken = default) + /// The documentation text for the resource, or null if the resource doesn't exist. + public async Task GetResourceDocsAsync(string resourceName, CancellationToken cancellationToken = default) { var appModel = serviceProvider.GetService(); if (appModel is null) @@ -154,24 +156,73 @@ public Task GetAppHostInformationAsync(CancellationToken can // Check if the resource has an AgentContentAnnotation #pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - if (!resource.TryGetLastAnnotation(out var annotation)) + if (resource.TryGetLastAnnotation(out var annotation)) { - logger.LogDebug("Resource '{ResourceName}' does not have agent content configured.", resourceName); - return null; + try + { + // Create the context and invoke the callback + var context = new AgentContentContext(resource, serviceProvider, cancellationToken); + await annotation.Callback(context).ConfigureAwait(false); + return context.GetContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting agent content for resource '{ResourceName}'.", resourceName); + // Fall through to generate generic documentation + } + } +#pragma warning restore ASPIREAGENTCONTENT001 + + // Generate generic documentation based on the resource type's assembly + return GenerateGenericResourceDocs(resource); + } + + private static string GenerateGenericResourceDocs(IResource resource) + { + var resourceType = resource.GetType(); + var assembly = resourceType.Assembly; + var assemblyName = assembly.GetName().Name; + + // Get the version from AssemblyInformationalVersionAttribute (which contains the NuGet package version) + var version = assembly.GetCustomAttribute()?.InformationalVersion; + + // Strip the commit hash suffix if present (e.g., "9.0.0+abc123" -> "9.0.0") + if (version is not null) + { + var plusIndex = version.IndexOf('+'); + if (plusIndex > 0) + { + version = version[..plusIndex]; + } } - try + // Fall back to assembly version if informational version is not available + version ??= assembly.GetName().Version?.ToString(); + + var sb = new StringBuilder(); + sb.Append("# ").AppendLine(resourceType.Name); + sb.AppendLine(); + sb.Append("This is a `").Append(resourceType.Name).Append("` resource from the `").Append(assemblyName).AppendLine("` package."); + sb.AppendLine(); + sb.AppendLine("## Documentation"); + sb.AppendLine(); + sb.AppendLine("For detailed documentation on how to configure and use this resource, including connection patterns, configuration options, and best practices, see the package README:"); + sb.AppendLine(); + + if (assemblyName is not null && version is not null) { - // Create the context and invoke the callback - var context = new AgentContentContext(resource, serviceProvider, cancellationToken); - await annotation.Callback(context).ConfigureAwait(false); - return context.GetContent(); + sb.Append("https://www.nuget.org/packages/").Append(assemblyName).Append('/').Append(version).AppendLine("#readme-body-tab"); } - catch (Exception ex) + else if (assemblyName is not null) { - logger.LogError(ex, "Error getting agent content for resource '{ResourceName}'.", resourceName); - return null; + sb.Append("https://www.nuget.org/packages/").Append(assemblyName).AppendLine("#readme-body-tab"); } -#pragma warning restore ASPIREAGENTCONTENT001 + + sb.AppendLine(); + sb.AppendLine("## Note"); + sb.AppendLine(); + sb.AppendLine("This resource does not have custom agent documentation configured. The resource author can add detailed guidance for AI agents by using the `WithAgentContent` API."); + + return sb.ToString(); } } From 6ed99c456b386a835d60ba851c7774f0af5b19d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 22:51:45 +0000 Subject: [PATCH 15/16] Remove AgentContent APIs and GetResourceDocsTool, add GetIntegrationDocsTool Co-authored-by: mitchdenny <513398+mitchdenny@users.noreply.github.com> --- .../AuxiliaryBackchannelMonitor.cs | 11 -- src/Aspire.Cli/Commands/McpStartCommand.cs | 2 +- src/Aspire.Cli/Mcp/GetIntegrationDocsTool.cs | 106 +++++++++++++++ src/Aspire.Cli/Mcp/GetResourceDocsTool.cs | 112 --------------- src/Aspire.Cli/Mcp/ListIntegrationsTool.cs | 2 +- .../PostgresBuilderExtensions.cs | 128 +----------------- .../AgentContentAnnotation.cs | 103 -------------- .../AuxiliaryBackchannelRpcTarget.cs | 99 -------------- .../ResourceBuilderExtensions.cs | 29 ---- 9 files changed, 109 insertions(+), 483 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/GetIntegrationDocsTool.cs delete mode 100644 src/Aspire.Cli/Mcp/GetResourceDocsTool.cs delete mode 100644 src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index d20494471ad..8c7033be5d5 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -434,15 +434,4 @@ public AppHostConnection(string hash, string socketPath, JsonRpc rpc, DashboardM /// Gets the timestamp when this connection was established. /// public DateTimeOffset ConnectedAt { get; } - - /// - /// Gets documentation for a specific resource in the AppHost. - /// - /// The name of the resource. - /// A cancellation token. - /// The documentation text for the resource, or null if the resource doesn't exist. - public async Task GetResourceDocsAsync(string resourceName, CancellationToken cancellationToken = default) - { - return await Rpc.InvokeWithCancellationAsync("GetResourceDocsAsync", [resourceName], cancellationToken).ConfigureAwait(false); - } } diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index d87295b279e..ebe60b6a5bb 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -44,7 +44,7 @@ public McpStartCommand(IInteractionService interactionService, IFeatures feature ["select_apphost"] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), ["list_integrations"] = new ListIntegrationsTool(packagingService, executionContext), - ["get_resource_docs"] = new GetResourceDocsTool(auxiliaryBackchannelMonitor, executionContext) + ["get_integration_docs"] = new GetIntegrationDocsTool() }; } diff --git a/src/Aspire.Cli/Mcp/GetIntegrationDocsTool.cs b/src/Aspire.Cli/Mcp/GetIntegrationDocsTool.cs new file mode 100644 index 00000000000..8d5dea1bf71 --- /dev/null +++ b/src/Aspire.Cli/Mcp/GetIntegrationDocsTool.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp; + +/// +/// MCP tool for getting documentation for a specific Aspire hosting integration. +/// +internal sealed class GetIntegrationDocsTool : CliMcpTool +{ + public override string Name => "get_integration_docs"; + + public override string Description => "Gets documentation for a specific Aspire hosting integration package. Use this tool to get detailed information about how to use an integration within the AppHost."; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "packageId": { + "type": "string", + "description": "The NuGet package ID of the integration (e.g., 'Aspire.Hosting.Redis')." + }, + "packageVersion": { + "type": "string", + "description": "The version of the package (e.g., '9.0.0')." + } + }, + "required": ["packageId", "packageVersion"], + "additionalProperties": false, + "description": "Gets documentation for a specific Aspire hosting integration. Requires the package ID and version." + } + """).RootElement; + } + + public override ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + // This tool does not use the MCP client as it operates locally + _ = mcpClient; + _ = cancellationToken; + + if (arguments == null) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "Arguments are required." }] + }); + } + + if (!arguments.TryGetValue("packageId", out var packageIdElement)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageId' parameter is required." }] + }); + } + + var packageId = packageIdElement.GetString(); + if (string.IsNullOrEmpty(packageId)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageId' parameter cannot be empty." }] + }); + } + + if (!arguments.TryGetValue("packageVersion", out var packageVersionElement)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageVersion' parameter is required." }] + }); + } + + var packageVersion = packageVersionElement.GetString(); + if (string.IsNullOrEmpty(packageVersion)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageVersion' parameter cannot be empty." }] + }); + } + + var content = $""" + Instructions for the {packageId} integration can be downloaded from: + + https://www.nuget.org/packages/{packageId}/{packageVersion} + + Review this documentation for instructions on how to use this package within the apphost. Refer to linked documentation for additional information. + """; + + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = content }] + }); + } +} diff --git a/src/Aspire.Cli/Mcp/GetResourceDocsTool.cs b/src/Aspire.Cli/Mcp/GetResourceDocsTool.cs deleted file mode 100644 index b6b2d8381b4..00000000000 --- a/src/Aspire.Cli/Mcp/GetResourceDocsTool.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using Aspire.Cli.Backchannel; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -/// -/// MCP tool for getting documentation and guidance for a specific resource in the AppHost. -/// -internal sealed class GetResourceDocsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, CliExecutionContext executionContext) : CliMcpTool -{ - public override string Name => "get_resource_docs"; - - public override string Description => "IMPORTANT: Before writing ANY Aspire code that interacts with a specific resource (such as configuring, connecting to, or using a resource), you MUST call this tool first to get resource-specific documentation and guidance. This tool retrieves essential information about how to properly work with the resource, including connection patterns, configuration options, and best practices. Failing to consult this documentation may result in incorrect or suboptimal code."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "resourceName": { - "type": "string", - "description": "The name of the resource to get documentation for." - } - }, - "required": ["resourceName"], - "additionalProperties": false, - "description": "Gets documentation and guidance for a specific resource. MUST be called before writing any code that interacts with the resource." - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // This tool does not use the MCP client as it operates through the auxiliary backchannel - _ = mcpClient; - - if (arguments == null || !arguments.TryGetValue("resourceName", out var resourceNameElement)) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = "The 'resourceName' parameter is required." }] - }; - } - - var resourceName = resourceNameElement.GetString(); - if (string.IsNullOrEmpty(resourceName)) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = "The 'resourceName' parameter cannot be empty." }] - }; - } - - try - { - // Find all connections from AppHosts that were started within our working directory - var connections = auxiliaryBackchannelMonitor.GetConnectionsForWorkingDirectory(executionContext.WorkingDirectory); - - if (connections.Count == 0) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = "No Aspire AppHost is currently running. Start an Aspire application with 'aspire run' first." }] - }; - } - - // Get the selected connection - var selectedConnection = auxiliaryBackchannelMonitor.SelectedConnection; - if (selectedConnection == null) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = "No AppHost is selected. Use select_apphost to select an AppHost first." }] - }; - } - - // Call the RPC method to get documentation for the resource - var content = await selectedConnection.GetResourceDocsAsync(resourceName, cancellationToken); - - if (content == null) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"Resource '{resourceName}' was not found in the application model." }] - }; - } - - return new CallToolResult - { - Content = [new TextContentBlock { Text = content }] - }; - } - catch (Exception ex) - { - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"Failed to get resource documentation: {ex.Message}" }] - }; - } - } -} diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs index d3717f32453..432f9221587 100644 --- a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -52,7 +52,7 @@ internal sealed class ListIntegrationsTool(IPackagingService packagingService, C { public override string Name => "list_integrations"; - public override string Description => "List available Aspire hosting integrations. These are NuGet packages that can be added to an Aspire AppHost project to integrate with various services like databases, message brokers, and cloud services. Use 'aspire add ' to add an integration to your AppHost project. This tool does not require a running AppHost."; + public override string Description => "List available Aspire hosting integrations. These are NuGet packages that can be added to an Aspire AppHost project to integrate with various services like databases, message brokers, and cloud services. Use 'aspire add ' to add an integration to your AppHost project. Use the 'get_integration_docs' tool to get detailed documentation for a specific integration. This tool does not require a running AppHost."; public override JsonElement GetInputSchema() { diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 870823cd1be..b2f6c216297 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -116,8 +116,7 @@ public static IResourceBuilder AddPostgres(this IDistrib context.EnvironmentVariables[UserEnvVarName] = postgresServer.UserNameReference; context.EnvironmentVariables[PasswordEnvVarName] = postgresServer.PasswordParameter; }) - .WithHealthCheck(healthCheckKey) - .WithAgentContent(); + .WithHealthCheck(healthCheckKey); } /// @@ -601,129 +600,4 @@ private static async Task CreateDatabaseAsync(NpgsqlConnection npgsqlConnection, logger.LogError(e, "Failed to create database '{DatabaseName}'", npgsqlDatabase.DatabaseName); } } - - private static IResourceBuilder WithAgentContent(this IResourceBuilder builder) - { -#pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - return builder.WithAgentContent((context => - { - if (context.Resource is not PostgresServerResource resource) - { - return Task.CompletedTask; - } - - context.AppendText( - $$""" - - # {{resource.Name}} information - - The '{{resource.Name}}' is a Postgres database server. It is managed by Aspire and is running - as a container application. The following information relates to this specific instance of the - resource. From time to time the model should refresh this information especially if previous - information does not seem accurate anymore. This information can only be fetched - if the apphost is running. - """ - ); - - context.AppendLine("\n## Databases"); - if (resource.Databases.Any()) - { - foreach (var db in resource.Databases) - { - context.AppendText($"- `{db.Key} (resource name in app model: {db.Value})`"); - } - } - else - { - context.AppendText("No databases have been defined on this resource yet."); - } - - context.AppendLine("\n## Bind Mounts"); - var bindMounts = resource.Annotations.OfType() - .Where(m => m.Type == ContainerMountType.BindMount); - if (bindMounts.Any()) - { - foreach (var mount in bindMounts) - { - context.AppendText($"- Source: `{mount.Source}` -> Target: `{mount.Target}` (ReadOnly: {mount.IsReadOnly})"); - } - } - else - { - context.AppendText("No bind mounts have been defined on this resource."); - } - - context.AppendLine("\n## Data Volumes"); - var volumes = resource.Annotations.OfType() - .Where(m => m.Type == ContainerMountType.Volume); - if (volumes.Any()) - { - foreach (var volume in volumes) - { - context.AppendText($"- Name: `{volume.Source}` -> Target: `{volume.Target}` (ReadOnly: {volume.IsReadOnly})"); - } - } - else - { - context.AppendText("No data volumes have been defined on this resource."); - } - - context.AppendLine("\n## Development guidance"); - - context.AppendText( - $$""" - The following guidance provides useful information for connecting to the {{resource.Name}} Postgres server - from application code running within Aspire. - - ### Scripting databases - - ### .NET - client connectivity - To connect to the Postgres server from a .NET application add a reference to the .NET application - in the app model using the `WithReference(...)` extension method. This will automatically inject - connection strings into the app at runtime. - - - Aspire includes specific client libraries which simplify the configuration of database connections - to Postgres. To use these libraries install the `Aspire.Npgsql` package an use the following code - in the application entrypoint (assuming the use of the host builder). - - ```csharp - builder.AddNpgsqlDatasource("{{resource.Name}}"); - ``` - - ### Python - client connectivity - The most popular PostgreSQL client driver for Python is `psycopg2` (or `psycopg` for the newer version 3). - Install it using pip: - - ```bash - pip install psycopg2-binary - ``` - - Example usage: - - ```python - import os - import psycopg2 - - conn = psycopg2.connect( - host=os.environ["{{resource.Name}}_HOST"], - port=os.environ["{{resource.Name}}_PORT"], - user=os.environ["{{resource.Name}}_USERNAME"], - password=os.environ["{{resource.Name}}_PASSWORD"], - database="your_database_name" - ) - - cursor = conn.cursor() - cursor.execute("SELECT * FROM your_table") - results = cursor.fetchall() - cursor.close() - conn.close() - ``` - """ - ); - - return Task.CompletedTask; - })); -#pragma warning restore ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - } } diff --git a/src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs deleted file mode 100644 index 8b6f7f4bd58..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/AgentContentAnnotation.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// Represents a context for generating agent content from a resource. -/// -[Experimental("ASPIREAGENTCONTENT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public sealed class AgentContentContext -{ - private readonly List _contentParts = []; - - /// - /// Initializes a new instance of the class. - /// - /// The resource for which agent content is being generated. - /// The service provider for accessing application services. - /// A cancellation token. - public AgentContentContext(IResource resource, IServiceProvider serviceProvider, CancellationToken cancellationToken) - { - Resource = resource; - ServiceProvider = serviceProvider; - CancellationToken = cancellationToken; - } - - /// - /// Gets the resource for which agent content is being generated. - /// - public IResource Resource { get; } - - /// - /// Gets the service provider for accessing application services. - /// - public IServiceProvider ServiceProvider { get; } - - /// - /// Gets the cancellation token. - /// - public CancellationToken CancellationToken { get; } - - /// - /// Appends text content to the agent content. - /// - /// The text to append. - public void AppendText(string text) - { - ArgumentNullException.ThrowIfNull(text); - _contentParts.Add(text); - } - - /// - /// Appends a line of text content to the agent content. - /// - /// The text to append. - public void AppendLine(string text) - { - ArgumentNullException.ThrowIfNull(text); - _contentParts.Add(text + Environment.NewLine); - } - - /// - /// Appends an empty line to the agent content. - /// - public void AppendLine() - { - _contentParts.Add(Environment.NewLine); - } - - /// - /// Gets the combined agent content text. - /// - /// The combined content as a single string. - internal string GetContent() - { - return string.Concat(_contentParts); - } -} - -/// -/// Represents an annotation that provides agent content for a resource. -/// Agent content is text that can be retrieved by AI agents to understand the resource. -/// -[Experimental("ASPIREAGENTCONTENT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public sealed class AgentContentAnnotation : IResourceAnnotation -{ - /// - /// Initializes a new instance of the class. - /// - /// The callback to invoke to generate agent content. - public AgentContentAnnotation(Func callback) - { - ArgumentNullException.ThrowIfNull(callback); - Callback = callback; - } - - /// - /// Gets the callback that generates agent content for the resource. - /// - public Func Callback { get; } -} diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 741d7ae9d44..3526676d504 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; -using System.Text; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; using Microsoft.Extensions.Configuration; @@ -128,101 +126,4 @@ public Task GetAppHostInformationAsync(CancellationToken can ApiToken = mcpApiKey }; } - - /// - /// Gets documentation for a specific resource in the AppHost. - /// - /// The name of the resource. - /// A cancellation token. - /// The documentation text for the resource, or null if the resource doesn't exist. - public async Task GetResourceDocsAsync(string resourceName, CancellationToken cancellationToken = default) - { - var appModel = serviceProvider.GetService(); - if (appModel is null) - { - logger.LogWarning("Application model not found."); - return null; - } - - // Find the resource by name - var resource = appModel.Resources.FirstOrDefault(r => - string.Equals(r.Name, resourceName, StringComparisons.ResourceName)); - - if (resource is null) - { - logger.LogDebug("Resource '{ResourceName}' not found in application model.", resourceName); - return null; - } - - // Check if the resource has an AgentContentAnnotation -#pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - if (resource.TryGetLastAnnotation(out var annotation)) - { - try - { - // Create the context and invoke the callback - var context = new AgentContentContext(resource, serviceProvider, cancellationToken); - await annotation.Callback(context).ConfigureAwait(false); - return context.GetContent(); - } - catch (Exception ex) - { - logger.LogError(ex, "Error getting agent content for resource '{ResourceName}'.", resourceName); - // Fall through to generate generic documentation - } - } -#pragma warning restore ASPIREAGENTCONTENT001 - - // Generate generic documentation based on the resource type's assembly - return GenerateGenericResourceDocs(resource); - } - - private static string GenerateGenericResourceDocs(IResource resource) - { - var resourceType = resource.GetType(); - var assembly = resourceType.Assembly; - var assemblyName = assembly.GetName().Name; - - // Get the version from AssemblyInformationalVersionAttribute (which contains the NuGet package version) - var version = assembly.GetCustomAttribute()?.InformationalVersion; - - // Strip the commit hash suffix if present (e.g., "9.0.0+abc123" -> "9.0.0") - if (version is not null) - { - var plusIndex = version.IndexOf('+'); - if (plusIndex > 0) - { - version = version[..plusIndex]; - } - } - - // Fall back to assembly version if informational version is not available - version ??= assembly.GetName().Version?.ToString(); - - var sb = new StringBuilder(); - sb.Append("# ").AppendLine(resourceType.Name); - sb.AppendLine(); - sb.Append("This is a `").Append(resourceType.Name).Append("` resource from the `").Append(assemblyName).AppendLine("` package."); - sb.AppendLine(); - sb.AppendLine("## Documentation"); - sb.AppendLine(); - sb.AppendLine("For detailed documentation on how to configure and use this resource, including connection patterns, configuration options, and best practices, see the package README:"); - sb.AppendLine(); - - if (assemblyName is not null && version is not null) - { - sb.Append("https://www.nuget.org/packages/").Append(assemblyName).Append('/').Append(version).AppendLine("#readme-body-tab"); - } - else if (assemblyName is not null) - { - sb.Append("https://www.nuget.org/packages/").Append(assemblyName).AppendLine("#readme-body-tab"); - } - - sb.AppendLine(); - sb.AppendLine("## Note"); - sb.AppendLine(); - sb.AppendLine("This resource does not have custom agent documentation configured. The resource author can add detailed guidance for AI agents by using the `WithAgentContent` API."); - - return sb.ToString(); - } } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 7fce50f86b4..d28fcdd0fb5 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3182,33 +3182,4 @@ public static IResourceBuilder WithRemoteImageTag( context.Options.RemoteImageTag = remoteImageTag; }); } - - /// - /// Configures agent content for the resource. Agent content is text that can be retrieved by AI agents - /// to understand the resource and its configuration. - /// - /// The resource type. - /// The resource builder. - /// A callback that generates agent content using the provided context. - /// The . - /// - /// - /// var postgres = builder.AddPostgres("pg") - /// .WithAgentContent(async context => - /// { - /// context.AppendLine("This is a PostgreSQL database used for user data."); - /// context.AppendLine("Connection string format: Host=...;Database=...;Username=...;Password=..."); - /// }); - /// - /// - [Experimental("ASPIREAGENTCONTENT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - public static IResourceBuilder WithAgentContent(this IResourceBuilder builder, Func callback) where T : IResource - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(callback); - -#pragma warning disable ASPIREAGENTCONTENT001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - return builder.WithAnnotation(new AgentContentAnnotation(callback), ResourceAnnotationMutationBehavior.Replace); -#pragma warning restore ASPIREAGENTCONTENT001 - } } From 244698c391a9e12d0f3db2c05654c7829f68efb2 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 5 Dec 2025 13:06:43 +1100 Subject: [PATCH 16/16] Make get_integration_docs not rely on the apphost being alive. --- src/Aspire.Cli/Commands/McpStartCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index ebe60b6a5bb..63dd431e8d8 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -104,7 +104,7 @@ private async ValueTask HandleCallToolAsync(RequestContext