From 205eb7d884d1e7e5fd9e6c1f7b9082053f58108f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 4 Feb 2026 22:36:52 +0800 Subject: [PATCH 1/4] Lazy index docs in MCP tools and report progress --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 32 +++++----- src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs | 8 +++ src/Aspire.Cli/Mcp/IMcpNotifier.cs | 20 ++++++ src/Aspire.Cli/Mcp/McpServerNotifier.cs | 24 +++++++ src/Aspire.Cli/Mcp/Tools/CallToolContext.cs | 33 ++++++++++ src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs | 7 +-- src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs | 63 +++++++++++++++++++ src/Aspire.Cli/Mcp/Tools/DoctorTool.cs | 5 +- .../Mcp/Tools/ExecuteResourceCommandTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/GetDocTool.cs | 7 ++- src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs | 5 +- .../Mcp/Tools/ListConsoleLogsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs | 6 +- .../Mcp/Tools/ListIntegrationsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 5 +- .../Mcp/Tools/ListStructuredLogsTool.cs | 8 +-- .../Mcp/Tools/ListTraceStructuredLogsTool.cs | 8 +-- src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs | 8 +-- src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs | 11 ++-- src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs | 5 +- .../Commands/DocsCommandTests.cs | 2 + .../AppHostConnectionSelectionLogicTests.cs | 1 + .../Mcp/ExecuteResourceCommandToolTests.cs | 19 +++--- .../Mcp/ListAppHostsToolTests.cs | 13 ++-- .../Mcp/ListConsoleLogsToolTests.cs | 15 ++--- .../Mcp/ListIntegrationsToolTests.cs | 8 ++- .../Mcp/ListResourcesToolTests.cs | 11 ++-- .../Mcp/MockPackagingService.cs | 1 + .../Mcp/TestDocsIndexService.cs | 3 + .../Mcp/TestMcpServerTransport.cs | 1 + .../TestServices/CallToolContextTestHelper.cs | 39 ++++++++++++ .../TestServices/TestMcpNotifier.cs | 33 ++++++++++ 33 files changed, 324 insertions(+), 97 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/IMcpNotifier.cs create mode 100644 src/Aspire.Cli/Mcp/McpServerNotifier.cs create mode 100644 src/Aspire.Cli/Mcp/Tools/CallToolContext.cs create mode 100644 src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index ef5a35719d5..2e335fb2cfc 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -83,7 +83,7 @@ public AgentMcpCommand( [KnownMcpTools.Doctor] = new DoctorTool(environmentChecker), [KnownMcpTools.RefreshTools] = new RefreshToolsTool(RefreshResourceToolMapAsync, SendToolsListChangedNotificationAsync), [KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService), - [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService), + [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService), [KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService) }; } @@ -124,19 +124,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Keep a reference to the server for sending notifications _server = server; - // Start indexing aspire.dev documentation in the background (fire-and-forget) - _ = Task.Run(async () => - { - try - { - await _docsIndexService.EnsureIndexedAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to index aspire.dev documentation in background"); - } - }, cancellationToken); - // Starts the MCP server, it's blocking until cancellation is requested await server.RunAsync(cancellationToken); @@ -202,7 +189,14 @@ private async ValueTask HandleCallToolAsync(RequestContext CallDashboardToolAsync( try { _logger.LogDebug("Invoking CallToolAsync for tool {ToolName} with arguments: {Arguments}", toolName, arguments); - var result = await tool.CallToolAsync(mcpClient, arguments, cancellationToken).ConfigureAwait(false); + var context = new CallToolContext + { + Notifier = new McpServerNotifier(_server!), + McpClient = mcpClient, + Arguments = arguments + }; + var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Tool {ToolName} completed successfully", toolName); return result; } diff --git a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs index c4a3218c879..f706f41bcac 100644 --- a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs +++ b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs @@ -12,6 +12,11 @@ namespace Aspire.Cli.Mcp.Docs; /// internal interface IDocsIndexService { + /// + /// Gets a value indicating whether the documentation has been indexed. + /// + bool IsIndexed { get; } + /// /// Ensures documentation is loaded and indexed. /// @@ -121,6 +126,9 @@ internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, IDocsCa private volatile List? _indexedDocuments; private readonly SemaphoreSlim _indexLock = new(1, 1); + /// + public bool IsIndexed => _indexedDocuments is not null; + public async ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { if (_indexedDocuments is not null) diff --git a/src/Aspire.Cli/Mcp/IMcpNotifier.cs b/src/Aspire.Cli/Mcp/IMcpNotifier.cs new file mode 100644 index 00000000000..6556e1be712 --- /dev/null +++ b/src/Aspire.Cli/Mcp/IMcpNotifier.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Mcp; + +/// +/// Interface for sending MCP notifications. +/// +internal interface IMcpNotifier +{ + /// + /// Sends a notification to the MCP client. + /// + /// The notification method name. + /// The cancellation token. + /// A task representing the asynchronous operation. + Task SendNotificationAsync(string method, CancellationToken cancellationToken = default); + + Task SendNotificationAsync(string method, TParams parameters, CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Mcp/McpServerNotifier.cs b/src/Aspire.Cli/Mcp/McpServerNotifier.cs new file mode 100644 index 00000000000..fc271c6c2a6 --- /dev/null +++ b/src/Aspire.Cli/Mcp/McpServerNotifier.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ModelContextProtocol.Server; + +namespace Aspire.Cli.Mcp; + +/// +/// Implementation of that wraps an . +/// +internal sealed class McpServerNotifier(McpServer server) : IMcpNotifier +{ + /// + public Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) + { + return server.SendNotificationAsync(method, cancellationToken); + } + + /// + public Task SendNotificationAsync(string method, TParams parameters, CancellationToken cancellationToken = default) + { + return server.SendNotificationAsync(method, parameters, cancellationToken: cancellationToken); + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs b/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs new file mode 100644 index 00000000000..c53d56508a2 --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs @@ -0,0 +1,33 @@ +// 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.Tools; + +/// +/// Provides context for executing MCP tools. +/// +internal sealed class CallToolContext +{ + /// + /// Gets the MCP notifier for sending notifications. + /// + public required IMcpNotifier Notifier { get; init; } + + /// + /// Gets the MCP client instance to use for communicating with the dashboard. + /// + public required ModelContextProtocol.Client.McpClient? McpClient { get; init; } + + /// + /// Gets the arguments passed to the tool. + /// + public required IReadOnlyDictionary? Arguments { get; init; } + + /// + /// Gets the progress token for reporting progress updates, if provided by the client. + /// + public ProgressToken? ProgressToken { get; init; } +} diff --git a/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs b/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs index bd6cd221024..f69d8c8bac8 100644 --- a/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs @@ -28,11 +28,10 @@ internal abstract class CliMcpTool public abstract JsonElement GetInputSchema(); /// - /// Executes the tool with the provided arguments. + /// Executes the tool with the provided context. /// - /// The MCP client instance to use for communicating with the dashboard. - /// The arguments passed to the tool. + /// The call context containing the MCP server, client, and arguments. /// The cancellation token. /// The result of the tool execution. - public abstract ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken); + public abstract ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken); } diff --git a/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs b/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs new file mode 100644 index 00000000000..df738931e60 --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs @@ -0,0 +1,63 @@ +// 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.Mcp.Docs; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp.Tools; + +/// +/// Helper methods for documentation tool operations. +/// +internal static class DocsToolHelper +{ + /// + /// Ensures the documentation index is ready, sending progress notifications if indexing is needed. + /// + public static async ValueTask EnsureIndexedWithNotificationsAsync( + IDocsIndexService docsIndexService, + ProgressToken? progressToken, + IMcpNotifier notifier, + CancellationToken cancellationToken) + { + if (docsIndexService.IsIndexed) + { + return; + } + + if (progressToken != null) + { + await notifier.SendNotificationAsync( + NotificationMethods.ProgressNotification, + new ProgressNotificationParams + { + ProgressToken = progressToken.Value, + Progress = new ProgressNotificationValue + { + Message = "Indexing Aspire documentation...", + Progress = 1, + Total = 2 + } + }, cancellationToken).ConfigureAwait(false); + } + + await docsIndexService.EnsureIndexedAsync(cancellationToken).ConfigureAwait(false); + + if (progressToken != null) + { + await notifier.SendNotificationAsync( + NotificationMethods.ProgressNotification, + new ProgressNotificationParams + { + ProgressToken = progressToken.Value, + Progress = new ProgressNotificationValue + { + Message = "Aspire documentation indexed.", + Progress = 2, + Total = 2 + } + }, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs index 541347375da..846fbea7715 100644 --- a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs @@ -28,11 +28,10 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client or arguments - _ = mcpClient; - _ = arguments; + _ = context; try { diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index cf285d044a5..b369d343c56 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -41,10 +41,9 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates via backchannel - _ = mcpClient; + var arguments = context.Arguments; if (arguments is null || !arguments.TryGetValue("resourceName", out var resourceNameElement) || diff --git a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs index 5dde5c0f5e1..0e21d6b3e11 100644 --- a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs @@ -45,11 +45,10 @@ public override JsonElement GetInputSchema() } public override async ValueTask CallToolAsync( - ModelContextProtocol.Client.McpClient mcpClient, - IReadOnlyDictionary? arguments, + CallToolContext context, CancellationToken cancellationToken) { - _ = mcpClient; + var arguments = context.Arguments; if (arguments is null || !arguments.TryGetValue("slug", out var slugElement)) { @@ -76,6 +75,8 @@ public override async ValueTask CallToolAsync( section = sectionElement.GetString(); } + await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); + var doc = await _docsIndexService.GetDocumentAsync(slug, section, cancellationToken).ConfigureAwait(false); if (doc is null) diff --git a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs index 4a8e4bb468c..14ac11b57a9 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs @@ -35,11 +35,10 @@ public override JsonElement GetInputSchema() return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates locally - _ = mcpClient; - _ = arguments; + _ = context; // Trigger an immediate scan to ensure we have the latest AppHost connections await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index 445b0710850..56fa8aade4f 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -36,10 +36,9 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates via backchannel - _ = mcpClient; + var arguments = context.Arguments; // Get the resource name from arguments string? resourceName = null; diff --git a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs index 429a6795891..6969f0925cd 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs @@ -39,12 +39,10 @@ public override JsonElement GetInputSchema() } public override async ValueTask CallToolAsync( - ModelContextProtocol.Client.McpClient mcpClient, - IReadOnlyDictionary? arguments, + CallToolContext context, CancellationToken cancellationToken) { - _ = mcpClient; - _ = arguments; + await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); var docs = await _docsIndexService.ListDocumentsAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs index 205820b64db..443702cb971 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs @@ -67,11 +67,10 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates locally - _ = mcpClient; - _ = arguments; + _ = context; try { diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 5eb3a39e7b7..8e579c8d115 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -55,11 +55,10 @@ public override JsonElement GetInputSchema() return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates via backchannel - _ = mcpClient; - _ = arguments; + _ = context; var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); if (connection is null) diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index ed9271dfcbc..17eab72cfe7 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -28,21 +28,21 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // Convert JsonElement arguments to Dictionary Dictionary? convertedArgs = null; - if (arguments != null) + if (context.Arguments != null) { convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + foreach (var kvp in context.Arguments) { convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; } } // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( + return await context.McpClient!.CallToolAsync( Name, convertedArgs, serializerOptions: McpJsonUtilities.DefaultOptions, diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 756f2c489b1..7127c546758 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -29,21 +29,21 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // Convert JsonElement arguments to Dictionary Dictionary? convertedArgs = null; - if (arguments != null) + if (context.Arguments != null) { convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + foreach (var kvp in context.Arguments) { convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; } } // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( + return await context.McpClient!.CallToolAsync( Name, convertedArgs, serializerOptions: McpJsonUtilities.DefaultOptions, diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 498c5067e46..4e589bcffba 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -28,21 +28,21 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // Convert JsonElement arguments to Dictionary Dictionary? convertedArgs = null; - if (arguments != null) + if (context.Arguments != null) { convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + foreach (var kvp in context.Arguments) { convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; } } // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( + return await context.McpClient!.CallToolAsync( Name, convertedArgs, serializerOptions: McpJsonUtilities.DefaultOptions, diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs index c7caf14ed92..9eee648bf78 100644 --- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs @@ -17,10 +17,9 @@ public override JsonElement GetInputSchema() return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - _ = mcpClient; - _ = arguments; + _ = context; var count = await refreshToolsAsync(cancellationToken).ConfigureAwait(false); await sendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs index 4906ecb289f..8b1524bf9c7 100644 --- a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs @@ -10,9 +10,10 @@ namespace Aspire.Cli.Mcp.Tools; /// /// MCP tool for searching aspire.dev documentation using lexical search. /// -internal sealed class SearchDocsTool(IDocsSearchService docsSearchService) : CliMcpTool +internal sealed class SearchDocsTool(IDocsSearchService docsSearchService, IDocsIndexService docsIndexService) : CliMcpTool { private readonly IDocsSearchService _docsSearchService = docsSearchService; + private readonly IDocsIndexService _docsIndexService = docsIndexService; public override string Name => KnownMcpTools.SearchDocs; @@ -48,12 +49,10 @@ public override JsonElement GetInputSchema() } public override async ValueTask CallToolAsync( - ModelContextProtocol.Client.McpClient mcpClient, - IReadOnlyDictionary? arguments, + CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = mcpClient; + var arguments = context.Arguments; if (arguments is null || !arguments.TryGetValue("query", out var queryElement)) { @@ -80,6 +79,8 @@ public override async ValueTask CallToolAsync( topK = Math.Clamp(topKValue, 1, 10); } + await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); + var response = await _docsSearchService.SearchAsync(query, topK, cancellationToken); if (response is null) diff --git a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs index 012e88df8d7..1024bcc8331 100644 --- a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs @@ -32,12 +32,13 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates locally - _ = mcpClient; _ = cancellationToken; + var arguments = context.Arguments; + if (arguments == null || !arguments.TryGetValue("appHostPath", out var appHostPathElement)) { return ValueTask.FromResult(new CallToolResult diff --git a/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs index ad9791b6ab6..403692be9d4 100644 --- a/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs @@ -170,6 +170,8 @@ public async Task DocsGetCommand_WithInvalidSlug_ReturnsError() internal sealed class TestDocsIndexService : IDocsIndexService { + public bool IsIndexed => true; + public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; diff --git a/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs b/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs index 5fa4cc733c9..975669d056d 100644 --- a/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs @@ -76,3 +76,4 @@ private static AppHostAuxiliaryBackchannel CreateConnection(string hash, string isInScope); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index a83f51ff414..8e923c714ba 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -31,7 +31,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenNoAppHostRunnin var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, CreateArguments("test-resource", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("test-resource", "resource-start")), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); Assert.Contains("--detach", exception.Message); @@ -48,7 +48,7 @@ public async Task ExecuteResourceCommandTool_ReturnsSuccess_WhenCommandExecutedS monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-start"), CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-start")), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -77,7 +77,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandFails() var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, CreateArguments("nonexistent", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("nonexistent", "resource-start")), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Resource not found", exception.Message); } @@ -99,7 +99,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandCanceled var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, CreateArguments("api-service", "resource-stop"), CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-stop")), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("cancelled", exception.Message); } @@ -117,15 +117,15 @@ public async Task ExecuteResourceCommandTool_WorksWithKnownCommands() var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); // Test with resource-start - var startResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-start"), CancellationToken.None).DefaultTimeout(); + var startResult = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-start")), CancellationToken.None).DefaultTimeout(); Assert.True(startResult.IsError is null or false); // Test with resource-stop - var stopResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-stop"), CancellationToken.None).DefaultTimeout(); + var stopResult = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-stop")), CancellationToken.None).DefaultTimeout(); Assert.True(stopResult.IsError is null or false); // Test with resource-restart - var restartResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-restart"), CancellationToken.None).DefaultTimeout(); + var restartResult = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-restart")), CancellationToken.None).DefaultTimeout(); Assert.True(restartResult.IsError is null or false); } @@ -140,14 +140,15 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenMissingArgument // Test with null arguments var exception1 = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Missing required arguments", exception1.Message); // Test with only resourceName var partialArgs = JsonDocument.Parse("""{"resourceName": "test"}""").RootElement .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); var exception2 = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, partialArgs, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(partialArgs), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Missing required arguments", exception2.Message); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs index 8d7738f7698..736a02e2383 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs @@ -20,7 +20,7 @@ public async Task ListAppHostsTool_ReturnsEmptyListWhenNoConnections() var executionContext = CreateCliExecutionContext(workspace.WorkspaceRoot); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); Assert.NotNull(result.Content); @@ -55,7 +55,7 @@ public async Task ListAppHostsTool_ReturnsInScopeAppHosts() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -87,7 +87,7 @@ public async Task ListAppHostsTool_ReturnsOutOfScopeAppHosts() monitor.AddConnection("hash2", "socket.hash2", connection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -130,7 +130,7 @@ public async Task ListAppHostsTool_SeparatesInScopeAndOutOfScopeAppHosts() monitor.AddConnection("hash2", "socket.hash2", outOfScopeConnection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -154,12 +154,12 @@ public async Task ListAppHostsTool_CallsScanAsyncBeforeReturningResults() Assert.Equal(0, monitor.ScanCallCount); var tool = new ListAppHostsTool(monitor, executionContext); - await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Equal(1, monitor.ScanCallCount); // Call again to verify it scans each time - await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Equal(2, monitor.ScanCallCount); } @@ -178,3 +178,4 @@ private static AppHostAuxiliaryBackchannel CreateAppHostConnection(string hash, return new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, mcpInfo: null, appHostInfo, isInScope); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs index 7f7b23bf0cb..13a4cafb8ed 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -25,7 +25,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenNoAppHostRunning() }; var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, arguments, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); Assert.Contains("--detach", exception.Message); @@ -41,7 +41,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenResourceNameNotProvide var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("resourceName", exception.Message); } @@ -63,7 +63,7 @@ public async Task ListConsoleLogsTool_ReturnsLogs_WhenResourceHasNoLogs() ["resourceName"] = JsonDocument.Parse("\"test-resource\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -98,7 +98,7 @@ public async Task ListConsoleLogsTool_ReturnsLogs_ForSpecificResource() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -132,7 +132,7 @@ public async Task ListConsoleLogsTool_ReturnsPlainTextFormat() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -162,7 +162,7 @@ public async Task ListConsoleLogsTool_StripsTimestamps() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -191,7 +191,7 @@ public async Task ListConsoleLogsTool_StripsAnsiSequences() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -206,3 +206,4 @@ private static string ExtractCodeBlockContent(string text) return match.Success ? match.Groups[1].Value : string.Empty; } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs index ecb7e9ee88c..d99a2792030 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.InternalTesting; using System.Text.Json; using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; namespace Aspire.Cli.Tests.Mcp; @@ -49,7 +50,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenN var mockPackagingService = new MockPackagingService(); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -74,7 +75,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_Whe }); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -112,7 +113,7 @@ public async Task ListIntegrationsTool_UsesDefaultChannelOnly() }); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); @@ -122,3 +123,4 @@ public async Task ListIntegrationsTool_UsesDefaultChannelOnly() Assert.Equal(1, integrations.GetArrayLength()); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs index 26384f0af29..1d68fce864b 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs @@ -19,7 +19,7 @@ public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning() var tool = new ListResourcesTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); Assert.Contains("--detach", exception.Message); @@ -37,7 +37,7 @@ public async Task ListResourcesTool_ReturnsNoResourcesFound_WhenSnapshotsAreEmpt monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -82,7 +82,7 @@ public async Task ListResourcesTool_ReturnsMultipleResources() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -118,7 +118,7 @@ public async Task ListResourcesTool_IncludesEnvironmentVariableNamesButNotValues monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -159,7 +159,7 @@ public async Task ListResourcesTool_ReturnsValidJson() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -183,3 +183,4 @@ public async Task ListResourcesTool_ReturnsValidJson() Assert.Equal("Running", resource.GetProperty("state").GetString()); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 2733ad4a126..c1d55866cb9 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -77,3 +77,4 @@ public IReadOnlyList GetConnectionsForWorkingDirec return []; } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs index e464cdf51c9..a3a535c3227 100644 --- a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs @@ -17,6 +17,8 @@ internal sealed class TestDocsIndexService : IDocsIndexService new DocsListItem { Slug = "deployment/azure", Title = "Deploy to Azure", Summary = "Deploy your Aspire app to Azure" }, ]; + public bool IsIndexed => true; + public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; @@ -58,3 +60,4 @@ public ValueTask> SearchAsync(string query, int return ValueTask.FromResult>(results); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs b/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs index 8cdde5c7825..432366d7ae6 100644 --- a/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs +++ b/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs @@ -79,3 +79,4 @@ public void Dispose() CompletePipes(); } } + diff --git a/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs b/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs new file mode 100644 index 00000000000..44456c7996d --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs @@ -0,0 +1,39 @@ +// 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.Tools; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Provides helper methods for creating instances in tests. +/// +internal static class CallToolContextTestHelper +{ + /// + /// Creates a for testing with optional arguments. + /// + /// Optional arguments to pass to the tool. + /// A new configured for testing. + public static CallToolContext Create(IReadOnlyDictionary? arguments = null) + { + return Create(new TestMcpNotifier(), arguments); + } + + /// + /// Creates a for testing with a specific notifier and optional arguments. + /// + /// The notifier to use. + /// Optional arguments to pass to the tool. + /// A new configured for testing. + public static CallToolContext Create(TestMcpNotifier notifier, IReadOnlyDictionary? arguments = null) + { + return new CallToolContext + { + Notifier = notifier, + McpClient = null, + Arguments = arguments + }; + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs b/tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs new file mode 100644 index 00000000000..936f42cc7fc --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs @@ -0,0 +1,33 @@ +// 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.Mcp; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// A test implementation of that collects notifications. +/// +internal sealed class TestMcpNotifier : IMcpNotifier +{ + private readonly List _notifications = []; + + /// + /// Gets the list of notification methods that have been sent. + /// + public IReadOnlyList Notifications => _notifications; + + /// + public Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) + { + _notifications.Add(method); + return Task.CompletedTask; + } + + /// + public Task SendNotificationAsync(string method, TParams parameters, CancellationToken cancellationToken = default) + { + _notifications.Add(method); + return Task.CompletedTask; + } +} From 89e5227e3a8bc1ef09f5b9c21b9b433d9787703e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 4 Feb 2026 22:48:38 +0800 Subject: [PATCH 2/4] Improve notification --- src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs b/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs index df738931e60..b2ff0e533b9 100644 --- a/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs +++ b/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs @@ -35,9 +35,8 @@ await notifier.SendNotificationAsync( ProgressToken = progressToken.Value, Progress = new ProgressNotificationValue { - Message = "Indexing Aspire documentation...", - Progress = 1, - Total = 2 + Message = "Indexing Aspire docs...", + Progress = 1 } }, cancellationToken).ConfigureAwait(false); } @@ -53,9 +52,8 @@ await notifier.SendNotificationAsync( ProgressToken = progressToken.Value, Progress = new ProgressNotificationValue { - Message = "Aspire documentation indexed.", - Progress = 2, - Total = 2 + Message = "Aspire docs indexed", + Progress = 2 } }, cancellationToken).ConfigureAwait(false); } From 6f597cb76f3a5d5864754f3ee0e808d46d601a75 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 4 Feb 2026 23:08:51 +0800 Subject: [PATCH 3/4] Tests --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 6 +++-- src/Aspire.Cli/Mcp/Tools/CallToolContext.cs | 2 +- .../Mcp/TestDocsIndexService.cs | 26 +++++++++++++++++-- .../TestServices/CallToolContextTestHelper.cs | 25 +++++++----------- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 2e335fb2cfc..ce9106bc126 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -202,7 +202,7 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext CallDashboardToolAsync( string toolName, CliMcpTool tool, + ProgressToken? progressToken, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) { @@ -316,7 +317,8 @@ private async ValueTask CallDashboardToolAsync( { Notifier = new McpServerNotifier(_server!), McpClient = mcpClient, - Arguments = arguments + Arguments = arguments, + ProgressToken = progressToken }; var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Tool {ToolName} completed successfully", toolName); diff --git a/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs b/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs index c53d56508a2..e0ce091b24f 100644 --- a/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs +++ b/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs @@ -29,5 +29,5 @@ internal sealed class CallToolContext /// /// Gets the progress token for reporting progress updates, if provided by the client. /// - public ProgressToken? ProgressToken { get; init; } + public required ProgressToken? ProgressToken { get; init; } } diff --git a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs index a3a535c3227..1c559defb31 100644 --- a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs @@ -10,17 +10,39 @@ namespace Aspire.Cli.Tests.Mcp; /// internal sealed class TestDocsIndexService : IDocsIndexService { - private readonly List _documents = + private static readonly List s_defaultDocuments = [ new DocsListItem { Slug = "getting-started", Title = "Getting Started", Summary = "Learn how to get started with Aspire" }, new DocsListItem { Slug = "fundamentals/app-host", Title = "App Host", Summary = "Learn about the Aspire app host" }, new DocsListItem { Slug = "deployment/azure", Title = "Deploy to Azure", Summary = "Deploy your Aspire app to Azure" }, ]; - public bool IsIndexed => true; + private readonly List _documents; + private bool _isIndexed; + + /// + /// Creates a new instance with default documents and already indexed. + /// + public TestDocsIndexService() : this(s_defaultDocuments, isIndexed: true) + { + } + + /// + /// Creates a new instance with specified documents and indexing state. + /// + /// The documents to return. If null, uses default documents. + /// Whether the service starts in an indexed state. + public TestDocsIndexService(IEnumerable? documents, bool isIndexed = true) + { + _documents = documents?.ToList() ?? [.. s_defaultDocuments]; + _isIndexed = isIndexed; + } + + public bool IsIndexed => _isIndexed; public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { + _isIndexed = true; return ValueTask.CompletedTask; } diff --git a/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs b/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs index 44456c7996d..3afb22a32e5 100644 --- a/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs +++ b/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs @@ -12,28 +12,23 @@ namespace Aspire.Cli.Tests.TestServices; internal static class CallToolContextTestHelper { /// - /// Creates a for testing with optional arguments. + /// Creates a for testing. /// /// Optional arguments to pass to the tool. + /// Optional notifier to use. If null, a new is created. + /// Optional progress token to include in the context. /// A new configured for testing. - public static CallToolContext Create(IReadOnlyDictionary? arguments = null) - { - return Create(new TestMcpNotifier(), arguments); - } - - /// - /// Creates a for testing with a specific notifier and optional arguments. - /// - /// The notifier to use. - /// Optional arguments to pass to the tool. - /// A new configured for testing. - public static CallToolContext Create(TestMcpNotifier notifier, IReadOnlyDictionary? arguments = null) + public static CallToolContext Create( + IReadOnlyDictionary? arguments = null, + TestMcpNotifier? notifier = null, + string? progressToken = null) { return new CallToolContext { - Notifier = notifier, + Notifier = notifier ?? new TestMcpNotifier(), McpClient = null, - Arguments = arguments + Arguments = arguments, + ProgressToken = progressToken is not null ? new ModelContextProtocol.Protocol.ProgressToken(progressToken) : null }; } } From bee337c1245a1c694666e0d33d29e9f484d0909d Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 4 Feb 2026 23:09:14 +0800 Subject: [PATCH 4/4] Tests --- .../Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs diff --git a/tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs new file mode 100644 index 00000000000..11234d9cd15 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs @@ -0,0 +1,61 @@ +// 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.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListDocsToolTests +{ + [Fact] + public async Task ListDocsTool_CallToolAsync_ReturnsDocumentList() + { + var indexService = new TestDocsIndexService(); + var tool = new ListDocsTool(indexService); + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Aspire Documentation Pages", textContent.Text); + Assert.Contains("Getting Started", textContent.Text); + Assert.Contains("App Host", textContent.Text); + Assert.Contains("Deploy to Azure", textContent.Text); + } + + [Fact] + public async Task ListDocsTool_CallToolAsync_SendsProgressNotifications_WhenIndexingRequired() + { + var indexService = new TestDocsIndexService(documents: null, isIndexed: false); + var notifier = new TestMcpNotifier(); + var tool = new ListDocsTool(indexService); + + var context = CallToolContextTestHelper.Create(notifier: notifier, progressToken: "test-progress-token"); + var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.Contains(NotificationMethods.ProgressNotification, notifier.Notifications); + // Should have two progress notifications: start and complete + Assert.Equal(2, notifier.Notifications.Count(n => n == NotificationMethods.ProgressNotification)); + } + + [Fact] + public async Task ListDocsTool_CallToolAsync_DoesNotSendProgressNotifications_WhenAlreadyIndexed() + { + var indexService = new TestDocsIndexService(); // IsIndexed = true by default + var notifier = new TestMcpNotifier(); + var tool = new ListDocsTool(indexService); + + var context = CallToolContextTestHelper.Create(notifier: notifier, progressToken: "test-progress-token"); + var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.DoesNotContain(NotificationMethods.ProgressNotification, notifier.Notifications); + } +}