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
12 changes: 6 additions & 6 deletions src/Aspire.Cli/Commands/AgentMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
_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>());
_knownTools[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(staticProvider, null, _httpClientFactory, _loggerFactory.CreateLogger<ListStructuredLogsTool>());
_knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(staticProvider, null, _httpClientFactory, _loggerFactory.CreateLogger<ListTracesTool>());
_knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(staticProvider, null, _httpClientFactory, _loggerFactory.CreateLogger<ListTraceStructuredLogsTool>());
}
else
{
Expand All @@ -121,9 +121,9 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
_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.ListStructuredLogs] = new ListStructuredLogsTool(dashboardInfoProvider, _auxiliaryBackchannelMonitor, _httpClientFactory, _loggerFactory.CreateLogger<ListStructuredLogsTool>());
_knownTools[KnownMcpTools.ListTraces] = new ListTracesTool(dashboardInfoProvider, _auxiliaryBackchannelMonitor, _httpClientFactory, _loggerFactory.CreateLogger<ListTracesTool>());
_knownTools[KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(dashboardInfoProvider, _auxiliaryBackchannelMonitor, _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);
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 7 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ public override async ValueTask<CallToolResult> 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);
Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ public override async ValueTask<CallToolResult> 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);
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ public override async ValueTask<CallToolResult> 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
Expand Down
25 changes: 24 additions & 1 deletion src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +19,7 @@ namespace Aspire.Cli.Mcp.Tools;
/// MCP tool for listing structured logs.
/// Gets log data directly from the Dashboard telemetry API.
/// </summary>
internal sealed class ListStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger<ListStructuredLogsTool> logger) : CliMcpTool
internal sealed class ListStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IAuxiliaryBackchannelMonitor? auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger<ListStructuredLogsTool> logger) : CliMcpTool
{
public override string Name => KnownMcpTools.ListStructuredLogs;

Expand Down Expand Up @@ -70,6 +71,16 @@ public override async ValueTask<CallToolResult> 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))
{
Expand All @@ -91,6 +102,18 @@ public override async ValueTask<CallToolResult> 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()),
Expand Down
15 changes: 14 additions & 1 deletion src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
/// </summary>
internal sealed class ListTraceStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger<ListTraceStructuredLogsTool> logger) : CliMcpTool
internal sealed class ListTraceStructuredLogsTool(IDashboardInfoProvider dashboardInfoProvider, IAuxiliaryBackchannelMonitor? auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger<ListTraceStructuredLogsTool> logger) : CliMcpTool
{
public override string Name => KnownMcpTools.ListTraceStructuredLogs;

Expand Down Expand Up @@ -91,6 +92,18 @@ public override async ValueTask<CallToolResult> 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()),
Expand Down
25 changes: 24 additions & 1 deletion src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +19,7 @@ namespace Aspire.Cli.Mcp.Tools;
/// MCP tool for listing distributed traces.
/// Gets trace data directly from the Dashboard telemetry API.
/// </summary>
internal sealed class ListTracesTool(IDashboardInfoProvider dashboardInfoProvider, IHttpClientFactory httpClientFactory, ILogger<ListTracesTool> logger) : CliMcpTool
internal sealed class ListTracesTool(IDashboardInfoProvider dashboardInfoProvider, IAuxiliaryBackchannelMonitor? auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger<ListTracesTool> logger) : CliMcpTool
{
public override string Name => KnownMcpTools.ListTraces;

Expand Down Expand Up @@ -70,6 +71,16 @@ public override async ValueTask<CallToolResult> 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))
{
Expand All @@ -91,6 +102,18 @@ public override async ValueTask<CallToolResult> 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()),
Expand Down
116 changes: 116 additions & 0 deletions src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -111,4 +115,116 @@ private static bool IsLocalhostTld(string host)

return null;
}

/// <summary>
/// Checks whether a resource snapshot has the <c>resource.excludeFromMcp</c> property set to true.
/// Resources with this property should be excluded from all MCP tool results.
/// </summary>
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<bool>(out var boolValue))
{
return boolValue;
}

if (jsonValue.TryGetValue<string>(out var stringValue) && bool.TryParse(stringValue, out var parsedBool))
{
return parsedBool;
}
}
}

return false;
}

/// <summary>
/// Gets the error message text for a resource that is excluded from MCP.
/// </summary>
internal static string GetResourceNotAvailableMessage(string resourceName) =>
$"Resource '{resourceName}' is not available.";

/// <summary>
/// Gets resource snapshots from the backchannel and checks whether the specified resource is excluded from MCP.
/// Returns an error <see cref="CallToolResult"/> if the resource is excluded, or <c>null</c> if it is not excluded.
/// </summary>
internal static async Task<CallToolResult?> CheckResourceExcludedAsync(
IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
string resourceName,
CancellationToken cancellationToken)
{
var excludedNames = await GetExcludedResourceNamesAsync(auxiliaryBackchannelMonitor, cancellationToken).ConfigureAwait(false);
return CreateExcludedResult(excludedNames, resourceName);
}

/// <summary>
/// Checks whether the specified resource is excluded from MCP using an existing connection.
/// Returns an error <see cref="CallToolResult"/> if the resource is excluded, or <c>null</c> if it is not excluded.
/// </summary>
internal static async Task<CallToolResult?> CheckResourceExcludedAsync(
IAppHostAuxiliaryBackchannel connection,
string resourceName,
CancellationToken cancellationToken)
{
var excludedNames = await GetExcludedResourceNamesAsync(connection, cancellationToken).ConfigureAwait(false);
return CreateExcludedResult(excludedNames, resourceName);
}

private static CallToolResult? CreateExcludedResult(HashSet<string> excludedNames, string resourceName)
{
if (excludedNames.Contains(resourceName))
{
return new CallToolResult
{
Content = [new TextContentBlock { Text = GetResourceNotAvailableMessage(resourceName) }],
IsError = true
};
}

return null;
}

/// <summary>
/// Gets the set of resource names that are excluded from MCP.
/// </summary>
internal static async Task<HashSet<string>> 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);
}

/// <summary>
/// Gets the set of resource names that are excluded from MCP using an existing connection.
/// </summary>
internal static async Task<HashSet<string>> GetExcludedResourceNamesAsync(
IAppHostAuxiliaryBackchannel connection,
CancellationToken cancellationToken)
{
var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);
var excludedNames = new HashSet<string>(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;
}
}
Loading
Loading