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
30 changes: 24 additions & 6 deletions src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,30 @@ internal sealed class AspireResourceMcpTools
{
private readonly IDashboardClient _dashboardClient;
private readonly IOptionsMonitor<DashboardOptions> _dashboardOptions;
private readonly ILogger<AspireResourceMcpTools> _logger;

public AspireResourceMcpTools(IDashboardClient dashboardClient, IOptionsMonitor<DashboardOptions> dashboardOptions)
public AspireResourceMcpTools(IDashboardClient dashboardClient,
IOptionsMonitor<DashboardOptions> dashboardOptions,
ILogger<AspireResourceMcpTools> logger)
{
_dashboardClient = dashboardClient;
_dashboardOptions = dashboardOptions;
_logger = logger;
}

[McpServerTool(Name = "list_resources")]
[Description("List the application resources. Includes information about their type (.NET project, container, executable), running state, source, HTTP endpoints, health status, commands, and relationships.")]
public string ListResources()
{
_logger.LogDebug("MCP tool list_resources called");

try
{
var resources = _dashboardClient.GetResources().ToList();
var filteredResources = GetFilteredResources(resources);

var resourceGraphData = AIHelpers.GetResponseGraphJson(
resources,
filteredResources,
_dashboardOptions.CurrentValue,
includeDashboardUrl: true,
getResourceName: r => ResourceViewModel.GetResourceName(r, resources));
Expand All @@ -57,16 +64,24 @@ public string ListResources()
return "No resources found.";
}

private static List<ResourceViewModel> GetFilteredResources(List<ResourceViewModel> resources)
{
return resources.Where(r => !AIHelpers.IsResourceAIOptOut(r)).ToList();
}

[McpServerTool(Name = "list_console_logs")]
[Description("List console logs for a resource. The console logs includes standard output from resources and resource commands. Known resource commands are 'resource-start', 'resource-stop' and 'resource-restart' which are used to start and stop resources. Don't print the full console logs in the response to the user. Console logs should be examined when determining why a resource isn't running.")]
public async Task<string> ListConsoleLogsAsync(
[Description("The resource name.")]
string resourceName,
CancellationToken cancellationToken)
{
var resources = _dashboardClient.GetResources();
_logger.LogDebug("MCP tool list_console_logs called with resource '{ResourceName}'.", resourceName);

var resources = _dashboardClient.GetResources().ToList();
var filteredResources = GetFilteredResources(resources);

if (AIHelpers.TryGetResource(resources, resourceName, out var resource))
if (AIHelpers.TryGetResource(filteredResources, resourceName, out var resource))
{
resourceName = resource.Name;
}
Expand Down Expand Up @@ -125,9 +140,12 @@ public async Task<string> ListConsoleLogsAsync(
[Description("Executes a command on a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.")]
public async Task ExecuteResourceCommand([Description("The resource name")] string resourceName, [Description("The command name")] string commandName)
{
var resources = _dashboardClient.GetResources();
_logger.LogDebug("MCP tool execute_resource_command called with resource '{ResourceName}' and command '{CommandName}'.", resourceName, commandName);

var resources = _dashboardClient.GetResources().ToList();
var filteredResources = GetFilteredResources(resources);

if (!AIHelpers.TryGetResource(resources, resourceName, out var resource))
if (!AIHelpers.TryGetResource(filteredResources, resourceName, out var resource))
{
throw new McpProtocolException($"Resource '{resourceName}' not found.", McpErrorCode.InvalidParams);
}
Expand Down
49 changes: 43 additions & 6 deletions src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ internal sealed class AspireTelemetryMcpTools
private readonly TelemetryRepository _telemetryRepository;
private readonly IEnumerable<IOutgoingPeerResolver> _outgoingPeerResolvers;
private readonly IOptionsMonitor<DashboardOptions> _dashboardOptions;

public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository, IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers, IOptionsMonitor<DashboardOptions> dashboardOptions)
private readonly IDashboardClient _dashboardClient;
private readonly ILogger<AspireTelemetryMcpTools> _logger;

public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository,
IEnumerable<IOutgoingPeerResolver> outgoingPeerResolvers,
IOptionsMonitor<DashboardOptions> dashboardOptions,
IDashboardClient dashboardClient,
ILogger<AspireTelemetryMcpTools> logger)
{
_telemetryRepository = telemetryRepository;
_outgoingPeerResolvers = outgoingPeerResolvers;
_dashboardOptions = dashboardOptions;
_dashboardClient = dashboardClient;
_logger = logger;
}

[McpServerTool(Name = "list_structured_logs")]
Expand All @@ -36,6 +44,8 @@ public string ListStructuredLogs(
[Description("The resource name. This limits logs returned to the specified resource. If no resource name is specified then structured logs for all resources are returned.")]
string? resourceName = null)
{
_logger.LogDebug("MCP tool list_structured_logs called with resource '{ResourceName}'.", resourceName);

if (!TryResolveResourceNameForTelemetry(resourceName, out var message, out var resourceKey))
{
return message;
Expand All @@ -49,12 +59,21 @@ public string ListStructuredLogs(
StartIndex = 0,
Count = int.MaxValue,
Filters = []
});
}).Items;

if (_dashboardClient.IsEnabled)
{
var optOutResources = GetOptOutResources(_dashboardClient.GetResources());
if (optOutResources.Count > 0)
{
logs = logs.Where(l => !optOutResources.Any(r => l.ResourceView.ResourceKey.EqualsCompositeName(r.Name))).ToList();
}
}

var resources = _telemetryRepository.GetResources();

var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(
logs.Items,
logs,
_dashboardOptions.CurrentValue,
includeDashboardUrl: true,
getResourceName: r => OtlpResource.GetResourceName(r, resources));
Expand All @@ -77,6 +96,8 @@ public string ListTraces(
[Description("The resource name. This limits traces returned to the specified resource. If no resource name is specified then distributed traces for all resources are returned.")]
string? resourceName = null)
{
_logger.LogDebug("MCP tool list_traces called with resource '{ResourceName}'.", resourceName);

if (!TryResolveResourceNameForTelemetry(resourceName, out var message, out var resourceKey))
{
return message;
Expand All @@ -89,12 +110,21 @@ public string ListTraces(
Count = int.MaxValue,
Filters = [],
FilterText = string.Empty
});
}).PagedResult.Items;

if (_dashboardClient.IsEnabled)
{
var optOutResources = GetOptOutResources(_dashboardClient.GetResources());
if (optOutResources.Count > 0)
{
traces = traces.Where(t => !optOutResources.Any(r => t.Spans.Any(s => s.Source.ResourceKey.EqualsCompositeName(r.Name)))).ToList();
}
}

var resources = _telemetryRepository.GetResources();

var (tracesData, limitMessage) = AIHelpers.GetTracesJson(
traces.PagedResult.Items,
traces,
_outgoingPeerResolvers,
_dashboardOptions.CurrentValue,
includeDashboardUrl: true,
Expand All @@ -117,6 +147,8 @@ public string ListTraceStructuredLogs(
[Description("The trace id of the distributed trace.")]
string traceId)
{
_logger.LogDebug("MCP tool list_trace_structured_logs called with trace '{TraceId}'.", traceId);

// Condition of filter should be contains because a substring of the traceId might be provided.
var traceIdFilter = new FieldTelemetryFilter
{
Expand Down Expand Up @@ -177,4 +209,9 @@ private bool TryResolveResourceNameForTelemetry([NotNullWhen(false)] string? res
resourceKey = resource.ResourceKey;
return true;
}

private static List<ResourceViewModel> GetOptOutResources(IEnumerable<ResourceViewModel> resources)
{
return resources.Where(AIHelpers.IsResourceAIOptOut).ToList();
}
}
5 changes: 5 additions & 0 deletions src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,9 @@ private static string GetLimitSummary(int totalValues, int returnedCount, string

return $"Returned latest {itemName.ToQuantity(returnedCount, formatProvider: CultureInfo.InvariantCulture)}. Earlier {itemName.ToQuantity(totalValues - returnedCount, formatProvider: CultureInfo.InvariantCulture)} not returned because of size limits.";
}

public static bool IsResourceAIOptOut(ResourceViewModel r)
{
return r.Properties.TryGetValue(KnownProperties.Resource.ExcludeFromMcp, out var v) && v.Value.TryConvertToBool(out var b) && b;
}
}
16 changes: 16 additions & 0 deletions src/Aspire.Dashboard/Utils/ValueExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ public static bool TryConvertToInt(this Value value, out int i)
return false;
}

public static bool TryConvertToBool(this Value value, out bool b)
{
if (value.HasStringValue && bool.TryParse(value.StringValue, out b))
{
return true;
}
else if (value.HasBoolValue)
{
b = value.BoolValue;
return true;
}

b = false;
return false;
}

public static bool TryConvertToString(this Value value, [NotNullWhen(returnValue: true)] out string? s)
{
if (value.HasStringValue)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

internal sealed class ExcludeFromMcpAnnotation : IResourceAnnotation
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Aspire.Dashboard.Model;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
Expand Down Expand Up @@ -605,6 +606,14 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func<Custo

newState = UpdateIcons(resource, newState);

if (resource.TryGetAnnotationsOfType<ExcludeFromMcpAnnotation>(out _))
{
newState = newState with
{
Properties = newState.Properties.SetResourceProperty(KnownProperties.Resource.ExcludeFromMcp, true)
};
}

notificationState.LastSnapshot = newState;

OnResourceUpdated?.Invoke(new ResourceEvent(resource, resourceId, newState));
Expand Down
13 changes: 13 additions & 0 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2914,4 +2914,17 @@ private static IResourceBuilder<T> WithProbe<T>(this IResourceBuilder<T> builder

return builder.WithAnnotation(probeAnnotation);
}

/// <summary>
/// Exclude the resource from MCP operations using the Aspire MCP server. The resource is excluded from results that return resources, console logs and telemetry.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<T> ExcludeFromMcp<T>(this IResourceBuilder<T> builder) where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);

return builder.WithAnnotation(new ExcludeFromMcpAnnotation());
}
}
1 change: 1 addition & 0 deletions src/Shared/Model/KnownProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public static class Resource
public const string ParentName = "resource.parentName";
public const string AppArgs = "resource.appArgs";
public const string AppArgsSensitivity = "resource.appArgsSensitivity";
public const string ExcludeFromMcp = "resource.excludeFromMcp";
}

public static class Container
Expand Down
64 changes: 63 additions & 1 deletion tests/Aspire.Dashboard.Tests/Mcp/AspireResourceMcpToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
using Aspire.Dashboard.Tests.Model;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Tests.Shared.DashboardModel;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace Aspire.Dashboard.Tests.Mcp;

public class AspireResourceMcpToolsTests
{
private static readonly ResourcePropertyViewModel s_excludeFromMcpProperty = new ResourcePropertyViewModel(KnownProperties.Resource.ExcludeFromMcp, Value.ForBool(true), isValueSensitive: false, knownProperty: null, priority: 0);

[Fact]
public void ListResources_NoResources_ReturnsResourceData()
{
Expand Down Expand Up @@ -66,6 +70,27 @@ public void ListResources_MultipleResources_ReturnsAllResources()
Assert.Contains("app2", result);
}

[Fact]
public void ListResources_OptOutResources_FiltersOptOutResources()
{
// Arrange
var resource1 = ModelTestHelpers.CreateResource(resourceName: "app1");
var resource2 = ModelTestHelpers.CreateResource(
resourceName: "app2",
properties: new Dictionary<string, ResourcePropertyViewModel> { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty });
var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource1, resource2]);
var tools = CreateTools(dashboardClient);

// Act
var result = tools.ListResources();

// Assert
Assert.NotNull(result);
Assert.Contains("# RESOURCE DATA", result);
Assert.Contains("app1", result);
Assert.DoesNotContain("app2", result);
}

[Fact]
public async Task ListConsoleLogsAsync_ResourceNotFound_ReturnsErrorMessage()
{
Expand All @@ -82,6 +107,24 @@ public async Task ListConsoleLogsAsync_ResourceNotFound_ReturnsErrorMessage()
Assert.Contains("Unable to find a resource named 'nonexistent'", result);
}

[Fact]
public async Task ListConsoleLogsAsync_ResourceOptOut_ReturnsErrorMessage()
{
// Arrange
var resource = ModelTestHelpers.CreateResource(
resourceName: "app1",
properties: new Dictionary<string, ResourcePropertyViewModel> { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty });
var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource]);
var tools = CreateTools(dashboardClient);

// Act
var result = await tools.ListConsoleLogsAsync("app1", CancellationToken.None);

// Assert
Assert.NotNull(result);
Assert.Contains("Unable to find a resource named 'app1'", result);
}

[Fact]
public async Task ListConsoleLogsAsync_ResourceFound_ReturnsLogs()
{
Expand Down Expand Up @@ -138,6 +181,24 @@ public async Task ExecuteResourceCommand_ResourceNotFound_ThrowsMcpProtocolExcep
Assert.Contains("Resource 'nonexistent' not found", exception.Message);
}

[Fact]
public async Task ExecuteResourceCommand_ResourceOptOut_ThrowsMcpProtocolException()
{
// Arrange
var resource = ModelTestHelpers.CreateResource(
resourceName: "app1",
commands: ImmutableArray<CommandViewModel>.Empty,
properties: new Dictionary<string, ResourcePropertyViewModel> { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty });
var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource]);
var tools = CreateTools(dashboardClient);

// Act & Assert
var exception = await Assert.ThrowsAsync<ModelContextProtocol.McpProtocolException>(
async () => await tools.ExecuteResourceCommand("app1", "start"));

Assert.Contains("Resource 'app1' not found", exception.Message);
}

[Fact]
public async Task ExecuteResourceCommand_CommandNotFound_ThrowsMcpProtocolException()
{
Expand All @@ -162,6 +223,7 @@ private static AspireResourceMcpTools CreateTools(IDashboardClient dashboardClie

return new AspireResourceMcpTools(
dashboardClient,
new TestOptionsMonitor<DashboardOptions>(options));
new TestOptionsMonitor<DashboardOptions>(options),
NullLogger<AspireResourceMcpTools>.Instance);
}
}
Loading