diff --git a/Directory.Packages.props b/Directory.Packages.props index 6070a5bc7e7..b9f5d85a6d5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -97,7 +97,8 @@ - + + diff --git a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json index 35de4d07f48..24496246d0e 100644 --- a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json +++ b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json @@ -9,6 +9,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16037", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:16036", //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:16038", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037" } @@ -22,6 +23,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16031", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:16033", //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:16032", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17031", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 3a994148c24..68e6935d037 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -210,6 +210,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell env["ASPNETCORE_ENVIRONMENT"] = "Development"; env["DOTNET_ENVIRONMENT"] = "Development"; env["ASPNETCORE_URLS"] = "https://localhost:17193;http://localhost:15069"; + env["ASPIRE_DASHBOARD_MCP_ENDPOINT_URL"] = "https://localhost:21294"; env["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:21293"; env["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:22086"; } diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 2ee81de14b1..34dc1789567 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -19,6 +19,12 @@ Major $(DefineConstants);ASPIRE_DASHBOARD + + + use-roslyn-tokenizer @@ -51,6 +57,8 @@ + + diff --git a/src/Aspire.Dashboard/Authentication/Connection/ConnectionType.cs b/src/Aspire.Dashboard/Authentication/Connection/ConnectionType.cs index 6629ad6f33b..28ca3ddfef0 100644 --- a/src/Aspire.Dashboard/Authentication/Connection/ConnectionType.cs +++ b/src/Aspire.Dashboard/Authentication/Connection/ConnectionType.cs @@ -7,5 +7,6 @@ public enum ConnectionType { None, Frontend, - Otlp + Otlp, + Mcp } diff --git a/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs b/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs index 46c47f04830..615c8edbebf 100644 --- a/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs +++ b/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs @@ -35,6 +35,7 @@ public static class ConnectionTypeAuthenticationDefaults { public const string AuthenticationSchemeFrontend = "ConnectionFrontend"; public const string AuthenticationSchemeOtlp = "ConnectionOtlp"; + public const string AuthenticationSchemeMcp = "ConnectionMcp"; } public sealed class ConnectionTypeAuthenticationHandlerOptions : AuthenticationSchemeOptions diff --git a/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeMiddleware.cs b/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeMiddleware.cs index e1b7de7eebc..1eaadea591d 100644 --- a/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeMiddleware.cs +++ b/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeMiddleware.cs @@ -6,9 +6,9 @@ namespace Aspire.Dashboard.Authentication.Connection; /// -/// This connection middleware registers an OTLP feature on the connection. -/// OTLP services check for this feature when authorizing incoming requests to -/// ensure OTLP is only available on specified connections. +/// This connection middleware registers a connection type feature on the connection. +/// OTLP and MCP services check for this feature when authorizing incoming requests to +/// ensure services are only available on specified connections. /// internal sealed class ConnectionTypeMiddleware { diff --git a/src/Aspire.Dashboard/Components/CustomIcons/AspireIcons.cs b/src/Aspire.Dashboard/Components/CustomIcons/AspireIcons.cs index 229f8ff2178..8e22fe498b7 100644 --- a/src/Aspire.Dashboard/Components/CustomIcons/AspireIcons.cs +++ b/src/Aspire.Dashboard/Components/CustomIcons/AspireIcons.cs @@ -115,6 +115,11 @@ internal sealed class Logo : Icon { public Logo() : base("Logo", IconVariant.Reg ") { } } + internal sealed class McpIcon : Icon { public McpIcon() : base("McpIcon", IconVariant.Regular, IconSize.Size24, + """ + + + """) { } } } internal static class Size48 diff --git a/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor new file mode 100644 index 00000000000..5c4666961c4 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor @@ -0,0 +1,57 @@ +@implements IDialogContentComponent + +@using Aspire.Dashboard.Components.CustomIcons +@using Aspire.Dashboard.Configuration +@using Aspire.Dashboard.Mcp +@using Aspire.Dashboard.Model.Markdown +@using Microsoft.AspNetCore.Components +@using Microsoft.Extensions.Options +@using System.Text.Encodings.Web +@using System.Text.Json + +
+ @if (McpEnabled) + { +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vitae condimentum nulla, nec viverra purus. Morbi cursus egestas leo, eget lacinia quam hendrerit non. +

+
Add to VS Code
+

+ + + VS Code: Install Aspire MCP Server + + VS CodeInstall Aspire MCP Server + + @* + Generated from: + https://img.shields.io/badge/VS_Code-Install_Aspire_MCP_Server-0098FF?style=flat-square&logo=modelcontextprotocol&logoColor=white + *@ + +

+

+ + + VS Code Insiders: Install Aspire MCP Server + + VS Code InsidersInstall Aspire MCP Server + + @* + Generated from: + https://img.shields.io/badge/VS_Code_Insiders-Install_Aspire_MCP_Server-65BBA5?style=flat-square&logo=modelcontextprotocol&logoColor=white + *@ + +

+
MCP JSON
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vitae condimentum nulla, nec viverra purus. Morbi cursus egestas leo, eget lacinia quam hendrerit non. +

+ + } + else + { +

+ MCP isn't configured. +

+ } +
diff --git a/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.cs new file mode 100644 index 00000000000..19cd457877c --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Mcp; +using Aspire.Dashboard.Model.Markdown; +using Aspire.Dashboard.Resources; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class McpServerDialog +{ + [CascadingParameter] + public FluentDialog Dialog { get; set; } = default!; + + [Inject] + public required IStringLocalizer ControlsStringsLoc { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required IOptions DashboardOptions { get; init; } + + private MarkdownProcessor _markdownProcessor = default!; + private string? _mcpServerJson; + private string? _mcpUrl; + + protected override void OnInitialized() + { + _markdownProcessor = new MarkdownProcessor(ControlsStringsLoc, MarkdownHelpers.SafeUrlSchemes, []); + _mcpUrl = DashboardOptions.Value.Mcp.PublicUrl ?? DashboardOptions.Value.Mcp.EndpointUrl; + + if (McpEnabled) + { + _mcpServerJson = GetMcpServerJson(); + } + } + + [MemberNotNullWhen(true, nameof(_mcpServerJson))] + [MemberNotNullWhen(true, nameof(_mcpUrl))] + private bool McpEnabled => !string.IsNullOrEmpty(_mcpUrl); + + private string GetMcpServerJson() + { + Dictionary? headers = null; + + if (DashboardOptions.Value.Mcp.AuthMode == McpAuthMode.ApiKey) + { + headers = new Dictionary + { + [McpApiKeyAuthenticationHandler.ApiKeyHeaderName] = DashboardOptions.Value.Mcp.PrimaryApiKey! + }; + } + + var url = new Uri(baseUri: new Uri(_mcpUrl!), relativeUri: "/mcp").ToString(); + + return JsonSerializer.Serialize( + new McpServerModel + { + Name = "aspire-dashboard", + Type = "http", + Url = url, + Headers = headers + }, + McpServerModelContext.Default.McpServerModel); + } + + private string GetJsonConfigurationMarkdown() => + $""" + ```json + {_mcpServerJson} + ``` + """; +} diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index bcd224e0bdf..1571843541b 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -1,5 +1,6 @@ @using Aspire.Dashboard.Components.CustomIcons @using Aspire.Dashboard.Components.Interactions +@using Aspire.Dashboard.Components.Dialogs @using Aspire.Dashboard.Model @using Aspire.Dashboard.Model.Assistant @using Aspire.Dashboard.Resources @@ -33,10 +34,18 @@ Title="@Loc[nameof(Layout.MainLayoutAspireDashboardHelpLink)]" aria-label="@Loc[nameof(Layout.MainLayoutAspireDashboardHelpLink)]"> + @if (!Options.CurrentValue.Mcp.Disabled.GetValueOrDefault()) + { + + + + } @if (AIContextProvider.Enabled) { diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index bc76e3fb953..677a83f2398 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -29,6 +29,7 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable private IDisposable? _aiDisplayChangedSubscription; private const string SettingsDialogId = "SettingsDialog"; private const string HelpDialogId = "HelpDialog"; + private const string McpDialogId = "McpServerDialog"; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -145,6 +146,36 @@ await MessageService.ShowMessageBarAsync(options => } } + if (Options.CurrentValue.Mcp.AuthMode == Mcp.McpAuthMode.Unsecured) + { + var dismissedResult = await LocalStorage.GetUnprotectedAsync(BrowserStorageKeys.UnsecuredMcpMessageDismissedKey); + var skipMessage = dismissedResult.Success && dismissedResult.Value; + + if (!skipMessage) + { + // ShowMessageBarAsync must come after an await. Otherwise it will NRE. + // I think this order allows the message bar provider to be fully initialized. + await MessageService.ShowMessageBarAsync(options => + { + options.Title = Loc[nameof(Resources.Layout.MessageMcpTitle)]; + options.Body = Loc[nameof(Resources.Layout.MessageMcpBody)]; + options.Link = new() + { + Text = Loc[nameof(Resources.Layout.MessageMcpLink)], + Href = "https://aka.ms/dotnet/aspire/mcp-unsecured", + Target = "_blank" + }; + options.Intent = MessageIntent.Warning; + options.Section = DashboardUIHelpers.MessageBarSection; + options.AllowDismiss = true; + options.OnClose = async m => + { + await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.UnsecuredMcpMessageDismissedKey, true); + }; + }); + } + } + _aiDisplayChangedSubscription = AIContextProvider.OnDisplayChanged(() => InvokeAsync(StateHasChanged)); } @@ -169,6 +200,36 @@ protected override void OnParametersSet() } } + private async Task LaunchMcpAsync() + { + DialogParameters parameters = new() + { + Title = "Aspire MCP server", + DismissTitle = DialogsLoc[nameof(Resources.Dialogs.DialogCloseButtonText)], + PrimaryAction = "Close", + PrimaryActionEnabled = true, + SecondaryAction = null, + TrapFocus = true, + Modal = true, + Alignment = HorizontalAlignment.Center, + Width = "700px", + Height = "auto", + Id = McpDialogId, + OnDialogClosing = EventCallback.Factory.Create(this, HandleDialogClose) + }; + + if (_openPageDialog is not null) + { + if (Equals(_openPageDialog.Id, McpDialogId)) + { + return; + } + + await _openPageDialog.CloseAsync(); + } + + _openPageDialog = await DialogService.ShowDialogAsync(parameters).ConfigureAwait(true); + } private async Task LaunchHelpAsync() { DialogParameters parameters = new() diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 883f51607b5..103623f0ecb 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography.X509Certificates; using System.Text; +using Aspire.Dashboard.Mcp; using Aspire.Hosting; namespace Aspire.Dashboard.Configuration; @@ -13,6 +14,7 @@ public sealed class DashboardOptions { public string? ApplicationName { get; set; } public OtlpOptions Otlp { get; set; } = new(); + public McpOptions Mcp { get; set; } = new(); public FrontendOptions Frontend { get; set; } = new(); public ResourceServiceClientOptions ResourceServiceClient { get; set; } = new(); public TelemetryLimitOptions TelemetryLimits { get; set; } = new(); @@ -146,6 +148,48 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) } } +public class McpOptions +{ + private BindingAddress? _parsedEndpointAddress; + private byte[]? _primaryApiKeyBytes; + private byte[]? _secondaryApiKeyBytes; + + public bool? Disabled { get; set; } + public McpAuthMode? AuthMode { get; set; } + public string? PrimaryApiKey { get; set; } + public string? SecondaryApiKey { get; set; } + public string? EndpointUrl { get; set; } + public string? PublicUrl { get; set; } + + public BindingAddress? GetEndpointAddress() + { + return _parsedEndpointAddress; + } + + public byte[] GetPrimaryApiKeyBytes() + { + Debug.Assert(_primaryApiKeyBytes is not null, "Should have been parsed during validation."); + return _primaryApiKeyBytes; + } + + public byte[]? GetSecondaryApiKeyBytes() => _secondaryApiKeyBytes; + + internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) + { + if (!string.IsNullOrEmpty(EndpointUrl) && !OptionsHelpers.TryParseBindingAddress(EndpointUrl, out _parsedEndpointAddress)) + { + errorMessage = $"Failed to parse MCP endpoint URL '{EndpointUrl}'."; + return false; + } + + _primaryApiKeyBytes = PrimaryApiKey != null ? Encoding.UTF8.GetBytes(PrimaryApiKey) : null; + _secondaryApiKeyBytes = SecondaryApiKey != null ? Encoding.UTF8.GetBytes(SecondaryApiKey) : null; + + errorMessage = null; + return true; + } +} + public sealed class OtlpCors { public string? AllowedOrigins { get; set; } diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index a9719420e39..beec68b87b1 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Mcp; using Aspire.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -40,6 +41,12 @@ public void PostConfigure(string? name, DashboardOptions options) options.Otlp.HttpEndpointUrl = otlpHttpUrl; } + // Copy aliased config values to the strongly typed options. + if (_configuration[DashboardConfigNames.DashboardMcpUrlName.ConfigKey] is { Length: > 0 } mcpUrl) + { + options.Mcp.EndpointUrl = mcpUrl; + } + if (_configuration[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] is { Length: > 0 } frontendUrls) { options.Frontend.EndpointUrls = frontendUrls; @@ -56,11 +63,13 @@ public void PostConfigure(string? name, DashboardOptions options) { options.Frontend.AuthMode = FrontendAuthMode.Unsecured; options.Otlp.AuthMode = OtlpAuthMode.Unsecured; + options.Mcp.AuthMode = McpAuthMode.Unsecured; } else { options.Frontend.AuthMode ??= FrontendAuthMode.BrowserToken; options.Otlp.AuthMode ??= OtlpAuthMode.Unsecured; + options.Mcp.AuthMode ??= McpAuthMode.Unsecured; } if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken && string.IsNullOrEmpty(options.Frontend.BrowserToken)) diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index 3d9bb3f2b28..5833ffc6b92 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Mcp; using Aspire.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -93,6 +94,24 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) break; } + if (!options.Mcp.TryParseOptions(out var mcpParseErrorMessage)) + { + errorMessages.Add(mcpParseErrorMessage); + } + + switch (options.Mcp.AuthMode) + { + case McpAuthMode.Unsecured: + break; + case McpAuthMode.ApiKey: + if (string.IsNullOrEmpty(options.Mcp.PrimaryApiKey)) + { + errorMessages.Add($"PrimaryApiKey is required when MCP authentication mode is API key. Specify a {DashboardConfigNames.DashboardMcpPrimaryApiKeyName.ConfigKey} value."); + } + break; + + } + if (!options.ResourceServiceClient.TryParseOptions(out var resourceServiceClientParseErrorMessage)) { errorMessages.Add(resourceServiceClientParseErrorMessage); diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index ff9b034e8b2..830a5479b0b 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -15,6 +15,7 @@ using Aspire.Dashboard.Components; using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Mcp; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Assistant; using Aspire.Dashboard.Model.Assistant.Prompts; @@ -49,6 +50,7 @@ public sealed class DashboardWebApplication : IAsyncDisposable { private const string DashboardAuthCookieName = ".Aspire.Dashboard.Auth"; private const string DashboardAntiForgeryCookieName = ".Aspire.Dashboard.Antiforgery"; + private static readonly List s_allConnectionTypes = [ConnectionType.Frontend, ConnectionType.Otlp, ConnectionType.Mcp]; private readonly WebApplication _app; private readonly ILogger _logger; @@ -57,6 +59,7 @@ public sealed class DashboardWebApplication : IAsyncDisposable private readonly List> _frontendEndPointAccessor = new(); private Func? _otlpServiceGrpcEndPointAccessor; private Func? _otlpServiceHttpEndPointAccessor; + private Func? _mcpEndPointAccessor; public List> FrontendEndPointsAccessor { @@ -98,6 +101,11 @@ public Func OtlpServiceHttpEndPointAccessor get => _otlpServiceHttpEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet."); } + public Func McpEndPointAccessor + { + get => _mcpEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet."); + } + public IOptionsMonitor DashboardOptionsMonitor => _dashboardOptionsMonitor; public IReadOnlyList ValidationFailures => _validationFailures; @@ -255,6 +263,12 @@ public DashboardWebApplication( // Data from the server. builder.Services.TryAddSingleton(); + + // Host an in-process MCP server so the dashboard can expose MCP tools (resource listing, diagnostics). + // Register the MCP server directly via the SDK. + + builder.Services.AddAspireMcpTools(); + builder.Services.TryAddScoped(); builder.Services.AddSingleton(); @@ -372,6 +386,11 @@ public DashboardWebApplication( _logger.LogWarning("OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. For more information, visit https://go.microsoft.com/fwlink/?linkid=2267030"); } + if (_dashboardOptionsMonitor.CurrentValue.Mcp.AuthMode == McpAuthMode.Unsecured) + { + _logger.LogWarning("MCP server is unsecured. Untrusted apps can access sensitive information."); + } + // Log frontend login URL last at startup so it's easy to find in the logs. if (frontendEndpointInfo != null) { @@ -427,6 +446,11 @@ public DashboardWebApplication( _app.UseMiddleware(); + if (!_dashboardOptionsMonitor.CurrentValue.Mcp.Disabled.GetValueOrDefault()) + { + _app.MapMcp("/mcp").RequireAuthorization(McpApiKeyAuthenticationHandler.PolicyName); + } + // Configure the HTTP request pipeline. if (!_app.Environment.IsDevelopment()) { @@ -540,7 +564,8 @@ private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardO var frontendAddresses = dashboardOptions.Frontend.GetEndpointAddresses(); var otlpGrpcAddress = dashboardOptions.Otlp.GetGrpcEndpointAddress(); var otlpHttpAddress = dashboardOptions.Otlp.GetHttpEndpointAddress(); - var hasSingleEndpoint = frontendAddresses.Count == 1 && IsSameOrNull(frontendAddresses[0], otlpGrpcAddress) && IsSameOrNull(frontendAddresses[0], otlpHttpAddress); + var mcpAddress = dashboardOptions.Mcp.GetEndpointAddress(); + var hasSingleEndpoint = frontendAddresses.Count == 1 && IsSameOrNull(frontendAddresses[0], otlpGrpcAddress) && IsSameOrNull(frontendAddresses[0], otlpHttpAddress) && IsSameOrNull(frontendAddresses[0], mcpAddress); var initialValues = new Dictionary(); var browserEndpointNames = new List(capacity: frontendAddresses.Count); @@ -557,6 +582,10 @@ private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardO { AddEndpointConfiguration(initialValues, "OtlpHttp", otlpHttpAddress.ToString(), HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate); } + if (mcpAddress != null) + { + AddEndpointConfiguration(initialValues, "Mcp", mcpAddress.ToString(), HttpProtocols.Http1AndHttp2); + } if (frontendAddresses.Count == 1) { @@ -625,17 +654,7 @@ static void AddEndpointConfiguration(Dictionary values, string _otlpServiceGrpcEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); if (hasSingleEndpoint) { - logger.LogDebug("Browser and OTLP accessible on a single endpoint."); - - if (!endpointConfiguration.IsHttps) - { - logger.LogWarning( - "The dashboard is configured with a shared endpoint for browser access and the OTLP service. " + - "The endpoint doesn't use TLS so browser access is only possible via a TLS terminating proxy."); - } - - connectionTypes.Add(ConnectionType.Frontend); - _frontendEndPointAccessor.Add(_otlpServiceGrpcEndPointAccessor); + ConfigureSingleEndpoint(logger, endpointConfiguration.IsHttps, _otlpServiceGrpcEndPointAccessor, _frontendEndPointAccessor, connectionTypes); } endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray()); @@ -657,17 +676,7 @@ static void AddEndpointConfiguration(Dictionary values, string _otlpServiceHttpEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); if (hasSingleEndpoint) { - logger.LogDebug("Browser and OTLP accessible on a single endpoint."); - - if (!endpointConfiguration.IsHttps) - { - logger.LogWarning( - "The dashboard is configured with a shared endpoint for browser access and the OTLP service. " + - "The endpoint doesn't use TLS so browser access is only possible via a TLS terminating proxy."); - } - - connectionTypes.Add(ConnectionType.Frontend); - _frontendEndPointAccessor.Add(_otlpServiceHttpEndPointAccessor); + ConfigureSingleEndpoint(logger, endpointConfiguration.IsHttps, _otlpServiceHttpEndPointAccessor, _frontendEndPointAccessor, connectionTypes); } endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray()); @@ -678,8 +687,43 @@ static void AddEndpointConfiguration(Dictionary values, string endpointConfiguration.HttpsOptions.ClientCertificateValidation = (certificate, chain, sslPolicyErrors) => { return true; }; } }); + + configurationLoader.Endpoint("Mcp", endpointConfiguration => + { + var connectionTypes = new List { ConnectionType.Mcp }; + + _mcpEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); + if (hasSingleEndpoint) + { + ConfigureSingleEndpoint(logger, endpointConfiguration.IsHttps, _mcpEndPointAccessor, _frontendEndPointAccessor, connectionTypes); + } + + endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray()); + }); }); + static void ConfigureSingleEndpoint(ILogger logger, bool isHttps, Func endpointAccessor, List> frontEndPointAccessors, List connectionTypes) + { + logger.LogDebug("Browser and OTLP accessible on a single endpoint."); + + if (!isHttps) + { + logger.LogWarning( + "The dashboard is configured with a shared endpoint for browser access and the OTLP service. " + + "The endpoint doesn't use TLS so browser access is only possible via a TLS terminating proxy."); + } + + foreach (var connectionType in s_allConnectionTypes) + { + if (!connectionTypes.Contains(connectionType)) + { + connectionTypes.Add(connectionType); + } + } + + frontEndPointAccessors.Add(endpointAccessor); + } + static Func CreateEndPointAccessor(EndpointConfiguration endpointConfiguration) { // We want to provide a way for someone to get the IP address of an endpoint. @@ -709,8 +753,11 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb .AddScheme(FrontendCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) .AddScheme(OtlpCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) .AddScheme(OtlpApiKeyAuthenticationDefaults.AuthenticationScheme, o => { }) + .AddScheme(McpCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) + .AddScheme(McpApiKeyAuthenticationHandler.AuthenticationScheme, o => { }) .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeFrontend, o => o.RequiredConnectionType = ConnectionType.Frontend) .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeOtlp, o => o.RequiredConnectionType = ConnectionType.Otlp) + .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeMcp, o => o.RequiredConnectionType = ConnectionType.Mcp) .AddCertificate(options => { // Bind options to configuration so they can be overridden by environment variables. @@ -850,6 +897,12 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb .RequireClaim(OtlpAuthorization.OtlpClaimName, [bool.TrueString]) .Build()); + options.AddPolicy( + name: McpApiKeyAuthenticationHandler.PolicyName, + policy: new AuthorizationPolicyBuilder(McpCompositeAuthenticationDefaults.AuthenticationScheme) + .RequireClaim(McpApiKeyAuthenticationHandler.McpClaimName, [bool.TrueString]) + .Build()); + switch (dashboardOptions.Frontend.AuthMode) { case FrontendAuthMode.OpenIdConnect: diff --git a/src/Aspire.Dashboard/Mcp/McpApiKeyAuthenticationHandler.cs b/src/Aspire.Dashboard/Mcp/McpApiKeyAuthenticationHandler.cs new file mode 100644 index 00000000000..e4b397f5182 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpApiKeyAuthenticationHandler.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Encodings.Web; +using Aspire.Dashboard.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.Mcp; + +public class McpApiKeyAuthenticationHandler : AuthenticationHandler +{ + public const string PolicyName = "McpPolicy"; + public const string McpClaimName = "McpClaim"; + + public const string AuthenticationScheme = "McpApiKey"; + public const string ApiKeyHeaderName = "x-mcp-api-key"; + + private readonly IOptionsMonitor _dashboardOptions; + + public McpApiKeyAuthenticationHandler(IOptionsMonitor dashboardOptions, IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) + { + _dashboardOptions = dashboardOptions; + } + + protected override Task HandleAuthenticateAsync() + { + var options = _dashboardOptions.CurrentValue.Mcp; + + if (Context.Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKey)) + { + // There must be only one header with the API key. + if (apiKey.Count != 1) + { + return Task.FromResult(AuthenticateResult.Fail($"Multiple '{ApiKeyHeaderName}' headers in request.")); + } + + if (!CompareHelpers.CompareKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString())) + { + if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareHelpers.CompareKey(secondaryBytes, apiKey.ToString())) + { + return Task.FromResult(AuthenticateResult.Fail($"Incoming API key from '{ApiKeyHeaderName}' header doesn't match configured API key.")); + } + } + } + else + { + return Task.FromResult(AuthenticateResult.Fail($"API key from '{ApiKeyHeaderName}' header is missing.")); + } + + return Task.FromResult(AuthenticateResult.NoResult()); + } +} + +public sealed class McpApiKeyAuthenticationHandlerOptions : AuthenticationSchemeOptions +{ +} diff --git a/src/Aspire.Dashboard/Mcp/McpAuthMode.cs b/src/Aspire.Dashboard/Mcp/McpAuthMode.cs new file mode 100644 index 00000000000..571c9848489 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpAuthMode.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Mcp; + +public enum McpAuthMode +{ + Unsecured, + ApiKey, +} diff --git a/src/Aspire.Dashboard/Mcp/McpCompositeAuthenticationHandler.cs b/src/Aspire.Dashboard/Mcp/McpCompositeAuthenticationHandler.cs new file mode 100644 index 00000000000..00bcfd10311 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpCompositeAuthenticationHandler.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Aspire.Dashboard.Authentication.Connection; +using Aspire.Dashboard.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.Mcp; + +public sealed class McpCompositeAuthenticationHandler( + IOptionsMonitor dashboardOptions, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task HandleAuthenticateAsync() + { + var options = dashboardOptions.CurrentValue; + + foreach (var scheme in GetRelevantAuthenticationSchemes()) + { + var result = await Context.AuthenticateAsync(scheme).ConfigureAwait(false); + + if (result.Failure is not null) + { + return result; + } + } + + var id = new ClaimsIdentity([new Claim(McpApiKeyAuthenticationHandler.McpClaimName, bool.TrueString)]); + + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(id), Scheme.Name)); + + IEnumerable GetRelevantAuthenticationSchemes() + { + yield return ConnectionTypeAuthenticationDefaults.AuthenticationSchemeMcp; + + if (options.Mcp.AuthMode is McpAuthMode.ApiKey) + { + yield return McpApiKeyAuthenticationHandler.AuthenticationScheme; + } + } + } +} + +public static class McpCompositeAuthenticationDefaults +{ + public const string AuthenticationScheme = "McpComposite"; +} + +public sealed class McpCompositeAuthenticationHandlerOptions : AuthenticationSchemeOptions +{ +} diff --git a/src/Aspire.Dashboard/Mcp/McpExtensions.cs b/src/Aspire.Dashboard/Mcp/McpExtensions.cs new file mode 100644 index 00000000000..bf8d75197b4 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ModelContextProtocol.Protocol; + +namespace Aspire.Dashboard.Mcp; + +public static class McpExtensions +{ + public static IMcpServerBuilder AddAspireMcpTools(this IServiceCollection services) + { + var builder = services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "Aspire MCP Server", Version = "1.0.0" }; + options.ServerInstructions = + """ + ## Description + This MCP Server provides various tools for managing Aspire resources, logs, traces and commands. + + ## Instructions + - When a resource name is returned, render it in bold chars like **resourceName** + - When a resource state (running, stopped, starting, ...) is returned, render it in italic chars like *running*, and add a colored badge next to it (green, red, orange, ...). + + ## Tools + + """; + }).WithHttpTransport(); + + builder.WithTools(); + + return builder; + } +} diff --git a/src/Aspire.Dashboard/Mcp/McpServerModel.cs b/src/Aspire.Dashboard/Mcp/McpServerModel.cs new file mode 100644 index 00000000000..d6522146d0d --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpServerModel.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Aspire.Dashboard.Mcp; + +public sealed class McpServerModel +{ + public required string Name { get; init; } + public required string Type { get; init; } + public required string Url { get; init; } + public Dictionary? Headers { get; init; } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] +[JsonSerializable(typeof(McpServerModel))] +[JsonSerializable(typeof(Dictionary))] +public sealed partial class McpServerModelContext : JsonSerializerContext; diff --git a/src/Aspire.Dashboard/Mcp/ResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/ResourceMcpTools.cs new file mode 100644 index 00000000000..8aec54edb22 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/ResourceMcpTools.cs @@ -0,0 +1,385 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Aspire.Dashboard.ConsoleLogs; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Assistant; +using Aspire.Dashboard.Model.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.Hosting.ConsoleLogs; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace Aspire.Dashboard.Mcp; + +[McpServerToolType] +internal sealed class ResourceMcpTools +{ + private static readonly ConcurrentDictionary s_referencedTraces = new(); + private static readonly ConcurrentDictionary s_referencedLogs = new(); + + private readonly TelemetryRepository _telemetryRepository; + private readonly IDashboardClient _dashboardClient; + private readonly IEnumerable _outgoingPeerResolvers; + + public ResourceMcpTools(TelemetryRepository telemetryRepository, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) + { + _telemetryRepository = telemetryRepository; + _dashboardClient = dashboardClient; + _outgoingPeerResolvers = outgoingPeerResolvers; + } + + [McpServerTool(Name = "aspire_resource_graph"), Description("Get the application resources. Includes information about their type (.NET project, container, executable), running state, source, HTTP endpoints, health status and relationships.")] + public string GetResourceGraph() + { + try + { + var cts = new CancellationTokenSource(millisecondsDelay: 500); + var resources = _dashboardClient.GetResources(); + + var resourceGraphData = AIHelpers.GetResponseGraphJson(resources.ToList()); + + var response = $""" + Always format resource_name in the response as code like this: `frontend-abcxyz` + Console logs for a resource can provide more information about why a resource is not in a running state. + + # RESOURCE GRAPH DATA + + {resourceGraphData} + """; + + return response; + } + catch { } + + return "No resources found."; + } + + [McpServerTool(Name = "aspire_structured_logs"), Description("Get structured logs for resources.")] + public string GetStructuredLogs( + [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) + { + // TODO: The resourceName might be a name that resolves to multiple replicas, e.g. catalogservice has two replicas. + // Support resolving to multiple replicas and getting data for them. + if (!TryResolveResourceNameForTelemetry(resourceName, out var message, out var resourceKey)) + { + return message; + } + + // Get all logs because we want the most recent logs and they're at the end of the results. + // If support is added for ordering logs by timestamp then improve this. + var logs = _telemetryRepository.GetLogs(new GetLogsContext + { + ResourceKey = resourceKey, + StartIndex = 0, + Count = int.MaxValue, + Filters = [] + }); + + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(logs.Items); + + var response = $""" + Always format log_id in the response as code like this: `log_id: 123`. + {limitMessage} + + # STRUCTURED LOGS DATA + + {logsData} + """; + + return response; + } + + [McpServerTool(Name = "aspire_traces"), Description("Get distributed traces for resources. A distributed trace is used to track operations. A distributed trace can span multiple resources across a distributed system. Includes a list of distributed traces with their IDs, resources in the trace, duration and whether an error occurred in the trace.")] + public string GetTraces( + [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) + { + // TODO: The resourceName might be a name that resolves to multiple replicas, e.g. catalogservice has two replicas. + // Support resolving to multiple replicas and getting data for them. + if (!TryResolveResourceNameForTelemetry(resourceName, out var message, out var resourceKey)) + { + return message; + } + + var traces = _telemetryRepository.GetTraces(new GetTracesRequest + { + ResourceKey = resourceKey, + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + FilterText = string.Empty + }); + + var (tracesData, limitMessage) = AIHelpers.GetTracesJson(traces.PagedResult.Items, _outgoingPeerResolvers); + + var response = $""" + {limitMessage} + + # TRACES DATA + + {tracesData} + """; + + return response; + } + + [McpServerTool(Name = "aspire_trace_structured_logs"), Description("Get structured logs for a distributed trace. Logs for a distributed trace each belong to a span identified by 'span_id'. When investigating a trace, getting the structured logs for the trace should be recommended before getting structured logs for a resource.")] + public string GetTraceStructuredLogs( + [Description("The trace id of the distributed trace.")] + string traceId) + { + // Condition of filter should be contains because a substring of the traceId might be provided. + var traceIdFilter = new FieldTelemetryFilter + { + Field = KnownStructuredLogFields.TraceIdField, + Value = traceId, + Condition = FilterCondition.Contains + }; + + var logs = _telemetryRepository.GetLogs(new GetLogsContext + { + ResourceKey = null, + Count = int.MaxValue, + StartIndex = 0, + Filters = [traceIdFilter] + }); + + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(logs.Items); + + var response = $""" + {limitMessage} + + # STRUCTURED LOGS DATA + + {logsData} + """; + + return response; + } + + [McpServerTool(Name = "aspire_console_logs"), Description("Get 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 GetConsoleLogsAsync( + [Description("The resource name.")] + string resourceName, + CancellationToken cancellationToken) + { + var resources = _dashboardClient.GetResources(); + + if (AIHelpers.TryGetResource(resources, resourceName, out var resource)) + { + resourceName = resource.Name; + } + else + { + return $"Unable to find a resource named '{resourceName}'."; + } + + var logParser = new LogParser(ConsoleColor.Black); + var logEntries = new LogEntries(maximumEntryCount: AIHelpers.ConsoleLogsLimit) { BaseLineNumber = 1 }; + + // Add a timeout for getting all console logs. + using var subscribeConsoleLogsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + subscribeConsoleLogsCts.CancelAfter(TimeSpan.FromSeconds(20)); + + try + { + await foreach (var entry in _dashboardClient.GetConsoleLogs(resourceName, subscribeConsoleLogsCts.Token).ConfigureAwait(false)) + { + foreach (var logLine in entry) + { + logEntries.InsertSorted(logParser.CreateLogEntry(logLine.Content, logLine.IsErrorMessage, resourceName)); + } + } + } + catch (OperationCanceledException) + { + return $"Timeout getting console logs for `{resourceName}`"; + } + + var entries = logEntries.GetEntries().ToList(); + var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; + var (trimmedItems, limitMessage) = AIHelpers.GetLimitFromEndWithSummary( + entries, + totalLogsCount, + AIHelpers.ConsoleLogsLimit, + "console log", + AIHelpers.SerializeLogEntry, + logEntry => AIHelpers.EstimateTokenCount((string)logEntry)); + var consoleLogsText = AIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + + var consoleLogsData = $""" + {limitMessage} + + # CONSOLE LOGS + + ```plaintext + {consoleLogsText.Trim()} + ``` + """; + + return consoleLogsData; + } + + [McpServerTool(Name = "aspire_list_commands"), Description("Lists the command names available for a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.")] + public static string GetResourceCommands(IDashboardClient dashboardClient, [Description("The resource name.")] string resourceName) + { + var resource = dashboardClient.GetResource(resourceName); + + if (resource == null) + { + throw new McpException($"Resource '{resourceName}' not found.", McpErrorCode.InvalidParams); + } + + // Only include commands that can be executed (Enabled). + + return string.Join("\n---\n", resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled).Select(cmd => cmd.Name)); + } + + [McpServerTool(Name = "aspire_execute_command"), Description("Executes a command on a resource.")] + public static async Task ExecuteCommand(IDashboardClient dashboardClient, [Description("The resource name")] string resourceName, [Description("The command name")] string commandName) + { + var resource = dashboardClient.GetResource(resourceName); + + if (resource == null) + { + throw new McpException($"Resource '{resourceName}' not found.", McpErrorCode.InvalidParams); + } + + var command = resource.Commands.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.Ordinal)); + + if (command is null) + { + throw new McpException($"Command '{commandName}' not found for resource '{resourceName}'.", McpErrorCode.InvalidParams); + } + + // Block execution when command isn't available. + if (command.State == Model.CommandViewModelState.Hidden) + { + throw new McpException($"Command '{commandName}' is not available for resource '{resourceName}'.", McpErrorCode.InvalidParams); + } + + if (command.State == Model.CommandViewModelState.Disabled) + { + if (command.Name == "resource-restart" && resource.Commands.Any(c => c.Name == "resource-start" && c.State == CommandViewModelState.Enabled)) + { + throw new McpException($"Resource '{resourceName}' is stopped. Use the 'resource-start' command instead of 'resource-restart'.", McpErrorCode.InvalidParams); + } + + throw new McpException($"Command '{commandName}' is currently disabled for resource '{resourceName}'.", McpErrorCode.InvalidParams); + } + + try + { + var response = await dashboardClient.ExecuteResourceCommandAsync(resource.Name, resource.ResourceType, command, CancellationToken.None).ConfigureAwait(false); + + switch (response.Kind) + { + case Model.ResourceCommandResponseKind.Succeeded: + return; + case Model.ResourceCommandResponseKind.Cancelled: + throw new McpException($"Command '{commandName}' was cancelled.", McpErrorCode.InternalError); + case Model.ResourceCommandResponseKind.Failed: + default: + var message = response.ErrorMessage is { Length: > 0 } ? response.ErrorMessage : "Unknown error. See logs for details."; + throw new McpException($"Command '{commandName}' failed for resource '{resourceName}': {message}", McpErrorCode.InternalError); + } + } + catch (McpException) + { + throw; + } + catch (Exception ex) + { + throw new McpException($"Error executing command '{commandName}' for resource '{resourceName}': {ex.Message}", McpErrorCode.InternalError); + } + } + + private bool TryResolveResourceNameForTelemetry([NotNullWhen(false)] string? resourceName, [NotNullWhen(false)] out string? message, out ResourceKey? resourceKey) + { + if (AIHelpers.IsMissingValue(resourceName)) + { + message = null; + resourceKey = null; + return true; + } + + var resources = _dashboardClient.GetResources(); + + if (!AIHelpers.TryGetResource(resources, resourceName, out var resource)) + { + message = $"Unable to find a resource named '{resourceName}'."; + resourceKey = null; + return false; + } + + var appKey = ResourceKey.Create(resource.Name, resource.Name); + var apps = _telemetryRepository.GetResources(appKey); + if (apps.Count == 0) + { + message = $"Resource '{resourceName}' doesn't have any telemetry. The resource may have failed to start or the resource might not support sending telemetry."; + resourceKey = null; + return false; + } + + message = null; + resourceKey = appKey; + return true; + } + + public bool TryGetTrace(string text, [NotNullWhen(true)] out OtlpTrace? trace) + { + // TODO: Traces are mutable. It's possible the trace has been updated since it was last fetched. + // Check if the root span isn't finished yet and go back to repository to get for a new version. + if (s_referencedTraces.TryGetValue(text, out trace)) + { + return true; + } + + trace = _telemetryRepository.GetTrace(text); + if (trace != null) + { + s_referencedTraces.TryAdd(trace.TraceId, trace); + return true; + } + + return false; + } + + public static void AddReferencedLogEntry(OtlpLogEntry logEntry) + { + s_referencedLogs[logEntry.InternalId] = logEntry; + } + + public bool TryGetLog(long internalId, [NotNullWhen(true)] out OtlpLogEntry? logEntry) + { + if (s_referencedLogs.TryGetValue(internalId, out logEntry)) + { + return true; + } + + logEntry = _telemetryRepository.GetLog(internalId); + if (logEntry != null) + { + s_referencedLogs.TryAdd(logEntry.InternalId, logEntry); + return true; + } + + return false; + } + + public static IEnumerable GetReferencedTraces() + { + return s_referencedTraces.Values; + } + + public static void AddReferencedTrace(OtlpTrace trace) + { + s_referencedTraces[trace.TraceId] = trace; + } +} diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index 6efaa73f362..627e2c2893b 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -12,6 +12,7 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Resources; using Aspire.Hosting.ConsoleLogs; +using Humanizer; using Microsoft.Extensions.AI; using Microsoft.Extensions.Localization; @@ -19,6 +20,10 @@ namespace Aspire.Dashboard.Model.Assistant; internal static class AIHelpers { + public const int TracesLimit = 200; + public const int StructuredLogsLimit = 200; + public const int ConsoleLogsLimit = 500; + // There is currently a 64K token limit in VS. // Limit the result from individual token calls to a smaller number so multiple results can live inside the context. public const int MaximumListTokenLength = 8192; @@ -103,9 +108,9 @@ private static int ConvertToMilliseconds(TimeSpan duration) public static (string json, string limitMessage) GetTracesJson(List traces, IEnumerable outgoingPeerResolvers) { var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = AssistantChatDataContext.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( traces, - AssistantChatDataContext.TracesLimit, + TracesLimit, "trace", trace => GetTraceDto(trace, outgoingPeerResolvers, promptContext), EstimateSerializedJsonTokenSize); @@ -244,9 +249,9 @@ private static string SerializeJson(T value) public static (string json, string limitMessage) GetStructuredLogsJson(List errorLogs) { var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = AssistantChatDataContext.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( errorLogs, - AssistantChatDataContext.StructuredLogsLimit, + StructuredLogsLimit, "log entry", i => GetLogEntryDto(i, promptContext), EstimateSerializedJsonTokenSize); @@ -426,4 +431,52 @@ public static string LimitLength(string value) {value.AsSpan(0, MaximumStringLength)}...[TRUNCATED] """; } + + public static (List items, string message) GetLimitFromEndWithSummary(List values, int limit, string itemName, Func convertToDto, Func estimateTokenSize) + { + return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, convertToDto, estimateTokenSize); + } + + public static (List items, string message) GetLimitFromEndWithSummary(List values, int totalValues, int limit, string itemName, Func convertToDto, Func estimateTokenSize) + { + Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method."); + + var trimmedItems = values.Count <= limit + ? values + : values[^limit..]; + + var currentTokenCount = 0; + var serializedValuesCount = 0; + var dtos = trimmedItems.Select(i => convertToDto(i)).ToList(); + + // Loop backwards to prioritize the latest items. + for (var i = dtos.Count - 1; i >= 0; i--) + { + var obj = dtos[i]; + var tokenCount = estimateTokenSize(obj); + + if (currentTokenCount + tokenCount > AIHelpers.MaximumListTokenLength) + { + break; + } + + serializedValuesCount++; + currentTokenCount += tokenCount; + } + + // Trim again with what fits in the token limit. + dtos = dtos[^serializedValuesCount..]; + + return (dtos, GetLimitSummary(totalValues, dtos.Count, itemName)); + } + + private static string GetLimitSummary(int totalValues, int returnedCount, string itemName) + { + if (totalValues == returnedCount) + { + return $"Returned {itemName.ToQuantity(totalValues, formatProvider: CultureInfo.InvariantCulture)}."; + } + + return $"Returned latest {itemName.ToQuantity(returnedCount, formatProvider: CultureInfo.InvariantCulture)}. Earlier {itemName.ToQuantity(totalValues - returnedCount, formatProvider: CultureInfo.InvariantCulture)} not returned because of size limits."; + } } diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 28391bed0d0..5a620878fc6 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -3,26 +3,19 @@ using System.Collections.Concurrent; using System.ComponentModel; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Hosting.ConsoleLogs; -using Humanizer; using Microsoft.Extensions.Localization; namespace Aspire.Dashboard.Model.Assistant; public sealed class AssistantChatDataContext { - public const int TracesLimit = 200; - public const int StructuredLogsLimit = 200; - public const int ConsoleLogsLimit = 500; - private readonly IDashboardClient _dashboardClient; private readonly IEnumerable _outgoingPeerResolvers; private readonly IStringLocalizer _loc; @@ -244,7 +237,7 @@ public async Task GetConsoleLogsAsync( await InvokeToolCallbackAsync(nameof(GetConsoleLogsAsync), _loc.GetString(nameof(AIAssistant.ToolNotificationConsoleLogs), resourceName), cancellationToken).ConfigureAwait(false); var logParser = new LogParser(ConsoleColor.Black); - var logEntries = new LogEntries(maximumEntryCount: ConsoleLogsLimit) { BaseLineNumber = 1 }; + var logEntries = new LogEntries(maximumEntryCount: AIHelpers.ConsoleLogsLimit) { BaseLineNumber = 1 }; // Add a timeout for getting all console logs. using var subscribeConsoleLogsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -267,10 +260,10 @@ public async Task GetConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = AIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, - ConsoleLogsLimit, + AIHelpers.ConsoleLogsLimit, "console log", AIHelpers.SerializeLogEntry, logEntry => AIHelpers.EstimateTokenCount((string) logEntry)); @@ -289,54 +282,6 @@ public async Task GetConsoleLogsAsync( return consoleLogsData; } - public static (List items, string message) GetLimitFromEndWithSummary(List values, int limit, string itemName, Func convertToDto, Func estimateTokenSize) - { - return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, convertToDto, estimateTokenSize); - } - - public static (List items, string message) GetLimitFromEndWithSummary(List values, int totalValues, int limit, string itemName, Func convertToDto, Func estimateTokenSize) - { - Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method."); - - var trimmedItems = values.Count <= limit - ? values - : values[^limit..]; - - var currentTokenCount = 0; - var serializedValuesCount = 0; - var dtos = trimmedItems.Select(i => convertToDto(i)).ToList(); - - // Loop backwards to prioritize the latest items. - for (var i = dtos.Count - 1; i >= 0; i--) - { - var obj = dtos[i]; - var tokenCount = estimateTokenSize(obj); - - if (currentTokenCount + tokenCount > AIHelpers.MaximumListTokenLength) - { - break; - } - - serializedValuesCount++; - currentTokenCount += tokenCount; - } - - // Trim again with what fits in the token limit. - dtos = dtos[^serializedValuesCount..]; - - return (dtos, GetLimitSummary(totalValues, dtos.Count, itemName)); - } - - private static string GetLimitSummary(int totalValues, int returnedCount, string itemName) - { - if (totalValues == returnedCount) - { - return $"Returned {itemName.ToQuantity(totalValues, formatProvider: CultureInfo.InvariantCulture)}."; - } - - return $"Returned latest {itemName.ToQuantity(returnedCount, formatProvider: CultureInfo.InvariantCulture)}. Earlier {itemName.ToQuantity(totalValues - returnedCount, formatProvider: CultureInfo.InvariantCulture)} not returned because of size limits."; - } - private bool TryResolveResourceNameForTelemetry([NotNullWhen(false)] string? resourceName, [NotNullWhen(false)] out string? message, out ResourceKey? resourceKey) { if (AIHelpers.IsMissingValue(resourceName)) diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 486d351f3a9..b07a61099ac 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -11,32 +12,46 @@ namespace Aspire.Dashboard.Resources { using System; - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] - [System.Diagnostics.DebuggerNonUserCodeAttribute()] - [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Layout { - private static System.Resources.ResourceManager resourceMan; + private static global::System.Resources.ResourceManager resourceMan; - private static System.Globalization.CultureInfo resourceCulture; + private static global::System.Globalization.CultureInfo resourceCulture; - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Layout() { } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - public static System.Resources.ResourceManager ResourceManager { + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { get { - if (object.Equals(null, resourceMan)) { - System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Dashboard.Resources.Layout", typeof(Layout).Assembly); + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Dashboard.Resources.Layout", typeof(Layout).Assembly); resourceMan = temp; } return resourceMan; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - public static System.Globalization.CultureInfo Culture { + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -45,126 +60,216 @@ public static System.Globalization.CultureInfo Culture { } } - public static string MainLayoutAspireRepoLink { + /// + /// Looks up a localized string similar to .NET Aspire. + /// + public static string MainLayoutAspire { get { - return ResourceManager.GetString("MainLayoutAspireRepoLink", resourceCulture); + return ResourceManager.GetString("MainLayoutAspire", resourceCulture); } } + /// + /// Looks up a localized string similar to Help. + /// public static string MainLayoutAspireDashboardHelpLink { get { return ResourceManager.GetString("MainLayoutAspireDashboardHelpLink", resourceCulture); } } + /// + /// Looks up a localized string similar to .NET Aspire repo. + /// + public static string MainLayoutAspireRepoLink { + get { + return ResourceManager.GetString("MainLayoutAspireRepoLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Settings. + /// public static string MainLayoutLaunchSettings { get { return ResourceManager.GetString("MainLayoutLaunchSettings", resourceCulture); } } + /// + /// Looks up a localized string similar to Close. + /// + public static string MainLayoutSettingsDialogClose { + get { + return ResourceManager.GetString("MainLayoutSettingsDialogClose", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Settings. + /// + public static string MainLayoutSettingsDialogTitle { + get { + return ResourceManager.GetString("MainLayoutSettingsDialogTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An unhandled error has occurred.. + /// public static string MainLayoutUnhandledErrorMessage { get { return ResourceManager.GetString("MainLayoutUnhandledErrorMessage", resourceCulture); } } + /// + /// Looks up a localized string similar to Reload. + /// public static string MainLayoutUnhandledErrorReload { get { return ResourceManager.GetString("MainLayoutUnhandledErrorReload", resourceCulture); } } - public static string MainLayoutSettingsDialogTitle { + /// + /// Looks up a localized string similar to Untrusted apps can access sensitive information about the running services.. + /// + public static string MessageMcpBody { get { - return ResourceManager.GetString("MainLayoutSettingsDialogTitle", resourceCulture); + return ResourceManager.GetString("MessageMcpBody", resourceCulture); } } - public static string MainLayoutSettingsDialogClose { + /// + /// Looks up a localized string similar to More information. + /// + public static string MessageMcpLink { get { - return ResourceManager.GetString("MainLayoutSettingsDialogClose", resourceCulture); + return ResourceManager.GetString("MessageMcpLink", resourceCulture); } } - public static string NavMenuResourcesTab { + /// + /// Looks up a localized string similar to MCP endpoint is unsecured. + /// + public static string MessageMcpTitle { get { - return ResourceManager.GetString("NavMenuResourcesTab", resourceCulture); + return ResourceManager.GetString("MessageMcpTitle", resourceCulture); } } - public static string NavMenuConsoleLogsTab { + /// + /// Looks up a localized string similar to Untrusted apps can send telemetry to the dashboard.. + /// + public static string MessageTelemetryBody { get { - return ResourceManager.GetString("NavMenuConsoleLogsTab", resourceCulture); + return ResourceManager.GetString("MessageTelemetryBody", resourceCulture); } } - public static string NavMenuStructuredLogsTab { + /// + /// Looks up a localized string similar to More information. + /// + public static string MessageTelemetryLink { get { - return ResourceManager.GetString("NavMenuStructuredLogsTab", resourceCulture); + return ResourceManager.GetString("MessageTelemetryLink", resourceCulture); } } - public static string NavMenuTracesTab { + /// + /// Looks up a localized string similar to Telemetry endpoint is unsecured. + /// + public static string MessageTelemetryTitle { get { - return ResourceManager.GetString("NavMenuTracesTab", resourceCulture); + return ResourceManager.GetString("MessageTelemetryTitle", resourceCulture); } } - public static string NavMenuMetricsTab { + /// + /// Looks up a localized string similar to Console. + /// + public static string NavMenuConsoleLogsTab { get { - return ResourceManager.GetString("NavMenuMetricsTab", resourceCulture); + return ResourceManager.GetString("NavMenuConsoleLogsTab", resourceCulture); } } - public static string MainLayoutAspire { + /// + /// Looks up a localized string similar to Metrics. + /// + public static string NavMenuMetricsTab { get { - return ResourceManager.GetString("MainLayoutAspire", resourceCulture); + return ResourceManager.GetString("NavMenuMetricsTab", resourceCulture); } } - public static string MessageTelemetryBody { + /// + /// Looks up a localized string similar to Resources. + /// + public static string NavMenuResourcesTab { get { - return ResourceManager.GetString("MessageTelemetryBody", resourceCulture); + return ResourceManager.GetString("NavMenuResourcesTab", resourceCulture); } } - public static string MessageTelemetryLink { + /// + /// Looks up a localized string similar to Structured. + /// + public static string NavMenuStructuredLogsTab { get { - return ResourceManager.GetString("MessageTelemetryLink", resourceCulture); + return ResourceManager.GetString("NavMenuStructuredLogsTab", resourceCulture); } } - public static string MessageTelemetryTitle { + /// + /// Looks up a localized string similar to Traces. + /// + public static string NavMenuTracesTab { get { - return ResourceManager.GetString("MessageTelemetryTitle", resourceCulture); + return ResourceManager.GetString("NavMenuTracesTab", resourceCulture); } } + /// + /// Looks up a localized string similar to View filters. + /// public static string PageLayoutViewFilters { get { return ResourceManager.GetString("PageLayoutViewFilters", resourceCulture); } } - public static string ReconnectFirstAttemptText { + /// + /// Looks up a localized string similar to Failed to rejoin.<br />Please retry or reload the page.. + /// + public static string ReconnectFailedText { get { - return ResourceManager.GetString("ReconnectFirstAttemptText", resourceCulture); + return ResourceManager.GetString("ReconnectFailedText", resourceCulture); } } - public static string ReconnectRepeatedAttemptText { + /// + /// Looks up a localized string similar to Rejoining the server.... + /// + public static string ReconnectFirstAttemptText { get { - return ResourceManager.GetString("ReconnectRepeatedAttemptText", resourceCulture); + return ResourceManager.GetString("ReconnectFirstAttemptText", resourceCulture); } } - public static string ReconnectFailedText { + /// + /// Looks up a localized string similar to Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.. + /// + public static string ReconnectRepeatedAttemptText { get { - return ResourceManager.GetString("ReconnectFailedText", resourceCulture); + return ResourceManager.GetString("ReconnectRepeatedAttemptText", resourceCulture); } } + /// + /// Looks up a localized string similar to Retry. + /// public static string ReconnectRetryButtonText { get { return ResourceManager.GetString("ReconnectRetryButtonText", resourceCulture); diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 2618ef1049d..717ea15dfd9 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -180,4 +180,13 @@ Retry + + MCP endpoint is unsecured + + + More information + + + Untrusted apps can access sensitive information about the running services. + diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 880397f41e5..177f640aaf1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -42,6 +42,21 @@ Načíst znovu + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Nedůvěryhodné aplikace můžou odesílat telemetrii na řídicí panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 17f2e745db6..c63ebdb9a39 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -42,6 +42,21 @@ Neu laden + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Nicht vertrauenswürdige Apps können Telemetriedaten an das Dashboard senden. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 57535e0bf7d..3fec080fbfe 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -42,6 +42,21 @@ Recargar + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Las aplicaciones que no son de confianza pueden enviar telemetría al panel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index d8d38d6476f..f6bbcbfa149 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -42,6 +42,21 @@ Recharger + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Les applications non approuvées peuvent envoyer des données de télémétrie au tableau de bord. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index f6b14cb3979..fbefbf57a9f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -42,6 +42,21 @@ Ricarica + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Le app non attendibili possono inviare dati di telemetria al dashboard. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index b3454a0180f..a0903b68b13 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -42,6 +42,21 @@ 再読み込み + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. 信頼されていないアプリは、テレメトリをダッシュボードに送信できます。 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 89b7607a7fc..1af2199b710 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -42,6 +42,21 @@ 다시 로드 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. 신뢰할 수 없는 앱은 대시보드에 원격 분석을 보낼 수 있습니다. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 12a02e3620c..5c5a73b4fe5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -42,6 +42,21 @@ Załaduj ponownie + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Niezaufane aplikacje mogą wysyłać dane telemetryczne do pulpitu nawigacyjnego. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index f702d60e124..4f1d22c8567 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -42,6 +42,21 @@ Recarregar + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Aplicativos não confiáveis podem enviar telemetria para o painel. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 623d91a17f2..68e38fefa5c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -42,6 +42,21 @@ Перезагрузить + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Недоверенные приложения могут отправлять телеметрию на панель мониторинга. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index e15f4e33654..fc21b605008 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -42,6 +42,21 @@ Yeniden yükle + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. Güvenilmeyen uygulamalar panoya telemetri verileri gönderebilir. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 9bf9947cf95..1ffd1adc723 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -42,6 +42,21 @@ 重新加载 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. 不受信任的应用可以将遥测数据发送到仪表板。 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 659cadb432c..9520484c500 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -42,6 +42,21 @@ 重新載入 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + More information + More information + + + + MCP endpoint is unsecured + MCP endpoint is unsecured + + Untrusted apps can send telemetry to the dashboard. 不受信任的應用程式可以將遙測傳送至儀表板。 diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 4bb528c72f7..0586292bd9b 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -553,11 +553,23 @@ public string ApplicationName public ResourceViewModel? GetResource(string resourceName) { EnsureInitialized(); - if (_resourceByName.TryGetValue(resourceName, out var resource)) + lock (_lock) + { + if (_resourceByName.TryGetValue(resourceName, out var resource)) + { + return resource; + } + return null; + } + } + + public IReadOnlyList GetResources() + { + EnsureInitialized(); + lock (_lock) { - return resource; + return _resourceByName.Values.ToList(); } - return null; } public async Task SubscribeResourcesAsync(CancellationToken cancellationToken) @@ -787,14 +799,6 @@ internal void SetInitialDataReceived(IList? initialData = null) _initialDataReceivedTcs.TrySetResult(); } - public IReadOnlyList GetResources() - { - lock (_lock) - { - return _resourceByName.Values.ToList(); - } - } - private class InteractionCollection : KeyedCollection { protected override int GetKeyForItem(WatchInteractionsResponseUpdate item) => item.InteractionId; diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 8683622ef04..159c9560c8b 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -46,6 +46,12 @@ public interface IDashboardClient : IAsyncDisposable /// ResourceViewModel? GetResource(string resourceName); + /// + /// Get the current resources. + /// + /// + IReadOnlyList GetResources(); + IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken); Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken); @@ -65,12 +71,6 @@ public interface IDashboardClient : IAsyncDisposable IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken); Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken); - - /// - /// Get the current resources. - /// - /// - IReadOnlyList GetResources(); } public sealed record ResourceViewModelSubscription( diff --git a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs index 74e7ca2b32b..6e4d63bb57f 100644 --- a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs +++ b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs @@ -8,6 +8,7 @@ namespace Aspire.Dashboard.Utils; internal static class BrowserStorageKeys { public const string UnsecuredTelemetryMessageDismissedKey = "Aspire_Telemetry_UnsecuredMessageDismissed"; + public const string UnsecuredMcpMessageDismissedKey = "Aspire_Mcp_UnsecuredMessageDismissed"; public const string TracesPageState = "Aspire_PageState_Traces"; public const string StructuredLogsPageState = "Aspire_PageState_StructuredLogs"; diff --git a/src/Aspire.Dashboard/wwwroot/img/mcp-vscode-insiders.png b/src/Aspire.Dashboard/wwwroot/img/mcp-vscode-insiders.png new file mode 100644 index 00000000000..0fefd2f4cfe Binary files /dev/null and b/src/Aspire.Dashboard/wwwroot/img/mcp-vscode-insiders.png differ diff --git a/src/Aspire.Dashboard/wwwroot/img/mcp-vscode.png b/src/Aspire.Dashboard/wwwroot/img/mcp-vscode.png new file mode 100644 index 00000000000..f9a297edaa8 Binary files /dev/null and b/src/Aspire.Dashboard/wwwroot/img/mcp-vscode.png differ diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index ff229233c88..ee995f21a6e 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -43,6 +43,7 @@ CodespacesUrlRewriter codespaceUrlRewriter // Internal for testing internal const string OtlpGrpcEndpointName = "otlp-grpc"; internal const string OtlpHttpEndpointName = "otlp-http"; + internal const string McpEndpointName = "mcp"; // Fallback defaults for framework versions and TFM private const string FallbackTargetFrameworkMoniker = "net8.0"; @@ -369,6 +370,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) var dashboardUrls = options.DashboardUrl; var otlpGrpcEndpointUrl = options.OtlpGrpcEndpointUrl; var otlpHttpEndpointUrl = options.OtlpHttpEndpointUrl; + var mcpEndpointUrl = options.McpEndpointUrl; eventing.Subscribe(dashboardResource, (context, resource) => { @@ -419,6 +421,15 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) }); } + if (mcpEndpointUrl != null) + { + var address = BindingAddress.Parse(mcpEndpointUrl); + dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: McpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) + { + TargetHost = address.Host + }); + } + dashboardResource.Annotations.Add(new ResourceUrlsCallbackAnnotation(c => { foreach (var url in c.Urls) @@ -486,6 +497,7 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con var environment = options.AspNetCoreEnvironment; var browserToken = options.DashboardToken; var otlpApiKey = options.OtlpApiKey; + var mcpApiKey = options.McpApiKey; var resourceServiceUrl = await dashboardEndpointProvider.GetResourceServiceUriAsync(context.CancellationToken).ConfigureAwait(false); @@ -547,6 +559,17 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName] = "Unsecured"; } + // Configure MCP API key + if (!string.IsNullOrEmpty(mcpApiKey)) + { + context.EnvironmentVariables[DashboardConfigNames.DashboardMcpAuthModeName.EnvVarName] = "ApiKey"; + context.EnvironmentVariables[DashboardConfigNames.DashboardMcpPrimaryApiKeyName.EnvVarName] = mcpApiKey; + } + else + { + context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName] = "Unsecured"; + } + // Change the dashboard formatter to use JSON so we can parse the logs and render them in the // via the ILogger. context.EnvironmentVariables["LOGGING__CONSOLE__FORMATTERNAME"] = "json"; @@ -601,6 +624,16 @@ static ReferenceExpression GetTargetUrlExpression(EndpointReference e) => context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpHttpUrlName.EnvVarName] = GetTargetUrlExpression(otlpHttp); } + var mcp = dashboardResource.GetEndpoint(McpEndpointName); + if (mcp.Exists) + { + // The URL that the dashboard binds to is proxied. We need to set the public URL to the proxied URL. + // This lets the dashboard provide the correct URL to clients. + context.EnvironmentVariables[DashboardConfigNames.DashboardMcpPublicUrlName.EnvVarName] = context.EnvironmentVariables[DashboardConfigNames.DashboardMcpUrlName.EnvVarName]; + + context.EnvironmentVariables[DashboardConfigNames.DashboardMcpUrlName.EnvVarName] = GetTargetUrlExpression(mcp); + } + var aspnetCoreUrls = new ReferenceExpressionBuilder(); var first = true; diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs index 217b907bf75..05e1f2df178 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -15,6 +15,8 @@ internal class DashboardOptions public string? OtlpGrpcEndpointUrl { get; set; } public string? OtlpHttpEndpointUrl { get; set; } public string? OtlpApiKey { get; set; } + public string? McpEndpointUrl { get; set; } + public string? McpApiKey { get; set; } public string AspNetCoreEnvironment { get; set; } = "Production"; public bool? TelemetryOptOut { get; set; } } @@ -29,7 +31,9 @@ public void Configure(DashboardOptions options) options.OtlpGrpcEndpointUrl = configuration.GetString(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl); options.OtlpHttpEndpointUrl = configuration.GetString(KnownConfigNames.DashboardOtlpHttpEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl); + options.McpEndpointUrl = configuration[KnownConfigNames.DashboardMcpEndpointUrl]; options.OtlpApiKey = configuration["AppHost:OtlpApiKey"]; + options.McpApiKey = configuration["AppHost:McpApiKey"]; options.AspNetCoreEnvironment = configuration["ASPNETCORE_ENVIRONMENT"] ?? "Production"; diff --git a/src/Aspire.Hosting/Dashboard/TransportOptionsValidator.cs b/src/Aspire.Hosting/Dashboard/TransportOptionsValidator.cs index b60ebceadd3..6211f7859d4 100644 --- a/src/Aspire.Hosting/Dashboard/TransportOptionsValidator.cs +++ b/src/Aspire.Hosting/Dashboard/TransportOptionsValidator.cs @@ -46,15 +46,22 @@ public ValidateOptionsResult Validate(string? name, TransportOptions transportOp return ValidateOptionsResult.Fail($"AppHost does not have the {KnownConfigNames.DashboardOtlpGrpcEndpointUrl} or {KnownConfigNames.DashboardOtlpHttpEndpointUrl} settings defined. At least one OTLP endpoint must be provided."); } - if (!TryValidateGrpcEndpointUrl(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, dashboardOtlpGrpcEndpointUrl, out var resultGrpc)) + if (!TryValidateEndpointUrl(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, dashboardOtlpGrpcEndpointUrl, out var resultGrpc)) { return resultGrpc; } - if (!TryValidateGrpcEndpointUrl(KnownConfigNames.DashboardOtlpHttpEndpointUrl, dashboardOtlpHttpEndpointUrl, out var resultHttp)) + if (!TryValidateEndpointUrl(KnownConfigNames.DashboardOtlpHttpEndpointUrl, dashboardOtlpHttpEndpointUrl, out var resultHttp)) { return resultHttp; } + // Validate ASPIRE_DASHBOARD_MCP_ENDPOINT_URL + var dashboardMcpEndpointUrl = configuration[KnownConfigNames.DashboardMcpEndpointUrl]; + if (!TryValidateEndpointUrl(KnownConfigNames.DashboardMcpEndpointUrl, dashboardMcpEndpointUrl, out var resultMcp)) + { + return resultMcp; + } + // Validate ASPIRE_DASHBOARD_RESOURCE_SERVER_ENDPOINT_URL var resourceServiceEndpointUrl = configuration.GetString(KnownConfigNames.ResourceServiceEndpointUrl, KnownConfigNames.Legacy.ResourceServiceEndpointUrl); if (string.IsNullOrEmpty(resourceServiceEndpointUrl)) @@ -88,7 +95,7 @@ static bool TryParseBindingAddress(string address, [NotNullWhen(true)] out Bindi } } - static bool TryValidateGrpcEndpointUrl(string configName, string? value, [NotNullWhen(false)] out ValidateOptionsResult? result) + static bool TryValidateEndpointUrl(string configName, string? value, [NotNullWhen(false)] out ValidateOptionsResult? result) { if (!string.IsNullOrEmpty(value)) { diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 5588d7a3a8a..01bf97d614e 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -322,6 +322,12 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // of persistent containers (as a new key would be a spec change). SecretsStore.GetOrSetUserSecret(_innerBuilder.Configuration, AppHostAssembly, "AppHost:OtlpApiKey", TokenGenerator.GenerateToken); + // Set a random API key for the MCP Server if one isn't already present in configuration. + // If a key is generated, it's stored in the user secrets store so that it will be auto-loaded + // on subsequent runs and not recreated. This is important to ensure it doesn't change the state + // of MCP clients. + SecretsStore.GetOrSetUserSecret(_innerBuilder.Configuration, AppHostAssembly, "AppHost:McpApiKey", TokenGenerator.GenerateToken); + // Determine the frontend browser token. if (_innerBuilder.Configuration.GetString(KnownConfigNames.DashboardFrontendBrowserToken, KnownConfigNames.Legacy.DashboardFrontendBrowserToken, fallbackOnEmpty: true) is not { } browserToken) diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index a33b70b31b7..33874d77b9a 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -9,6 +9,7 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardOtlpGrpcUrlName = new(KnownConfigNames.DashboardOtlpGrpcEndpointUrl); public static readonly ConfigName DashboardOtlpHttpUrlName = new(KnownConfigNames.DashboardOtlpHttpEndpointUrl); + public static readonly ConfigName DashboardMcpUrlName = new(KnownConfigNames.DashboardMcpEndpointUrl); public static readonly ConfigName DashboardUnsecuredAllowAnonymousName = new(KnownConfigNames.DashboardUnsecuredAllowAnonymous); public static readonly ConfigName DashboardConfigFilePathName = new(KnownConfigNames.DashboardConfigFilePath); public static readonly ConfigName DashboardFileConfigDirectoryName = new(KnownConfigNames.DashboardFileConfigDirectory); @@ -19,6 +20,10 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardOtlpAuthModeName = new("Dashboard:Otlp:AuthMode", "DASHBOARD__OTLP__AUTHMODE"); public static readonly ConfigName DashboardOtlpPrimaryApiKeyName = new("Dashboard:Otlp:PrimaryApiKey", "DASHBOARD__OTLP__PRIMARYAPIKEY"); public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY"); + public static readonly ConfigName DashboardMcpPublicUrlName = new("Dashboard:Mcp:PublicUrl", "DASHBOARD__MCP__PUBLICURL"); + public static readonly ConfigName DashboardMcpPathName = new("Dashboard:Mcp:Path", "DASHBOARD__MCP__PATH"); + public static readonly ConfigName DashboardMcpAuthModeName = new("Dashboard:Mcp:AuthMode", "DASHBOARD__MCP__AUTHMODE"); + public static readonly ConfigName DashboardMcpPrimaryApiKeyName = new("Dashboard:Mcp:PrimaryApiKey", "DASHBOARD__MCP__PRIMARYAPIKEY"); public static readonly ConfigName DashboardOtlpSuppressUnsecuredTelemetryMessageName = new("Dashboard:Otlp:SuppressUnsecuredTelemetryMessage", "DASHBOARD__OTLP__SUPPRESSUNSECUREDTELEMETRYMESSAGE"); public static readonly ConfigName DashboardOtlpCorsAllowedOriginsKeyName = new("Dashboard:Otlp:Cors:AllowedOrigins", "DASHBOARD__OTLP__CORS__ALLOWEDORIGINS"); public static readonly ConfigName DashboardOtlpCorsAllowedHeadersKeyName = new("Dashboard:Otlp:Cors:AllowedHeaders", "DASHBOARD__OTLP__CORS__ALLOWEDHEADERS"); diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 50ab7161f1c..df469b87123 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -8,6 +8,7 @@ internal static class KnownConfigNames public const string AspNetCoreUrls = "ASPNETCORE_URLS"; public const string AllowUnsecuredTransport = "ASPIRE_ALLOW_UNSECURED_TRANSPORT"; public const string VersionCheckDisabled = "ASPIRE_VERSION_CHECK_DISABLED"; + public const string DashboardMcpEndpointUrl = "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL"; public const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; public const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; public const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 19922963bc1..734a8258883 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -200,6 +200,11 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() Assert.Equal(LogLevel.Warning, w.LogLevel); }, w => + { + Assert.Equal("MCP server is unsecured. Untrusted apps can access sensitive information.", GetValue(w.State, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => { Assert.Equal("Login to the dashboard at {DashboardLoginUrl}", GetValue(w.State, "{OriginalFormat}")); diff --git a/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs b/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs index e6dc89b8131..4a69f505db3 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs @@ -61,6 +61,7 @@ public static DashboardWebApplication CreateDashboardWebApplication( [DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "http://127.0.0.1:0", [DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = "http://127.0.0.1:0", [DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = "http://127.0.0.1:0", + [DashboardConfigNames.DashboardMcpUrlName.ConfigKey] = "http://127.0.0.1:0", [DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = nameof(OtlpAuthMode.Unsecured), [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.Unsecured), // Allow the requirement of HTTPS communication with the OpenIdConnect authority to be relaxed during tests. diff --git a/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs new file mode 100644 index 00000000000..0cafadc2387 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Text.Json.Nodes; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Mcp; +using Aspire.Hosting; +using Microsoft.AspNetCore.InternalTesting; +using Xunit; + +namespace Aspire.Dashboard.Tests.Integration; + +public class McpServiceTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public McpServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task CallService_McpEndPoint_Success() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper); + await app.StartAsync().DefaultTimeout(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.McpEndPointAccessor().EndPoint}"); + + var request = CreateListToolsRequest(); + + // Act + var responseMessage = await httpClient.SendAsync(request).DefaultTimeout(TestConstants.LongTimeoutDuration); + responseMessage.EnsureSuccessStatusCode(); + + var responseData = await GetDataFromSseResponseAsync(responseMessage); + + // Assert + var jsonResponse = JsonNode.Parse(responseData!)!; + var tools = jsonResponse["result"]!["tools"]!.AsArray(); + + Assert.NotEmpty(tools); + } + + [Fact] + public async Task CallService_McpEndPoint_RequiredApiKeyWrong_Failure() + { + // Arrange + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardMcpAuthModeName.ConfigKey] = OtlpAuthMode.ApiKey.ToString(); + config[DashboardConfigNames.DashboardMcpPrimaryApiKeyName.ConfigKey] = apiKey; + }); + await app.StartAsync().DefaultTimeout(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.McpEndPointAccessor().EndPoint}"); + + var requestMessage = CreateListToolsRequest(); + + // Act + var responseMessage = await httpClient.SendAsync(requestMessage).DefaultTimeout(TestConstants.LongTimeoutDuration); + + // Assert + Assert.False(responseMessage.IsSuccessStatusCode); + } + + [Fact] + public async Task CallService_McpEndPoint_RequiredApiKeySent_Success() + { + // Arrange + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardMcpAuthModeName.ConfigKey] = OtlpAuthMode.ApiKey.ToString(); + config[DashboardConfigNames.DashboardMcpPrimaryApiKeyName.ConfigKey] = apiKey; + }); + await app.StartAsync().DefaultTimeout(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.McpEndPointAccessor().EndPoint}"); + + var requestMessage = CreateListToolsRequest(); + requestMessage.Headers.TryAddWithoutValidation(McpApiKeyAuthenticationHandler.ApiKeyHeaderName, apiKey); + + // Act + var responseMessage = await httpClient.SendAsync(requestMessage).DefaultTimeout(TestConstants.LongTimeoutDuration); + responseMessage.EnsureSuccessStatusCode(); + + var responseData = await GetDataFromSseResponseAsync(responseMessage); + + // Assert + var jsonResponse = JsonNode.Parse(responseData!)!; + var tools = jsonResponse["result"]!["tools"]!.AsArray(); + + Assert.NotEmpty(tools); + } + + [Fact] + public async Task CallService_BrowserEndPoint_Failure() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper); + await app.StartAsync().DefaultTimeout(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); + + var request = CreateListToolsRequest(); + + // Act + var responseMessage = await httpClient.SendAsync(request).DefaultTimeout(TestConstants.LongTimeoutDuration); + + // Assert + Assert.False(responseMessage.IsSuccessStatusCode); + } + + private static HttpRequestMessage CreateListToolsRequest() + { + var json = + """ + { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/list", + "params": {} + } + """; + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(json)); + content.Headers.TryAddWithoutValidation("content-type", "application/json"); + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = content + }; + request.Headers.TryAddWithoutValidation("accept", "application/json"); + request.Headers.TryAddWithoutValidation("accept", "text/event-stream"); + return request; + } + + static async Task GetDataFromSseResponseAsync(HttpResponseMessage response) + { + string responseText = await response.Content.ReadAsStringAsync(); + + // Find the line that starts with "data:" + var dataLine = Array.Find(responseText.Split('\n'), line => line.StartsWith("data:")); + if (dataLine != null) + { + return dataLine.Substring("data:".Length).Trim(); + } + + return null; + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index c7a1042f49c..4c8b33156e7 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -3,6 +3,7 @@ using System.Reflection; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Mcp; using Aspire.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -29,7 +30,8 @@ public DashboardServerFixture() [DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "http://127.0.0.1:0", [DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = "http://127.0.0.1:0", [DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = nameof(OtlpAuthMode.Unsecured), - [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.Unsecured) + [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.Unsecured), + [DashboardConfigNames.DashboardMcpAuthModeName.ConfigKey] = nameof(McpAuthMode.Unsecured) }; } diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 7bb61cc2049..f0470f271a7 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -632,6 +632,11 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() { Assert.Equal("OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. For more information, visit https://go.microsoft.com/fwlink/?linkid=2267030", GetValue(w.State, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.Equal("MCP server is unsecured. Untrusted apps can access sensitive information.", GetValue(w.State, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); }); object? GetValue(object? values, string key) @@ -714,6 +719,11 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => { Assert.Equal("OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. For more information, visit https://go.microsoft.com/fwlink/?linkid=2267030", GetValue(w.State, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.Equal("MCP server is unsecured. Untrusted apps can access sensitive information.", GetValue(w.State, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); }); object? GetValue(object? values, string key) diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs index 08d754631a5..8f1c7f84719 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs @@ -21,4 +21,87 @@ public void LimitLength_OverLimit_ReturnTrimmedValue() var value = AIHelpers.LimitLength(new string('!', 10_000)); Assert.Equal($"{new string('!', AIHelpers.MaximumStringLength)}...[TRUNCATED]", value); } + + [Fact] + public void GetLimitFromEndWithSummary_UnderLimits_ReturnAll() + { + // Arrange + var values = new List(); + for (var i = 0; i < 10; i++) + { + values.Add(new string((char)('a' + i), 16)); + } + + // Act + var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, totalValues: values.Count, limit: 20, "test item", s => s, s => ((string)s).Length); + + // Assert + Assert.Equal(10, items.Count); + Assert.Equal("Returned 10 test items.", message); + } + + [Fact] + public void GetLimitFromEndWithSummary_UnderTotal_ReturnPassedIn() + { + // Arrange + var values = new List(); + for (var i = 0; i < 10; i++) + { + values.Add(new string((char)('a' + i), 16)); + } + + // Act + var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 20, "test item", s => s, s => ((string)s).Length); + + // Assert + Assert.Equal(10, items.Count); + Assert.Equal("Returned latest 10 test items. Earlier 90 test items not returned because of size limits.", message); + } + + [Fact] + public void GetLimitFromEndWithSummary_ExceedCountLimit_ReturnMostRecentItems() + { + // Arrange + var values = new List(); + for (var i = 0; i < 10; i++) + { + values.Add(new string((char)('a' + i), 2)); + } + + // Act + var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 5, "test item", s => s, s => ((string)s).Length); + + // Assert + Assert.Collection(items, + s => Assert.Equal("ff", s), + s => Assert.Equal("gg", s), + s => Assert.Equal("hh", s), + s => Assert.Equal("ii", s), + s => Assert.Equal("jj", s)); + Assert.Equal("Returned latest 5 test items. Earlier 95 test items not returned because of size limits.", message); + } + + [Fact] + public void GetLimitFromEndWithSummary_ExceedTokenLimit_ReturnMostRecentItems() + { + const int textLength = 1024 * 2; + + // Arrange + var values = new List(); + for (var i = 0; i < 10; i++) + { + values.Add(new string((char)('a' + i), textLength)); + } + + // Act + var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, limit: 10, "test item", s => s, s => ((string)s).Length); + + // Assert + Assert.Collection(items, + s => Assert.Equal(new string('g', textLength), s), + s => Assert.Equal(new string('h', textLength), s), + s => Assert.Equal(new string('i', textLength), s), + s => Assert.Equal(new string('j', textLength), s)); + Assert.Equal("Returned latest 4 test items. Earlier 6 test items not returned because of size limits.", message); + } } diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs index ea9ea7fbd3b..4a593e5b468 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs @@ -21,89 +21,6 @@ public class AssistantChatDataContextTests { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - [Fact] - public void GetLimitFromEndWithSummary_UnderLimits_ReturnAll() - { - // Arrange - var values = new List(); - for (var i = 0; i < 10; i++) - { - values.Add(new string((char)('a' + i), 16)); - } - - // Act - var (items, message) = AssistantChatDataContext.GetLimitFromEndWithSummary(values, totalValues: values.Count, limit: 20, "test item", s => s, s => ((string)s).Length); - - // Assert - Assert.Equal(10, items.Count); - Assert.Equal("Returned 10 test items.", message); - } - - [Fact] - public void GetLimitFromEndWithSummary_UnderTotal_ReturnPassedIn() - { - // Arrange - var values = new List(); - for (var i = 0; i < 10; i++) - { - values.Add(new string((char)('a' + i), 16)); - } - - // Act - var (items, message) = AssistantChatDataContext.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 20, "test item", s => s, s => ((string)s).Length); - - // Assert - Assert.Equal(10, items.Count); - Assert.Equal("Returned latest 10 test items. Earlier 90 test items not returned because of size limits.", message); - } - - [Fact] - public void GetLimitFromEndWithSummary_ExceedCountLimit_ReturnMostRecentItems() - { - // Arrange - var values = new List(); - for (var i = 0; i < 10; i++) - { - values.Add(new string((char)('a' + i), 2)); - } - - // Act - var (items, message) = AssistantChatDataContext.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 5, "test item", s => s, s => ((string)s).Length); - - // Assert - Assert.Collection(items, - s => Assert.Equal("ff", s), - s => Assert.Equal("gg", s), - s => Assert.Equal("hh", s), - s => Assert.Equal("ii", s), - s => Assert.Equal("jj", s)); - Assert.Equal("Returned latest 5 test items. Earlier 95 test items not returned because of size limits.", message); - } - - [Fact] - public void GetLimitFromEndWithSummary_ExceedTokenLimit_ReturnMostRecentItems() - { - const int textLength = 1024 * 2; - - // Arrange - var values = new List(); - for (var i = 0; i < 10; i++) - { - values.Add(new string((char)('a' + i), textLength)); - } - - // Act - var (items, message) = AssistantChatDataContext.GetLimitFromEndWithSummary(values, limit: 10, "test item", s => s, s => ((string)s).Length); - - // Assert - Assert.Collection(items, - s => Assert.Equal(new string('g', textLength), s), - s => Assert.Equal(new string('h', textLength), s), - s => Assert.Equal(new string('i', textLength), s), - s => Assert.Equal(new string('j', textLength), s)); - Assert.Equal("Returned latest 4 test items. Earlier 6 test items not returned because of size limits.", message); - } - [Fact] public async Task GetStructuredLogs_ExceedTokenLimit_ReturnMostRecentItems() {