Skip to content

Commit 8075876

Browse files
committed
Add --dashboard-url and --api-key options to aspire agent mcp and aspire otel commands
Add support for connecting directly to a standalone Aspire Dashboard without an AppHost via --dashboard-url and optional --api-key options on the 'aspire agent mcp' and 'aspire otel logs/spans/traces' commands. - Introduce IDashboardInfoProvider abstraction (BackchannelDashboardInfoProvider and StaticDashboardInfoProvider) to decouple MCP tools from the backchannel - Add --dashboard-url and --api-key options to TelemetryLogs/Spans/TracesCommand - Add dashboard-only mode to AgentMcpCommand (exposes only telemetry tools) - Add smart error handling: 401 suggests --api-key, 404 checks if API is enabled, connection refused reports unreachable dashboard - Add tests for all new functionality
1 parent 2916fba commit 8075876

37 files changed

+1915
-394
lines changed

src/Aspire.Cli/Commands/AgentMcpCommand.cs

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,23 @@ namespace Aspire.Cli.Commands;
2828
/// </summary>
2929
internal sealed class AgentMcpCommand : BaseCommand
3030
{
31-
private readonly Dictionary<string, CliMcpTool> _knownTools;
31+
private readonly Dictionary<string, CliMcpTool> _knownTools = [];
3232
private readonly IMcpResourceToolRefreshService _resourceToolRefreshService;
3333
private McpServer? _server;
3434
private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor;
3535
private readonly IMcpTransportFactory _transportFactory;
3636
private readonly ILoggerFactory _loggerFactory;
3737
private readonly ILogger<AgentMcpCommand> _logger;
38+
private readonly IHttpClientFactory _httpClientFactory;
39+
private readonly IPackagingService _packagingService;
40+
private readonly IEnvironmentChecker _environmentChecker;
41+
private readonly IDocsSearchService _docsSearchService;
42+
private readonly IDocsIndexService _docsIndexService;
43+
private readonly CliExecutionContext _executionContext;
44+
private bool _dashboardOnlyMode;
45+
46+
private static readonly Option<string?> s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption();
47+
private static readonly Option<string?> s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption();
3848

3949
/// <summary>
4050
/// Gets the dictionary of known MCP tools. Exposed for testing purposes.
@@ -62,24 +72,16 @@ public AgentMcpCommand(
6272
_transportFactory = transportFactory;
6373
_loggerFactory = loggerFactory;
6474
_logger = logger;
75+
_httpClientFactory = httpClientFactory;
76+
_packagingService = packagingService;
77+
_environmentChecker = environmentChecker;
78+
_docsSearchService = docsSearchService;
79+
_docsIndexService = docsIndexService;
80+
_executionContext = executionContext;
6581
_resourceToolRefreshService = new McpResourceToolRefreshService(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<McpResourceToolRefreshService>());
66-
_knownTools = new Dictionary<string, CliMcpTool>
67-
{
68-
[KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ListResourcesTool>()),
69-
[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ListConsoleLogsTool>()),
70-
[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger<ExecuteResourceCommandTool>()),
71-
[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListStructuredLogsTool>()),
72-
[KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListTracesTool>()),
73-
[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger<ListTraceStructuredLogsTool>()),
74-
[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext),
75-
[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext),
76-
[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor),
77-
[KnownMcpTools.Doctor] = new DoctorTool(environmentChecker),
78-
[KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService),
79-
[KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService),
80-
[KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService),
81-
[KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService)
82-
};
82+
83+
Options.Add(s_dashboardUrlOption);
84+
Options.Add(s_apiKeyOption);
8385
}
8486

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

9698
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
9799
{
100+
var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption);
101+
var apiKey = parseResult.GetValue(s_apiKeyOption);
102+
103+
if (dashboardUrl is not null)
104+
{
105+
if (!UrlHelper.IsHttpUrl(dashboardUrl))
106+
{
107+
_logger.LogError("Invalid --dashboard-url: {DashboardUrl}", dashboardUrl);
108+
return ExitCodeConstants.InvalidCommand;
109+
}
110+
111+
_dashboardOnlyMode = true;
112+
var staticProvider = new StaticDashboardInfoProvider(dashboardUrl, apiKey);
113+
114+
_knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListStructuredLogsTool>());
115+
_knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTracesTool>());
116+
_knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTraceStructuredLogsTool>());
117+
}
118+
else
119+
{
120+
var dashboardInfoProvider = new BackchannelDashboardInfoProvider(_auxiliaryBackchannelMonitor, _logger);
121+
122+
_knownTools[KnownMcpTools.ListResources] = new ListResourcesTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger<ListResourcesTool>());
123+
_knownTools[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger<ListConsoleLogsTool>());
124+
_knownTools[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger<ExecuteResourceCommandTool>());
125+
_knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListStructuredLogsTool>());
126+
_knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTracesTool>());
127+
_knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger<ListTraceStructuredLogsTool>());
128+
_knownTools[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(_auxiliaryBackchannelMonitor, _executionContext);
129+
_knownTools[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(_auxiliaryBackchannelMonitor, _executionContext);
130+
_knownTools[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(_packagingService, _executionContext, _auxiliaryBackchannelMonitor);
131+
_knownTools[KnownMcpTools.Doctor] = new DoctorTool(_environmentChecker);
132+
_knownTools[KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService);
133+
_knownTools[KnownMcpTools.ListDocs] = new ListDocsTool(_docsIndexService);
134+
_knownTools[KnownMcpTools.SearchDocs] = new SearchDocsTool(_docsSearchService, _docsIndexService);
135+
_knownTools[KnownMcpTools.GetDoc] = new GetDocTool(_docsIndexService);
136+
}
137+
98138
var icons = McpIconHelper.GetAspireIcons(typeof(AgentMcpCommand).Assembly, "Aspire.Cli.Mcp.Resources");
99139

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

136176
var tools = new List<Tool>();
137177

138-
tools.AddRange(KnownTools.Values.Select(tool => new Tool
178+
tools.AddRange(KnownTools.Select(tool => new Tool
139179
{
140-
Name = tool.Name,
141-
Description = tool.Description,
142-
InputSchema = tool.GetInputSchema()
180+
Name = tool.Value.Name,
181+
Description = tool.Value.Description,
182+
InputSchema = tool.Value.GetInputSchema()
143183
}));
144184

145185
try
146186
{
147-
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
148-
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
187+
// In dashboard-only mode, skip resource tool discovery
188+
if (_dashboardOnlyMode)
149189
{
150-
// Don't send tools/list_changed here — the client already called tools/list
151-
// and will receive the up-to-date result. Sending a notification during the
152-
// list handler would cause the client to call tools/list again, creating an
153-
// infinite loop when tool availability is unstable (e.g., container MCP tools
154-
// oscillating between available/unavailable).
155-
(resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
190+
_logger.LogDebug("Dashboard-only mode: skipping resource tool discovery");
156191
}
157-
158-
tools.AddRange(resourceToolMap.Select(x => new Tool
192+
else
159193
{
160-
Name = x.Key,
161-
Description = x.Value.Tool.Description,
162-
InputSchema = x.Value.Tool.InputSchema
163-
}));
194+
// Refresh resource tools if needed (e.g., AppHost selection changed or invalidated)
195+
if (!_resourceToolRefreshService.TryGetResourceToolMap(out var resourceToolMap))
196+
{
197+
// Don't send tools/list_changed here — the client already called tools/list
198+
// and will receive the up-to-date result. Sending a notification during the
199+
// list handler would cause the client to call tools/list again, creating an
200+
// infinite loop when tool availability is unstable (e.g., container MCP tools
201+
// oscillating between available/unavailable).
202+
(resourceToolMap, _) = await _resourceToolRefreshService.RefreshResourceToolMapAsync(cancellationToken);
203+
}
204+
205+
tools.AddRange(resourceToolMap.Select(x => new Tool
206+
{
207+
Name = x.Key,
208+
Description = x.Value.Tool.Description,
209+
InputSchema = x.Value.Tool.InputSchema
210+
}));
211+
}
164212
}
165213
catch (Exception ex)
166214
{
@@ -179,6 +227,14 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT
179227

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

230+
// In dashboard-only mode, only allow tools that were registered
231+
if (_dashboardOnlyMode && !_knownTools.ContainsKey(toolName))
232+
{
233+
throw new McpProtocolException(
234+
$"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.",
235+
McpErrorCode.MethodNotFound);
236+
}
237+
182238
if (KnownTools.TryGetValue(toolName, out var tool))
183239
{
184240
var args = request.Params?.Arguments is { } a

0 commit comments

Comments
 (0)