Skip to content
Open
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
128 changes: 92 additions & 36 deletions src/Aspire.Cli/Commands/AgentMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,23 @@ namespace Aspire.Cli.Commands;
/// </summary>
internal sealed class AgentMcpCommand : BaseCommand
{
private readonly Dictionary<string, CliMcpTool> _knownTools;
private readonly Dictionary<string, CliMcpTool> _knownTools = [];
private readonly IMcpResourceToolRefreshService _resourceToolRefreshService;
private McpServer? _server;
private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor;
private readonly IMcpTransportFactory _transportFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<AgentMcpCommand> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPackagingService _packagingService;
private readonly IEnvironmentChecker _environmentChecker;
private readonly IDocsSearchService _docsSearchService;
private readonly IDocsIndexService _docsIndexService;
private readonly CliExecutionContext _executionContext;
private bool _dashboardOnlyMode;

private static readonly Option<string?> s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption();
private static readonly Option<string?> s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption();

/// <summary>
/// Gets the dictionary of known MCP tools. Exposed for testing purposes.
Expand Down Expand Up @@ -62,24 +72,16 @@ public AgentMcpCommand(
_transportFactory = transportFactory;
_loggerFactory = loggerFactory;
_logger = logger;
_httpClientFactory = httpClientFactory;
_packagingService = packagingService;
_environmentChecker = environmentChecker;
_docsSearchService = docsSearchService;
_docsIndexService = docsIndexService;
_executionContext = executionContext;
_resourceToolRefreshService = new McpResourceToolRefreshService(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<McpResourceToolRefreshService>());
_knownTools = new Dictionary<string, CliMcpTool>
{
[KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ListResourcesTool>()),
[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ListConsoleLogsTool>()),
[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ExecuteResourceCommandTool>()),
[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListStructuredLogsTool>()),
[KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListTracesTool>()),
[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListTraceStructuredLogsTool>()),
[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext),
[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext),
[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor),
[KnownMcpTools.Doctor] = new DoctorTool(environmentChecker),
[KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService),
[KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService),
[KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService),
[KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService)
};

Options.Add(s_dashboardUrlOption);
Options.Add(s_apiKeyOption);
}

protected override bool UpdateNotificationsEnabled => false;
Expand All @@ -95,6 +97,44 @@ internal Task<int> ExecuteCommandAsync(ParseResult parseResult, CancellationToke

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption);
var apiKey = parseResult.GetValue(s_apiKeyOption);

if (dashboardUrl is not null)
{
if (!UrlHelper.IsHttpUrl(dashboardUrl))
{
_logger.LogError("Invalid --dashboard-url: {DashboardUrl}", dashboardUrl);
return ExitCodeConstants.InvalidCommand;
}

_dashboardOnlyMode = true;
var staticProvider = new StaticDashboardInfoProvider(dashboardUrl, apiKey);

_knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListStructuredLogsTool>());
_knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTracesTool>());
_knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTraceStructuredLogsTool>());
}
else
{
var dashboardInfoProvider = new BackchannelDashboardInfoProvider(_auxiliaryBackchannelMonitor, _logger);

_knownTools[KnownMcpTools.ListResources] = new ListResourcesTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger<ListResourcesTool>());
_knownTools[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger<ListConsoleLogsTool>());
_knownTools[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger<ExecuteResourceCommandTool>());
_knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListStructuredLogsTool>());
_knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTracesTool>());
_knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTraceStructuredLogsTool>());
_knownTools[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(_auxiliaryBackchannelMonitor, _executionContext);
_knownTools[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(_auxiliaryBackchannelMonitor, _executionContext);
_knownTools[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(_packagingService, _executionContext, _auxiliaryBackchannelMonitor);
_knownTools[KnownMcpTools.Doctor] = new DoctorTool(_environmentChecker);
_knownTools[KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService);
_knownTools[KnownMcpTools.ListDocs] = new ListDocsTool(_docsIndexService);
_knownTools[KnownMcpTools.SearchDocs] = new SearchDocsTool(_docsSearchService, _docsIndexService);
_knownTools[KnownMcpTools.GetDoc] = new GetDocTool(_docsIndexService);
}

var icons = McpIconHelper.GetAspireIcons(typeof(AgentMcpCommand).Assembly, "Aspire.Cli.Mcp.Resources");

var options = new McpServerOptions
Expand Down Expand Up @@ -135,32 +175,40 @@ private async ValueTask<ListToolsResult> HandleListToolsAsync(RequestContext<Lis

var tools = new List<Tool>();

tools.AddRange(KnownTools.Values.Select(tool => new Tool
tools.AddRange(KnownTools.Select(tool => new Tool
{
Name = tool.Name,
Description = tool.Description,
InputSchema = tool.GetInputSchema()
Name = tool.Value.Name,
Description = tool.Value.Description,
InputSchema = tool.Value.GetInputSchema()
}));

try
{
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
// In dashboard-only mode, skip resource tool discovery
if (_dashboardOnlyMode)
{
// Don't send tools/list_changed here — the client already called tools/list
// and will receive the up-to-date result. Sending a notification during the
// list handler would cause the client to call tools/list again, creating an
// infinite loop when tool availability is unstable (e.g., container MCP tools
// oscillating between available/unavailable).
(resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
_logger.LogDebug("Dashboard-only mode: skipping resource tool discovery");
}

tools.AddRange(resourceToolMap.Select(x => new Tool
else
{
Name = x.Key,
Description = x.Value.Tool.Description,
InputSchema = x.Value.Tool.InputSchema
}));
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
{
// Don't send tools/list_changed here — the client already called tools/list
// and will receive the up-to-date result. Sending a notification during the
// list handler would cause the client to call tools/list again, creating an
// infinite loop when tool availability is unstable (e.g., container MCP tools
// oscillating between available/unavailable).
(resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
}

tools.AddRange(resourceToolMap.Select(x => new Tool
{
Name = x.Key,
Description = x.Value.Tool.Description,
InputSchema = x.Value.Tool.InputSchema
}));
}
}
catch (Exception ex)
{
Expand All @@ -179,6 +227,14 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT

_logger.LogDebug("MCP CallTool request received for tool: {ToolName}", toolName);

// In dashboard-only mode, only allow tools that were registered
if (_dashboardOnlyMode && !_knownTools.ContainsKey(toolName))
{
throw new McpProtocolException(
$"Tool '{toolName}' is not available in dashboard-only mode. Only telemetry tools (list_structured_logs, list_traces, list_trace_structured_logs) are available when using --dashboard-url.",
McpErrorCode.MethodNotFound);
}

if (KnownTools.TryGetValue(toolName, out var tool))
{
var args = request.Params?.Arguments is { } a
Expand Down
Loading
Loading