Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions src/Aspire.Cli/Commands/AgentMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
}
Expand Down Expand Up @@ -124,19 +124,6 @@ protected override async Task<int> 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);

Expand Down Expand Up @@ -202,13 +189,20 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT
if (KnownMcpTools.IsLocalTool(toolName))
{
var args = request.Params?.Arguments;
return await tool.CallToolAsync(null!, args, cancellationToken).ConfigureAwait(false);
var context = new CallToolContext
{
Notifier = new McpServerNotifier(_server!),
McpClient = null,
Arguments = args,
ProgressToken = request.Params?.ProgressToken
};
return await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false);
}

if (KnownMcpTools.IsDashboardTool(toolName))
{
var args = request.Params?.Arguments;
return await CallDashboardToolAsync(toolName, tool, args, cancellationToken).ConfigureAwait(false);
return await CallDashboardToolAsync(toolName, tool, request.Params?.ProgressToken, args, cancellationToken).ConfigureAwait(false);
}

// If a tool is registered in _tools, it must be classified as either local or dashboard-backed.
Expand Down Expand Up @@ -271,6 +265,7 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT
private async ValueTask<CallToolResult> CallDashboardToolAsync(
string toolName,
CliMcpTool tool,
ProgressToken? progressToken,
IReadOnlyDictionary<string, JsonElement>? arguments,
CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -318,7 +313,14 @@ private async ValueTask<CallToolResult> 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,
ProgressToken = progressToken
};
var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Tool {ToolName} completed successfully", toolName);
return result;
}
Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ namespace Aspire.Cli.Mcp.Docs;
/// </summary>
internal interface IDocsIndexService
{
/// <summary>
/// Gets a value indicating whether the documentation has been indexed.
/// </summary>
bool IsIndexed { get; }

/// <summary>
/// Ensures documentation is loaded and indexed.
/// </summary>
Expand Down Expand Up @@ -121,6 +126,9 @@ internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, IDocsCa
private volatile List<IndexedDocument>? _indexedDocuments;
private readonly SemaphoreSlim _indexLock = new(1, 1);

/// <inheritdoc />
public bool IsIndexed => _indexedDocuments is not null;

public async ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default)
{
if (_indexedDocuments is not null)
Expand Down
20 changes: 20 additions & 0 deletions src/Aspire.Cli/Mcp/IMcpNotifier.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Interface for sending MCP notifications.
/// </summary>
internal interface IMcpNotifier
{
/// <summary>
/// Sends a notification to the MCP client.
/// </summary>
/// <param name="method">The notification method name.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendNotificationAsync(string method, CancellationToken cancellationToken = default);

Task SendNotificationAsync<TParams>(string method, TParams parameters, CancellationToken cancellationToken = default);
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second SendNotificationAsync method overload is missing XML documentation. According to the custom guidelines (CodingGuidelineID: 1000002), all internal APIs should have at least a brief summary tag. Please add documentation for this method including param and returns tags.

Copilot generated this review using guidance from repository custom instructions.
}
24 changes: 24 additions & 0 deletions src/Aspire.Cli/Mcp/McpServerNotifier.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Implementation of <see cref="IMcpNotifier"/> that wraps an <see cref="McpServer"/>.
/// </summary>
internal sealed class McpServerNotifier(McpServer server) : IMcpNotifier
{
/// <inheritdoc />
public Task SendNotificationAsync(string method, CancellationToken cancellationToken = default)
{
return server.SendNotificationAsync(method, cancellationToken);
}

/// <inheritdoc />
public Task SendNotificationAsync<TParams>(string method, TParams parameters, CancellationToken cancellationToken = default)
{
return server.SendNotificationAsync(method, parameters, cancellationToken: cancellationToken);
}
}
33 changes: 33 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/CallToolContext.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides context for executing MCP tools.
/// </summary>
internal sealed class CallToolContext
{
/// <summary>
/// Gets the MCP notifier for sending notifications.
/// </summary>
public required IMcpNotifier Notifier { get; init; }

/// <summary>
/// Gets the MCP client instance to use for communicating with the dashboard.
/// </summary>
public required ModelContextProtocol.Client.McpClient? McpClient { get; init; }

/// <summary>
/// Gets the arguments passed to the tool.
/// </summary>
public required IReadOnlyDictionary<string, JsonElement>? Arguments { get; init; }

/// <summary>
/// Gets the progress token for reporting progress updates, if provided by the client.
/// </summary>
public required ProgressToken? ProgressToken { get; init; }
}
7 changes: 3 additions & 4 deletions src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,10 @@ internal abstract class CliMcpTool
public abstract JsonElement GetInputSchema();

/// <summary>
/// Executes the tool with the provided arguments.
/// Executes the tool with the provided context.
/// </summary>
/// <param name="mcpClient">The MCP client instance to use for communicating with the dashboard.</param>
/// <param name="arguments">The arguments passed to the tool.</param>
/// <param name="context">The call context containing the MCP server, client, and arguments.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The result of the tool execution.</returns>
public abstract ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken);
public abstract ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken);
}
61 changes: 61 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs
Original file line number Diff line number Diff line change
@@ -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.Docs;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;

namespace Aspire.Cli.Mcp.Tools;

/// <summary>
/// Helper methods for documentation tool operations.
/// </summary>
internal static class DocsToolHelper
{
/// <summary>
/// Ensures the documentation index is ready, sending progress notifications if indexing is needed.
/// </summary>
public static async ValueTask EnsureIndexedWithNotificationsAsync(
IDocsIndexService docsIndexService,
ProgressToken? progressToken,
IMcpNotifier notifier,
CancellationToken cancellationToken)
Comment on lines +18 to +22
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method EnsureIndexedWithNotificationsAsync is missing parameter documentation. According to the custom guidelines (CodingGuidelineID: 1000002), even internal APIs should document parameters. Please add <param> tags for all four parameters: docsIndexService, progressToken, notifier, and cancellationToken.

Copilot generated this review using guidance from repository custom instructions.
{
if (docsIndexService.IsIndexed)
{
return;
}

if (progressToken != null)
{
await notifier.SendNotificationAsync(
NotificationMethods.ProgressNotification,
new ProgressNotificationParams
{
ProgressToken = progressToken.Value,
Progress = new ProgressNotificationValue
{
Message = "Indexing Aspire docs...",
Progress = 1
}
}, 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 docs indexed",
Progress = 2
}
}, cancellationToken).ConfigureAwait(false);
}
}
}
5 changes: 2 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/DoctorTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,10 @@ public override JsonElement GetInputSchema()
""").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
// This tool does not use the MCP client or arguments
_ = mcpClient;
_ = arguments;
_ = context;

try
{
Expand Down
5 changes: 2 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ public override JsonElement GetInputSchema()
""").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> 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) ||
Expand Down
7 changes: 4 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/GetDocTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ public override JsonElement GetInputSchema()
}

public override async ValueTask<CallToolResult> CallToolAsync(
ModelContextProtocol.Client.McpClient mcpClient,
IReadOnlyDictionary<string, JsonElement>? arguments,
CallToolContext context,
CancellationToken cancellationToken)
{
_ = mcpClient;
var arguments = context.Arguments;

if (arguments is null || !arguments.TryGetValue("slug", out var slugElement))
{
Expand All @@ -76,6 +75,8 @@ public override async ValueTask<CallToolResult> 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)
Expand Down
5 changes: 2 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@ public override JsonElement GetInputSchema()
return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> 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);
Expand Down
5 changes: 2 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,9 @@ public override JsonElement GetInputSchema()
""").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> 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;
Expand Down
6 changes: 2 additions & 4 deletions src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,10 @@ public override JsonElement GetInputSchema()
}

public override async ValueTask<CallToolResult> CallToolAsync(
ModelContextProtocol.Client.McpClient mcpClient,
IReadOnlyDictionary<string, JsonElement>? 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);

Expand Down
5 changes: 2 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,10 @@ public override JsonElement GetInputSchema()
""").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
{
// This tool does not use the MCP client as it operates locally
_ = mcpClient;
_ = arguments;
_ = context;

try
{
Expand Down
5 changes: 2 additions & 3 deletions src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,10 @@ public override JsonElement GetInputSchema()
return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement;
}

public override async ValueTask<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? arguments, CancellationToken cancellationToken)
public override async ValueTask<CallToolResult> 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)
Expand Down
Loading
Loading