diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 34fe3735fdc..585acf3a866 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -110,9 +110,9 @@ protected override async Task ExecuteAsync(ParseResult parseResul _dashboardOnlyMode = true; var staticProvider = new StaticDashboardInfoProvider(dashboardUrl, apiKey); - _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); - _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); - _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, null, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, null, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, null, _httpClientFactory, _loggerFactory.CreateLogger()); } else { @@ -121,9 +121,9 @@ protected override async Task ExecuteAsync(ParseResult parseResul _knownTools[KnownMcpTools.ListResources] = new ListResourcesTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); _knownTools[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); _knownTools[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(_auxiliaryBackchannelMonitor, _loggerFactory.CreateLogger()); - _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); - _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); - _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _auxiliaryBackchannelMonitor, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _auxiliaryBackchannelMonitor, _httpClientFactory, _loggerFactory.CreateLogger()); + _knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _auxiliaryBackchannelMonitor, _httpClientFactory, _loggerFactory.CreateLogger()); _knownTools[KnownMcpTools.SelectAppHost] = new SelectAppHostTool(_auxiliaryBackchannelMonitor, _executionContext); _knownTools[KnownMcpTools.ListAppHosts] = new ListAppHostsTool(_auxiliaryBackchannelMonitor, _executionContext); _knownTools[KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(_packagingService, _executionContext, _auxiliaryBackchannelMonitor); diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs index 0db325c5d52..e2376e82b07 100644 --- a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs +++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp.Tools; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -87,7 +88,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel selectedAppHostPath = connection.AppHostInfo?.AppHostPath; var allResources = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false); - var resourcesWithTools = allResources.Where(r => r.McpServer is not null).ToList(); + var resourcesWithTools = allResources.Where(r => r.McpServer is not null && !McpToolHelpers.IsExcludedFromMcp(r)).ToList(); _logger.LogDebug("Resources with MCP tools received: {Count}", resourcesWithTools.Count); diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index 25aa005978b..1f5dd623afb 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -90,6 +90,13 @@ public override async ValueTask CallToolAsync(CallToolContext co throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); } + // Check if the resource is excluded from MCP before executing commands. + var excludedResult = await McpToolHelpers.CheckResourceExcludedAsync(connection, resourceName, cancellationToken).ConfigureAwait(false); + if (excludedResult is not null) + { + return excludedResult; + } + try { logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}' via backchannel", commandName, resourceName); diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index 9c9f08a965f..fad3ecf2cf4 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -71,6 +71,14 @@ public override async ValueTask CallToolAsync(CallToolContext co throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); } + // Check if the resource is excluded from MCP before fetching logs. + // This is the only check needed because the resource name is required for this tool. + var excludedResult = await McpToolHelpers.CheckResourceExcludedAsync(connection, resourceName, cancellationToken).ConfigureAwait(false); + if (excludedResult is not null) + { + return excludedResult; + } + try { var logParser = new LogParser(ConsoleColor.Black); diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index a07bafaa03a..8a0d17725c1 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -78,6 +78,9 @@ public override async ValueTask CallToolAsync(CallToolContext co var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false); var snapshots = await snapshotsTask.ConfigureAwait(false); + // Filter out resources that have opted out of MCP. + snapshots = snapshots.Where(s => !McpToolHelpers.IsExcludedFromMcp(s)).ToList(); + if (snapshots.Count == 0) { return new CallToolResult diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index bcc8fb28073..1a64f28f749 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -18,7 +19,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing structured logs. /// Gets log data directly from the Dashboard telemetry API. /// -internal sealed class ListStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IAuxiliaryBackchannelMonitor? auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListStructuredLogs; @@ -70,6 +71,16 @@ public override async ValueTask CallToolAsync(CallToolContext co // Resolve resource name to specific instances (handles replicas) var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + // If a specific resource was requested, check if it's excluded from MCP. + if (!string.IsNullOrEmpty(resourceName) && auxiliaryBackchannelMonitor is not null) + { + var excludedResult = await McpToolHelpers.CheckResourceExcludedAsync(auxiliaryBackchannelMonitor, resourceName, cancellationToken).ConfigureAwait(false); + if (excludedResult is not null) + { + return excludedResult; + } + } + // If a resource was specified but not found, return error if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources)) { @@ -91,6 +102,18 @@ public override async ValueTask CallToolAsync(CallToolContext co var apiResponse = await response.Content.ReadFromJsonAsync(OtlpJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); var resourceLogs = apiResponse?.Data?.ResourceLogs; + // Filter out logs from resources that are excluded from MCP. + if (resourceLogs is not null && string.IsNullOrEmpty(resourceName) && auxiliaryBackchannelMonitor is not null) + { + var excludedNames = await McpToolHelpers.GetExcludedResourceNamesAsync(auxiliaryBackchannelMonitor, cancellationToken).ConfigureAwait(false); + if (excludedNames.Count > 0) + { + resourceLogs = resourceLogs + .Where(rl => rl.Resource?.GetServiceName() is not { } name || !excludedNames.Contains(name)) + .ToArray(); + } + } + var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson( resourceLogs, getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index f0f0149fe99..74a3829f25a 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -18,7 +19,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing structured logs for a specific distributed trace. /// Gets log data directly from the Dashboard telemetry API. /// -internal sealed class ListTraceStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListTraceStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IAuxiliaryBackchannelMonitor? auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraceStructuredLogs; @@ -91,6 +92,18 @@ public override async ValueTask CallToolAsync(CallToolContext co var apiResponse = await response.Content.ReadFromJsonAsync(OtlpJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); var resourceLogs = apiResponse?.Data?.ResourceLogs; + // Filter out logs from resources that are excluded from MCP. + if (resourceLogs is not null && auxiliaryBackchannelMonitor is not null) + { + var excludedNames = await McpToolHelpers.GetExcludedResourceNamesAsync(auxiliaryBackchannelMonitor, cancellationToken).ConfigureAwait(false); + if (excludedNames.Count > 0) + { + resourceLogs = resourceLogs + .Where(rl => rl.Resource?.GetServiceName() is not { } name || !excludedNames.Contains(name)) + .ToArray(); + } + } + var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson( resourceLogs, getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index e17ea3023ab..18f72ea0ad8 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Dashboard.Otlp.Model; using Aspire.Otlp.Serialization; @@ -18,7 +19,7 @@ namespace Aspire.Cli.Mcp.Tools; /// MCP tool for listing distributed traces. /// Gets trace data directly from the Dashboard telemetry API. /// -internal sealed class ListTracesTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool +internal sealed class ListTracesTool(IDashboardInfoProvider dashboardInfoProvider, IAuxiliaryBackchannelMonitor? auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraces; @@ -70,6 +71,16 @@ public override async ValueTask CallToolAsync(CallToolContext co // Resolve resource name to specific instances (handles replicas) var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + // If a specific resource was requested, check if it's excluded from MCP. + if (!string.IsNullOrEmpty(resourceName) && auxiliaryBackchannelMonitor is not null) + { + var excludedResult = await McpToolHelpers.CheckResourceExcludedAsync(auxiliaryBackchannelMonitor, resourceName, cancellationToken).ConfigureAwait(false); + if (excludedResult is not null) + { + return excludedResult; + } + } + // If a resource was specified but not found, return error if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources)) { @@ -91,6 +102,18 @@ public override async ValueTask CallToolAsync(CallToolContext co var apiResponse = await response.Content.ReadFromJsonAsync(OtlpJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); var resourceSpans = apiResponse?.Data?.ResourceSpans; + // Filter out spans from resources that are excluded from MCP. + if (resourceSpans is not null && string.IsNullOrEmpty(resourceName) && auxiliaryBackchannelMonitor is not null) + { + var excludedNames = await McpToolHelpers.GetExcludedResourceNamesAsync(auxiliaryBackchannelMonitor, cancellationToken).ConfigureAwait(false); + if (excludedNames.Count > 0) + { + resourceSpans = resourceSpans + .Where(rs => rs.Resource?.GetServiceName() is not { } name || !excludedNames.Contains(name)) + .ToArray(); + } + } + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson( resourceSpans, getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), diff --git a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs index 5eb6697ed41..7a129ff19e3 100644 --- a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs +++ b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs @@ -2,10 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Text.Json.Nodes; using System.Web; using Aspire.Cli.Backchannel; +using Aspire.Dashboard.Model; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol; +using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; @@ -111,4 +115,116 @@ private static bool IsLocalhostTld(string host) return null; } + + /// + /// Checks whether a resource snapshot has the resource.excludeFromMcp property set to true. + /// Resources with this property should be excluded from all MCP tool results. + /// + internal static bool IsExcludedFromMcp(ResourceSnapshot snapshot) + { + if (snapshot.Properties.TryGetValue(KnownProperties.Resource.ExcludeFromMcp, out var value) && value is not null) + { + if (value is JsonValue jsonValue) + { + if (jsonValue.TryGetValue(out var boolValue)) + { + return boolValue; + } + + if (jsonValue.TryGetValue(out var stringValue) && bool.TryParse(stringValue, out var parsedBool)) + { + return parsedBool; + } + } + } + + return false; + } + + /// + /// Gets the error message text for a resource that is excluded from MCP. + /// + internal static string GetResourceNotAvailableMessage(string resourceName) => + $"Resource '{resourceName}' is not available."; + + /// + /// Gets resource snapshots from the backchannel and checks whether the specified resource is excluded from MCP. + /// Returns an error if the resource is excluded, or null if it is not excluded. + /// + internal static async Task CheckResourceExcludedAsync( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + string resourceName, + CancellationToken cancellationToken) + { + var excludedNames = await GetExcludedResourceNamesAsync(auxiliaryBackchannelMonitor, cancellationToken).ConfigureAwait(false); + return CreateExcludedResult(excludedNames, resourceName); + } + + /// + /// Checks whether the specified resource is excluded from MCP using an existing connection. + /// Returns an error if the resource is excluded, or null if it is not excluded. + /// + internal static async Task CheckResourceExcludedAsync( + IAppHostAuxiliaryBackchannel connection, + string resourceName, + CancellationToken cancellationToken) + { + var excludedNames = await GetExcludedResourceNamesAsync(connection, cancellationToken).ConfigureAwait(false); + return CreateExcludedResult(excludedNames, resourceName); + } + + private static CallToolResult? CreateExcludedResult(HashSet excludedNames, string resourceName) + { + if (excludedNames.Contains(resourceName)) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = GetResourceNotAvailableMessage(resourceName) }], + IsError = true + }; + } + + return null; + } + + /// + /// Gets the set of resource names that are excluded from MCP. + /// + internal static async Task> GetExcludedResourceNamesAsync( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + CancellationToken cancellationToken) + { + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, NullLogger.Instance, cancellationToken).ConfigureAwait(false); + if (connection is null) + { + return []; + } + + return await GetExcludedResourceNamesAsync(connection, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the set of resource names that are excluded from MCP using an existing connection. + /// + internal static async Task> GetExcludedResourceNamesAsync( + IAppHostAuxiliaryBackchannel connection, + CancellationToken cancellationToken) + { + var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false); + var excludedNames = new HashSet(StringComparers.ResourceName); + + foreach (var snapshot in snapshots) + { + if (IsExcludedFromMcp(snapshot)) + { + excludedNames.Add(snapshot.Name); + if (snapshot.DisplayName is not null) + { + excludedNames.Add(snapshot.DisplayName); + } + } + } + + return excludedNames; + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpExcludeFromMcpTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpExcludeFromMcpTests.cs new file mode 100644 index 00000000000..425945d1a6a --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentMcpExcludeFromMcpTests.cs @@ -0,0 +1,70 @@ +// 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.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for the ExcludeFromMcp resource filtering in the aspire agent mcp command. +/// Verifies that resources marked with ExcludeFromMcp() are hidden from MCP tool results. +/// +public sealed class AgentMcpExcludeFromMcpTests(ITestOutputHelper output) +{ + [Fact] + [CaptureWorkspaceOnFailure] + public async Task AgentMcpListResources_ExcludesResourceMarkedWithExcludeFromMcp() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + + using var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewAsync("McpExcludeApp", counter, useRedisCache: false); + + // Modify the AppHost to mark apiservice with ExcludeFromMcp() + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "McpExcludeApp", "McpExcludeApp.AppHost", "AppHost.cs"); + var content = File.ReadAllText(appHostFilePath); + var modified = content.Replace( + ".WithHttpHealthCheck(\"/health\");", + ".WithHttpHealthCheck(\"/health\")\n .ExcludeFromMcp();", + StringComparison.Ordinal); + File.WriteAllText(appHostFilePath, modified); + + // Navigate to the AppHost directory + await auto.TypeAsync("cd McpExcludeApp/McpExcludeApp.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Start the AppHost + await auto.AspireStartAsync(counter); + + // Wait for webfrontend to be up (confirms the app is running) + await auto.TypeAsync("aspire wait webfrontend --status up --timeout 300"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("is up (running).", timeout: TimeSpan.FromMinutes(6)); + await auto.WaitForSuccessPromptAsync(counter); + + // Call list_resources via MCP and verify apiservice is excluded but webfrontend is present + await auto.CallAgentMcpToolAsync( + counter, + "list_resources", + expectedMarker: "\"webfrontend\"", + doesNotContainMarker: "\"apiservice\""); + + // Stop the AppHost + await auto.AspireStopAsync(counter); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 69ee54260fd..b56e37dfcd9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -1039,12 +1039,14 @@ private static string BuildAspireDiagnosticsCaptureCommand(string destinationExp /// The prompt sequence counter. /// The MCP tool name to invoke (e.g. list_structured_logs). /// A string expected in the tool call output (e.g. STRUCTURED LOGS DATA). + /// An optional string that must NOT appear in the tool call output. /// Additional arguments to pass to aspire agent mcp (e.g. --dashboard-url "..."). internal static async Task CallAgentMcpToolAsync( this Hex1bTerminalAutomator auto, SequenceCounter counter, string toolName, string expectedMarker, + string? doesNotContainMarker = null, string? mcpArgs = null) { var argsFragment = mcpArgs is not null ? $" {mcpArgs}" : string.Empty; @@ -1077,6 +1079,17 @@ await auto.TypeAsync( await auto.WaitUntilTextAsync("MCP_DATA_PRESENT", timeout: TimeSpan.FromSeconds(10)); await auto.WaitForAnyPromptAsync(counter); + // If a doesNotContainMarker is specified, verify it is NOT in the output + if (doesNotContainMarker is not null) + { + await auto.TypeAsync( + $"if grep -q '{doesNotContainMarker}' /tmp/mcp_out.txt; then echo 'MCP_EXCLUDED_MARKER_FOUND'; " + + "else echo 'MCP_EXCLUDED_MARKER_ABSENT'; fi"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("MCP_EXCLUDED_MARKER_ABSENT", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForAnyPromptAsync(counter); + } + // Verify the initialize response was received (confirms MCP handshake worked) await auto.TypeAsync( "grep -q 'aspire-mcp-server' /tmp/mcp_out.txt && echo 'MCP_INIT_OK' || echo 'MCP_INIT_MISSING'"); diff --git a/tests/Aspire.Cli.Tests/Mcp/ExcludeFromMcpTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExcludeFromMcpTests.cs new file mode 100644 index 00000000000..9215874ff66 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ExcludeFromMcpTests.cs @@ -0,0 +1,613 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ExcludeFromMcpTests +{ + private const string ApiServiceName = "api-service"; + private const string SecretServiceName = "secret-service"; + + [Fact] + public void IsExcludedFromMcp_ReturnsFalse_WhenPropertyNotSet() + { + var snapshot = new ResourceSnapshot + { + Name = "test", + Properties = [] + }; + + Assert.False(McpToolHelpers.IsExcludedFromMcp(snapshot)); + } + + [Fact] + public void IsExcludedFromMcp_ReturnsTrue_WhenPropertyIsBoolTrue() + { + var snapshot = new ResourceSnapshot + { + Name = "test", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true) + } + }; + + Assert.True(McpToolHelpers.IsExcludedFromMcp(snapshot)); + } + + [Fact] + public void IsExcludedFromMcp_ReturnsFalse_WhenPropertyIsBoolFalse() + { + var snapshot = new ResourceSnapshot + { + Name = "test", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(false) + } + }; + + Assert.False(McpToolHelpers.IsExcludedFromMcp(snapshot)); + } + + [Fact] + public void IsExcludedFromMcp_ReturnsTrue_WhenPropertyIsStringTrue() + { + var snapshot = new ResourceSnapshot + { + Name = "test", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create("true") + } + }; + + Assert.True(McpToolHelpers.IsExcludedFromMcp(snapshot)); + } + + [Fact] + public void IsExcludedFromMcp_ReturnsFalse_WhenPropertyIsNull() + { + var snapshot = new ResourceSnapshot + { + Name = "test", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = null + } + }; + + Assert.False(McpToolHelpers.IsExcludedFromMcp(snapshot)); + } + + [Fact] + public async Task ListResourcesTool_ExcludesResourceWithExcludeFromMcp() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = ApiServiceName, + DisplayName = "API Service", + ResourceType = "Project", + State = "Running" + }, + new ResourceSnapshot + { + Name = SecretServiceName, + DisplayName = "Secret Service", + ResourceType = "Project", + State = "Running", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true) + } + } + ], + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "http://localhost:18888" } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains(ApiServiceName, textContent.Text); + Assert.DoesNotContain(SecretServiceName, textContent.Text); + } + + [Fact] + public async Task ListResourcesTool_ReturnsNoResourcesFound_WhenAllExcluded() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = SecretServiceName, + DisplayName = "Secret Service", + ResourceType = "Project", + State = "Running", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true) + } + } + ], + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "http://localhost:18888" } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("No resources found", textContent.Text); + } + + [Fact] + public async Task ListConsoleLogsTool_ReturnsError_WhenResourceIsExcluded() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = SecretServiceName, + DisplayName = "Secret Service", + ResourceType = "Project", + State = "Running", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true) + } + } + ], + LogLines = [new ResourceLogLine { Content = "secret log", IsError = false, ResourceName = SecretServiceName, LineNumber = 1 }] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse($"\"{SecretServiceName}\"").RootElement + }; + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal(McpToolHelpers.GetResourceNotAvailableMessage(SecretServiceName), textContent.Text); + } + + [Fact] + public async Task ListConsoleLogsTool_ReturnsLogs_WhenResourceIsNotExcluded() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = ApiServiceName, + DisplayName = "API Service", + ResourceType = "Project", + State = "Running" + } + ], + LogLines = [new ResourceLogLine { Content = "Application started", IsError = false, ResourceName = ApiServiceName, LineNumber = 1 }] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse($"\"{ApiServiceName}\"").RootElement + }; + + 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 TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Application started", textContent.Text); + } + + [Fact] + public async Task ExecuteResourceCommandTool_ReturnsError_WhenResourceIsExcluded() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = SecretServiceName, + DisplayName = "Secret Service", + ResourceType = "Project", + State = "Running", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true) + } + } + ], + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + + using var doc = JsonDocument.Parse($$"""{"resourceName": "{{SecretServiceName}}", "commandName": "restart"}"""); + var arguments = doc.RootElement.EnumerateObject() + .ToDictionary(p => p.Name, p => p.Value.Clone()); + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal(McpToolHelpers.GetResourceNotAvailableMessage(SecretServiceName), textContent.Text); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsError_WhenSpecificResourceIsExcluded() + { + var monitor = CreateMonitorWithDashboardAndExcludedResource(); + var (mockHttpClientFactory, _) = CreateMockHttpWithLogs(SecretServiceName); + + var tool = CreateStructuredLogsTool(monitor, mockHttpClientFactory); + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse($"\"{SecretServiceName}\"").RootElement + }; + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal(McpToolHelpers.GetResourceNotAvailableMessage(SecretServiceName), textContent.Text); + } + + [Fact] + public async Task ListStructuredLogsTool_FiltersExcludedResourceLogs_WhenNoResourceSpecified() + { + var monitor = CreateMonitorWithDashboardAndExcludedResource(); + var (mockHttpClientFactory, _) = CreateMockHttpWithLogs(ApiServiceName, SecretServiceName); + + var tool = CreateStructuredLogsTool(monitor, mockHttpClientFactory); + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains(ApiServiceName, textContent.Text); + Assert.DoesNotContain(SecretServiceName, textContent.Text); + } + + [Fact] + public async Task ListTracesTool_ReturnsError_WhenSpecificResourceIsExcluded() + { + var monitor = CreateMonitorWithDashboardAndExcludedResource(); + var (mockHttpClientFactory, _) = CreateMockHttpWithTraces(SecretServiceName); + + var tool = CreateTracesTool(monitor, mockHttpClientFactory); + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse($"\"{SecretServiceName}\"").RootElement + }; + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal(McpToolHelpers.GetResourceNotAvailableMessage(SecretServiceName), textContent.Text); + } + + [Fact] + public async Task ListTracesTool_FiltersExcludedResourceSpans_WhenNoResourceSpecified() + { + var monitor = CreateMonitorWithDashboardAndExcludedResource(); + var (mockHttpClientFactory, _) = CreateMockHttpWithTraces(ApiServiceName, SecretServiceName); + + var tool = CreateTracesTool(monitor, mockHttpClientFactory); + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains(ApiServiceName, textContent.Text); + Assert.DoesNotContain(SecretServiceName, textContent.Text); + } + + [Fact] + public async Task ListTraceStructuredLogsTool_FiltersExcludedResourceLogs() + { + var monitor = CreateMonitorWithDashboardAndExcludedResource(); + var (mockHttpClientFactory, _) = CreateMockHttpWithLogs(ApiServiceName, SecretServiceName); + + var tool = CreateTraceStructuredLogsTool(monitor, mockHttpClientFactory); + var arguments = new Dictionary + { + ["traceId"] = JsonDocument.Parse("\"abc123def456\"").RootElement + }; + + 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 TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains(ApiServiceName, textContent.Text); + Assert.DoesNotContain(SecretServiceName, textContent.Text); + } + + [Fact] + public async Task ListTraceStructuredLogsTool_ReturnsAllLogs_WhenNoResourcesExcluded() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = "http://localhost:5000", + ApiToken = "test-token", + DashboardUrls = ["http://localhost:18888"] + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = ApiServiceName, + DisplayName = "API Service", + ResourceType = "Project", + State = "Running" + }, + new ResourceSnapshot + { + Name = SecretServiceName, + DisplayName = "Secret Service", + ResourceType = "Project", + State = "Running" + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var (mockHttpClientFactory, _) = CreateMockHttpWithLogs(ApiServiceName, SecretServiceName); + + var tool = CreateTraceStructuredLogsTool(monitor, mockHttpClientFactory); + var arguments = new Dictionary + { + ["traceId"] = JsonDocument.Parse("\"abc123def456\"").RootElement + }; + + 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 TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains(ApiServiceName, textContent.Text); + Assert.Contains(SecretServiceName, textContent.Text); + } + + private static TestAuxiliaryBackchannelMonitor CreateMonitorWithDashboardAndExcludedResource() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = "http://localhost:5000", + ApiToken = "test-token", + DashboardUrls = ["http://localhost:18888"] + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = ApiServiceName, + DisplayName = "API Service", + ResourceType = "Project", + State = "Running" + }, + new ResourceSnapshot + { + Name = SecretServiceName, + DisplayName = "Secret Service", + ResourceType = "Project", + State = "Running", + Properties = new Dictionary + { + [KnownProperties.Resource.ExcludeFromMcp] = JsonValue.Create(true) + } + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + return monitor; + } + + private static (MockHttpClientFactory factory, MockHttpMessageHandler handler) CreateMockHttpWithLogs(params string[] serviceNames) + { + var resourceLogs = serviceNames.Select(name => new OtlpResourceLogsJson + { + Resource = new OtlpResourceJson + { + Attributes = [new OtlpKeyValueJson { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = name } }] + }, + ScopeLogs = + [ + new OtlpScopeLogsJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "Microsoft.Extensions.Logging" }, + LogRecords = + [ + new OtlpLogRecordJson + { + TimeUnixNano = 1706540400000000000, + SeverityNumber = 9, + SeverityText = "Information", + Body = new OtlpAnyValueJson { StringValue = $"Log from {name}" }, + Attributes = [new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { IntValue = 1 } }] + } + ] + } + ] + }).ToArray(); + + var resources = serviceNames.Select(name => new ResourceInfoJson + { + Name = name, + InstanceId = null, + HasLogs = true, + HasTraces = true, + HasMetrics = true + }).ToArray(); + + return CreateMockHttp(resources, new TelemetryApiResponse + { + Data = new OtlpTelemetryDataJson { ResourceLogs = resourceLogs }, + TotalCount = resourceLogs.Length, + ReturnedCount = resourceLogs.Length + }); + } + + private static (MockHttpClientFactory factory, MockHttpMessageHandler handler) CreateMockHttpWithTraces(params string[] serviceNames) + { + var resourceSpans = serviceNames.Select(name => new OtlpResourceSpansJson + { + Resource = new OtlpResourceJson + { + Attributes = [new OtlpKeyValueJson { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = name } }] + }, + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "TestScope" }, + Spans = + [ + new OtlpSpanJson + { + Name = $"GET /{name}", + TraceId = "abc123def456", + SpanId = "span123", + StartTimeUnixNano = 1706540400000000000, + EndTimeUnixNano = 1706540400100000000, + Kind = 2 // Server + } + ] + } + ] + }).ToArray(); + + var resources = serviceNames.Select(name => new ResourceInfoJson + { + Name = name, + InstanceId = null, + HasLogs = true, + HasTraces = true, + HasMetrics = true + }).ToArray(); + + return CreateMockHttp(resources, new TelemetryApiResponse + { + Data = new OtlpTelemetryDataJson { ResourceSpans = resourceSpans }, + TotalCount = resourceSpans.Length, + ReturnedCount = resourceSpans.Length + }); + } + + private static (MockHttpClientFactory factory, MockHttpMessageHandler handler) CreateMockHttp( + ResourceInfoJson[] resources, + TelemetryApiResponse apiResponseObj) + { + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpJsonSerializerContext.Default.TelemetryApiResponse); + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpJsonSerializerContext.Default.ResourceInfoJsonArray); + + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + + return (new MockHttpClientFactory(handler), handler); + } + + private static ListStructuredLogsTool CreateStructuredLogsTool( + TestAuxiliaryBackchannelMonitor monitor, + IHttpClientFactory httpClientFactory) + { + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(monitor, NullLogger.Instance); + return new ListStructuredLogsTool( + dashboardInfoProvider, + monitor, + httpClientFactory, + NullLogger.Instance); + } + + private static ListTracesTool CreateTracesTool( + TestAuxiliaryBackchannelMonitor monitor, + IHttpClientFactory httpClientFactory) + { + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(monitor, NullLogger.Instance); + return new ListTracesTool( + dashboardInfoProvider, + monitor, + httpClientFactory, + NullLogger.Instance); + } + + private static ListTraceStructuredLogsTool CreateTraceStructuredLogsTool( + TestAuxiliaryBackchannelMonitor monitor, + IHttpClientFactory httpClientFactory) + { + IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(monitor, NullLogger.Instance); + return new ListTraceStructuredLogsTool( + dashboardInfoProvider, + monitor, + httpClientFactory, + NullLogger.Instance); + } + +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs index b4f20a8803b..97b3b1294c9 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs @@ -393,6 +393,7 @@ private static ListStructuredLogsTool CreateTool( IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(actualMonitor, NullLogger.Instance); return new ListStructuredLogsTool( dashboardInfoProvider, + actualMonitor, httpClientFactory ?? s_httpClientFactory, NullLogger.Instance); } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs index cb06191aaac..258394a79eb 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs @@ -435,6 +435,7 @@ private static ListTracesTool CreateTool( IDashboardInfoProvider dashboardInfoProvider = new BackchannelDashboardInfoProvider(actualMonitor, NullLogger.Instance); return new ListTracesTool( dashboardInfoProvider, + actualMonitor, httpClientFactory ?? s_httpClientFactory, NullLogger.Instance); }