From 125f32310869c2e64bfd0ee831523a8d35c4df67 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 8 Oct 2025 15:20:35 +0800 Subject: [PATCH 1/4] Add dashboard MCP server --- Directory.Packages.props | 3 +- .../Properties/launchSettings.json | 2 + src/Aspire.Cli/Commands/RunCommand.cs | 1 + src/Aspire.Dashboard/Aspire.Dashboard.csproj | 8 + .../Connection/ConnectionType.cs | 4 +- .../ConnectionTypeAuthenticationHandler.cs | 7 +- .../Connection/ConnectionTypeMiddleware.cs | 6 +- .../ListenOptionsConnectionTypeExtensions.cs | 4 +- .../UnsecuredAuthenticationHandler.cs | 1 + .../Components/CustomIcons/AspireIcons.cs | 5 + .../Components/Dialogs/McpServerDialog.razor | 133 ++++++++ .../Dialogs/McpServerDialog.razor.cs | 151 +++++++++ .../Dialogs/McpServerDialog.razor.css | 3 + .../Components/Layout/MainLayout.razor | 11 +- .../Components/Layout/MainLayout.razor.cs | 80 ++++- .../Components/_Imports.razor | 1 + .../Configuration/DashboardOptions.cs | 53 ++- .../Configuration/EndpointInfo.cs | 59 ++++ .../FrontendAuthenticationDefaults.cs | 11 + .../FrontendAuthorizationDefaults.cs | 11 + .../Configuration/McpAuthMode.cs | 10 + .../PostConfigureDashboardOptions.cs | 13 + .../Configuration/ResolvedEndpointInfo.cs | 37 ++ .../Configuration/ValidateDashboardOptions.cs | 18 + .../DashboardWebApplication.cs | 271 +++++++-------- src/Aspire.Dashboard/Mcp/DashboardTools.cs | 317 ++++++++++++++++++ .../Mcp/McpApiKeyAuthenticationHandler.cs | 57 ++++ .../Mcp/McpCompositeAuthenticationHandler.cs | 57 ++++ .../Mcp/McpConfigPropertyViewModel.cs | 14 + src/Aspire.Dashboard/Mcp/McpExtensions.cs | 33 ++ .../Mcp/McpInstallButtonServerModel.cs | 39 +++ .../Model/Assistant/AIHelpers.cs | 69 +++- .../Assistant/AssistantChatDataContext.cs | 61 +--- .../Model/BrowserSecurityHeadersMiddleware.cs | 4 +- .../Resources/Layout.Designer.cs | 179 +++++++--- src/Aspire.Dashboard/Resources/Layout.resx | 69 ++-- .../Resources/xlf/Layout.cs.xlf | 23 +- .../Resources/xlf/Layout.de.xlf | 23 +- .../Resources/xlf/Layout.es.xlf | 23 +- .../Resources/xlf/Layout.fr.xlf | 23 +- .../Resources/xlf/Layout.it.xlf | 23 +- .../Resources/xlf/Layout.ja.xlf | 23 +- .../Resources/xlf/Layout.ko.xlf | 23 +- .../Resources/xlf/Layout.pl.xlf | 23 +- .../Resources/xlf/Layout.pt-BR.xlf | 23 +- .../Resources/xlf/Layout.ru.xlf | 23 +- .../Resources/xlf/Layout.tr.xlf | 23 +- .../Resources/xlf/Layout.zh-Hans.xlf | 23 +- .../Resources/xlf/Layout.zh-Hant.xlf | 23 +- .../ServiceClient/DashboardClient.cs | 26 +- .../ServiceClient/IDashboardClient.cs | 12 +- .../Utils/BrowserStorageKeys.cs | 1 + .../Dashboard/DashboardEventHandlers.cs | 43 +++ .../Dashboard/DashboardOptions.cs | 4 + .../Dashboard/TransportOptionsValidator.cs | 13 +- .../DistributedApplicationBuilder.cs | 6 + .../13.0/apphost.run.json | 2 + .../9.5/apphost.run.json | 2 + .../13.0/Properties/launchSettings.json | 2 + .../9.5/Properties/launchSettings.json | 2 + .../Properties/launchSettings.json | 2 + .../Properties/launchSettings.json | 2 + .../Properties/launchSettings.json | 2 + .../Properties/launchSettings.json | 2 + src/Shared/DashboardConfigNames.cs | 6 + src/Shared/KnownConfigNames.cs | 1 + .../Layout/MainLayoutTests.cs | 77 +++-- .../BrowserSecurityHeadersMiddlewareTests.cs | 9 +- .../DashboardOptionsTests.cs | 18 +- .../FrontendBrowserTokenAuthTests.cs | 9 +- .../FrontendOpenIdConnectAuthTests.cs | 2 +- .../Integration/IntegrationTestHelpers.cs | 1 + .../Integration/McpServiceTests.cs | 174 ++++++++++ .../Infrastructure/DashboardServerFixture.cs | 3 +- .../Integration/StartupTests.cs | 101 +++++- .../Model/AIAssistant/AIHelpersTests.cs | 83 +++++ .../AssistantChatDataContextTests.cs | 83 ----- .../Dashboard/DashboardResourceTests.cs | 78 ++++- 78 files changed, 2265 insertions(+), 602 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.css create mode 100644 src/Aspire.Dashboard/Configuration/EndpointInfo.cs create mode 100644 src/Aspire.Dashboard/Configuration/FrontendAuthenticationDefaults.cs create mode 100644 src/Aspire.Dashboard/Configuration/FrontendAuthorizationDefaults.cs create mode 100644 src/Aspire.Dashboard/Configuration/McpAuthMode.cs create mode 100644 src/Aspire.Dashboard/Configuration/ResolvedEndpointInfo.cs create mode 100644 src/Aspire.Dashboard/Mcp/DashboardTools.cs create mode 100644 src/Aspire.Dashboard/Mcp/McpApiKeyAuthenticationHandler.cs create mode 100644 src/Aspire.Dashboard/Mcp/McpCompositeAuthenticationHandler.cs create mode 100644 src/Aspire.Dashboard/Mcp/McpConfigPropertyViewModel.cs create mode 100644 src/Aspire.Dashboard/Mcp/McpExtensions.cs create mode 100644 src/Aspire.Dashboard/Mcp/McpInstallButtonServerModel.cs create mode 100644 tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8657eaa67ca..563a5941be3 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..b6c14d0a131 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 cb184a5c02e..0276ca8369c 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -207,6 +207,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..ba85e501890 100644 --- a/src/Aspire.Dashboard/Authentication/Connection/ConnectionType.cs +++ b/src/Aspire.Dashboard/Authentication/Connection/ConnectionType.cs @@ -7,5 +7,7 @@ public enum ConnectionType { None, Frontend, - Otlp + OtlpGrpc, + OtlpHttp, + Mcp } diff --git a/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs b/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs index 46c47f04830..ddf1997716a 100644 --- a/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs +++ b/src/Aspire.Dashboard/Authentication/Connection/ConnectionTypeAuthenticationHandler.cs @@ -22,9 +22,9 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail("No type specified on this connection.")); } - if (!connectionTypeFeature.ConnectionTypes.Contains(Options.RequiredConnectionType)) + if (!Options.RequiredConnectionTypes.Any(connectionTypeFeature.ConnectionTypes.Contains)) { - return Task.FromResult(AuthenticateResult.Fail($"Connection type {Options.RequiredConnectionType} is not enabled on this connection.")); + return Task.FromResult(AuthenticateResult.Fail($"Connection types '{string.Join(", ", Options.RequiredConnectionTypes)}' are not enabled on this connection.")); } return Task.FromResult(AuthenticateResult.NoResult()); @@ -35,9 +35,10 @@ public static class ConnectionTypeAuthenticationDefaults { public const string AuthenticationSchemeFrontend = "ConnectionFrontend"; public const string AuthenticationSchemeOtlp = "ConnectionOtlp"; + public const string AuthenticationSchemeMcp = "ConnectionMcp"; } public sealed class ConnectionTypeAuthenticationHandlerOptions : AuthenticationSchemeOptions { - public ConnectionType RequiredConnectionType { get; set; } + public HashSet RequiredConnectionTypes { get; set; } = []; } 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/Authentication/Connection/ListenOptionsConnectionTypeExtensions.cs b/src/Aspire.Dashboard/Authentication/Connection/ListenOptionsConnectionTypeExtensions.cs index 2b91b51344e..297f5de4822 100644 --- a/src/Aspire.Dashboard/Authentication/Connection/ListenOptionsConnectionTypeExtensions.cs +++ b/src/Aspire.Dashboard/Authentication/Connection/ListenOptionsConnectionTypeExtensions.cs @@ -7,8 +7,8 @@ namespace Aspire.Dashboard.Authentication.Connection; internal static class ListenOptionsConnectionTypeExtensions { - public static void UseConnectionTypes(this ListenOptions listenOptions, ConnectionType[] connectionTypes) + public static void UseConnectionTypes(this ListenOptions listenOptions, IEnumerable connectionTypes) { - listenOptions.Use(next => new ConnectionTypeMiddleware(connectionTypes, next).OnConnectionAsync); + listenOptions.Use(next => new ConnectionTypeMiddleware(connectionTypes.ToArray(), next).OnConnectionAsync); } } diff --git a/src/Aspire.Dashboard/Authentication/UnsecuredAuthenticationHandler.cs b/src/Aspire.Dashboard/Authentication/UnsecuredAuthenticationHandler.cs index 2ebdbbff4aa..536eefe58fa 100644 --- a/src/Aspire.Dashboard/Authentication/UnsecuredAuthenticationHandler.cs +++ b/src/Aspire.Dashboard/Authentication/UnsecuredAuthenticationHandler.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; +using Aspire.Dashboard.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; 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..1c5c3d59536 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor @@ -0,0 +1,133 @@ +@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) + { +

+ Aspire MCP connects AI assistants to Aspire app data. AI can use Aspire MCP to get information about app resources, health checks, commands, console logs and real-time telemetry. + For more information, see Use Aspire MCP with AI. +

+ + + + + +
+

+ Quickly add Aspire MCP to VS Code using a browser install button: +

+

+ + + 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 + *@ + +

+

+ For other options, such as updating mcp.json, see Add an MCP server to VS Code. +

+ + @if (_isHttps) + { +
+
+ +
+ +
+ VS Code limitation + As of October 2025, VS Code does not support using Aspire MCP over HTTPS. +
+
+ To use VS Code with Aspire MCP, configure the MCP endpoint to use HTTP instead of HTTPS—for example, by launching the app host with the http profile. + More information +
+
+ } +
+
+ +
+

+ Aspire MCP can be used with any AI tooling that supports streamable HTTP MCP servers. +

+

+ Important details for configuring Aspire MCP are below. Please refer to your AI client's documentation for how to add an MCP server. +

+
+ + +
+ + @_mcpConfigProperties.Count() + +
+ +
+
+
+
+
+
+ + } +
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..100ceffcd41 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.cs @@ -0,0 +1,151 @@ +// 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; +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? _mcpServerInstallButtonJson; + private string? _mcpServerConfigFileJson; + private string? _mcpUrl; + private bool _isHttps; + private McpToolView _activeView; + private List _mcpConfigProperties = []; + + protected override void OnInitialized() + { + _markdownProcessor = new MarkdownProcessor(ControlsStringsLoc, MarkdownHelpers.SafeUrlSchemes, []); + if ((DashboardOptions.Value.Mcp.PublicUrl ?? DashboardOptions.Value.Mcp.EndpointUrl) is { Length: > 0 } mcpUrl) + { + var uri = new Uri(baseUri: new Uri(mcpUrl), relativeUri: "/mcp"); + + _mcpUrl = uri.ToString(); + _isHttps = uri.Scheme == "https"; + } + + if (McpEnabled) + { + (_mcpServerInstallButtonJson, _mcpServerConfigFileJson) = GetMcpServerInstallButtonJson(); + _mcpConfigProperties = + [ + new McpConfigPropertyViewModel { Name = "name", Value = "aspire-dashboard" }, + new McpConfigPropertyViewModel { Name = "type", Value = "http" }, + new McpConfigPropertyViewModel { Name = "url", Value = _mcpUrl } + ]; + + if (DashboardOptions.Value.Mcp.AuthMode == McpAuthMode.ApiKey) + { + _mcpConfigProperties.Add(new McpConfigPropertyViewModel { Name = $"{McpApiKeyAuthenticationHandler.ApiKeyHeaderName} (header)", Value = DashboardOptions.Value.Mcp.PrimaryApiKey! }); + } + } + else + { + throw new InvalidOperationException("MCP server is not enabled or configured."); + } + } + + [MemberNotNullWhen(true, nameof(_mcpServerInstallButtonJson))] + [MemberNotNullWhen(true, nameof(_mcpUrl))] + private bool McpEnabled => !DashboardOptions.Value.Mcp.Disabled.GetValueOrDefault() && !string.IsNullOrEmpty(_mcpUrl); + + private (string InstallButtonJson, string ConfigFileJson) GetMcpServerInstallButtonJson() + { + Debug.Assert(_mcpUrl != null); + + Dictionary? headers = null; + + if (DashboardOptions.Value.Mcp.AuthMode == McpAuthMode.ApiKey) + { + headers = new Dictionary + { + [McpApiKeyAuthenticationHandler.ApiKeyHeaderName] = DashboardOptions.Value.Mcp.PrimaryApiKey! + }; + } + + var name = "aspire-dashboard"; + + var installButtonJson = JsonSerializer.Serialize( + new McpInstallButtonServerModel + { + Name = name, + Type = "http", + Url = _mcpUrl, + Headers = headers + }, + McpInstallButtonModelContext.Default.McpInstallButtonServerModel); + + var configFileJson = JsonSerializer.Serialize( + new McpJsonFileServerModel + { + Servers = new() + { + [name] = new() + { + Type = "http", + Url = _mcpUrl, + Headers = headers + } + } + }, + McpConfigFileModelContext.Default.McpJsonFileServerModel); + + return (installButtonJson, configFileJson); + } + + private Task OnTabChangeAsync(FluentTab newTab) + { + var id = newTab.Id?.Substring("tab-".Length); + + if (id is null + || !Enum.TryParse(typeof(McpToolView), id, out var o) + || o is not McpToolView viewKind) + { + return Task.CompletedTask; + } + + _activeView = viewKind; + return Task.CompletedTask; + } + + private string GetJsonConfigurationMarkdown() => + $""" + ```json + {_mcpServerConfigFileJson} + ``` + """; + + public enum McpToolView + { + VisualStudio, + VSCode, + Other + } +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.css new file mode 100644 index 00000000000..70c086115ed --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/McpServerDialog.razor.css @@ -0,0 +1,3 @@ +::deep .mcp-tool-tab { + margin-top: 10px; +} 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..32349b06c5c 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.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 System.Text; using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Configuration; @@ -29,6 +30,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; } @@ -115,10 +117,28 @@ protected override async Task OnInitializedAsync() TimeProvider.SetBrowserTimeZone(result.TimeZone); TelemetryContextProvider.SetBrowserUserAgent(result.UserAgent); - if (Options.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured && !Options.CurrentValue.Otlp.SuppressUnsecuredTelemetryMessage) + await DisplayUnsecuredEndpointsMessageAsync(); + + _aiDisplayChangedSubscription = AIContextProvider.OnDisplayChanged(() => InvokeAsync(StateHasChanged)); + } + + private async Task DisplayUnsecuredEndpointsMessageAsync() + { + var unsecuredEndpointsMessage = new StringBuilder(); + if (ShouldShowUnsecuredTelemetryMessage()) { - var dismissedResult = await LocalStorage.GetUnprotectedAsync(BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey); - var skipMessage = dismissedResult.Success && dismissedResult.Value; + unsecuredEndpointsMessage.AppendLine(Loc[nameof(Resources.Layout.MessageUnsecuredEndpointTelemetryBody)]); + } + if (ShouldShowUnsecuredMcpMessage()) + { + unsecuredEndpointsMessage.AppendLine(Loc[nameof(Resources.Layout.MessageUnsecuredEndpointMcpBody)]); + } + + if (unsecuredEndpointsMessage.Length > 0) + { + // Check UnsecuredTelemetryMessageDismissedKey for backwards compatibility. + var skipMessage = (await ShouldSkipMessageAsync(LocalStorage, BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey) || + await ShouldSkipMessageAsync(LocalStorage, BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey)); if (!skipMessage) { @@ -126,12 +146,12 @@ protected override async Task OnInitializedAsync() // I think this order allows the message bar provider to be fully initialized. await MessageService.ShowMessageBarAsync(options => { - options.Title = Loc[nameof(Resources.Layout.MessageTelemetryTitle)]; - options.Body = Loc[nameof(Resources.Layout.MessageTelemetryBody)]; + options.Title = Loc[nameof(Resources.Layout.MessageUnsecuredEndpointTitle)]; + options.Body = unsecuredEndpointsMessage.ToString(); options.Link = new() { - Text = Loc[nameof(Resources.Layout.MessageTelemetryLink)], - Href = "https://aka.ms/dotnet/aspire/telemetry-unsecured", + Text = Loc[nameof(Resources.Layout.MessageUnsecuredEndpointLink)], + Href = "https://aka.ms/aspire/api-endpoint-unsecured", Target = "_blank" }; options.Intent = MessageIntent.Warning; @@ -139,13 +159,27 @@ await MessageService.ShowMessageBarAsync(options => options.AllowDismiss = true; options.OnClose = async m => { - await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey, true); + await LocalStorage.SetUnprotectedAsync(BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey, true); }; }); } } - _aiDisplayChangedSubscription = AIContextProvider.OnDisplayChanged(() => InvokeAsync(StateHasChanged)); + static async Task ShouldSkipMessageAsync(ILocalStorage localStorage, string storageKey) + { + var dismissedResult = await localStorage.GetUnprotectedAsync(storageKey); + return dismissedResult.Success && dismissedResult.Value; + } + } + + private bool ShouldShowUnsecuredTelemetryMessage() + { + return Options.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured && !Options.CurrentValue.Otlp.SuppressUnsecuredMessage; + } + + private bool ShouldShowUnsecuredMcpMessage() + { + return Options.CurrentValue.Mcp.AuthMode == McpAuthMode.Unsecured && !Options.CurrentValue.Mcp.SuppressUnsecuredMessage; } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -169,6 +203,34 @@ protected override void OnParametersSet() } } + private async Task LaunchMcpAsync() + { + DialogParameters parameters = new() + { + Title = "Aspire MCP server", + DismissTitle = DialogsLoc[nameof(Resources.Dialogs.DialogCloseButtonText)], + PrimaryAction = null, + SecondaryAction = null, + TrapFocus = true, + Modal = true, + Width = "700px", + 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/Components/_Imports.razor b/src/Aspire.Dashboard/Components/_Imports.razor index 854de5bfc5f..eb68396f076 100644 --- a/src/Aspire.Dashboard/Components/_Imports.razor +++ b/src/Aspire.Dashboard/Components/_Imports.razor @@ -17,6 +17,7 @@ @using Aspire.Dashboard.Components @using Aspire.Dashboard.Components.Controls @using Aspire.Dashboard.Components.Layout +@using Aspire.Dashboard.Configuration @using Aspire.Dashboard.Model @using Aspire.Dashboard.ServiceClient @using Microsoft.Extensions.Localization diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 883f51607b5..55ffb4c09a8 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -13,6 +13,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(); @@ -86,11 +87,7 @@ public sealed class OtlpOptions public List AllowedCertificates { get; set; } = new(); - /// - /// Gets or sets a value indicating whether to suppress the unsecured telemetry message in the dashboard UI. - /// When true, the warning message about unsecured OTLP endpoints will not be displayed. - /// - public bool SuppressUnsecuredTelemetryMessage { get; set; } + public bool SuppressUnsecuredMessage { get; set; } public BindingAddress? GetGrpcEndpointAddress() { @@ -146,6 +143,52 @@ 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 URL could be different from the endpoint URL (e.g., when behind a proxy). + public string? PublicUrl { get; set; } + + public bool SuppressUnsecuredMessage { 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/EndpointInfo.cs b/src/Aspire.Dashboard/Configuration/EndpointInfo.cs new file mode 100644 index 00000000000..560cac65e55 --- /dev/null +++ b/src/Aspire.Dashboard/Configuration/EndpointInfo.cs @@ -0,0 +1,59 @@ +// 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.Authentication.Connection; +using Microsoft.AspNetCore.Server.Kestrel.Core; + +namespace Aspire.Dashboard.Configuration; + +public record EndpointInfo(string Name, BindingAddress Address, HttpProtocols? HttpProtocols, bool RequireCertificate, ConnectionType ConnectionType) +{ + public static bool TryAddEndpoint(List configuredEndpoints, BindingAddress? address, string name, HttpProtocols? httpProtocols, bool requireCertificate, ConnectionType connectionType) + { + if (address != null) + { + configuredEndpoints.Add(new EndpointInfo(name, address, httpProtocols, requireCertificate, connectionType)); + return true; + } + + return false; + } + + public static IEnumerable>> GroupEndpointsByAddress(IEnumerable endpoints) + { + var groups = new List>>(); + var map = new Dictionary>(); + + foreach (var endpoint in endpoints) + { + var address = endpoint.Address; + + if (address.Port == 0) + { + // Port 0 — each endpoint is its own group + groups.Add(new KeyValuePair>(address, [endpoint])); + } + else + { + var key = address.ToString(); + + if (!map.TryGetValue(key, out var list)) + { + list = []; + map[key] = list; + } + + list.Add(endpoint); + } + } + + // Add all normal (non-zero-port) grouped endpoints + foreach (var kvp in map) + { + var address = kvp.Value.First().Address; + groups.Add(new KeyValuePair>(address, kvp.Value)); + } + + return groups; + } +} diff --git a/src/Aspire.Dashboard/Configuration/FrontendAuthenticationDefaults.cs b/src/Aspire.Dashboard/Configuration/FrontendAuthenticationDefaults.cs new file mode 100644 index 00000000000..128988ee962 --- /dev/null +++ b/src/Aspire.Dashboard/Configuration/FrontendAuthenticationDefaults.cs @@ -0,0 +1,11 @@ +// 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.Configuration; + +public static class FrontendAuthenticationDefaults +{ + public const string AuthenticationSchemeOpenIdConnect = "FrontendOpenIdConnect"; + public const string AuthenticationSchemeBrowserToken = "FrontendBrowserToken"; + public const string AuthenticationSchemeUnsecured = "FrontendUnsecured"; +} diff --git a/src/Aspire.Dashboard/Configuration/FrontendAuthorizationDefaults.cs b/src/Aspire.Dashboard/Configuration/FrontendAuthorizationDefaults.cs new file mode 100644 index 00000000000..70b49648d6f --- /dev/null +++ b/src/Aspire.Dashboard/Configuration/FrontendAuthorizationDefaults.cs @@ -0,0 +1,11 @@ +// 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.Configuration; + +public static class FrontendAuthorizationDefaults +{ + public const string PolicyName = "Frontend"; + public const string BrowserTokenClaimName = "BrowserTokenClaim"; + public const string UnsecuredClaimName = "UnsecuredTokenClaim"; +} diff --git a/src/Aspire.Dashboard/Configuration/McpAuthMode.cs b/src/Aspire.Dashboard/Configuration/McpAuthMode.cs new file mode 100644 index 00000000000..ff330a20581 --- /dev/null +++ b/src/Aspire.Dashboard/Configuration/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.Configuration; + +public enum McpAuthMode +{ + Unsecured, + ApiKey, +} diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index a9719420e39..53e76744909 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -40,6 +40,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 +62,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)) @@ -74,5 +82,10 @@ public void PostConfigure(string? name, DashboardOptions options) } options.AI.Disabled = _configuration.GetBool(DashboardConfigNames.DashboardAIDisabledName.ConfigKey); + + if (_configuration.GetBool(DashboardConfigNames.Legacy.DashboardOtlpSuppressUnsecuredTelemetryMessage.ConfigKey) is { } suppressUnsecuredTelemetryMessage) + { + options.Otlp.SuppressUnsecuredMessage = suppressUnsecuredTelemetryMessage; + } } } diff --git a/src/Aspire.Dashboard/Configuration/ResolvedEndpointInfo.cs b/src/Aspire.Dashboard/Configuration/ResolvedEndpointInfo.cs new file mode 100644 index 00000000000..b0978c55dd0 --- /dev/null +++ b/src/Aspire.Dashboard/Configuration/ResolvedEndpointInfo.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Net; + +namespace Aspire.Dashboard.Configuration; + +/// +/// This endpoint info represents a resolved endpoint with its binding address, actual IP endpoint, and whether it uses HTTPS. +/// Useful when the port is dynamically assigned (port 0) but you need to address the endpoint with its resolved address. +/// +public record ResolvedEndpointInfo(BindingAddress BindingAddress, IPEndPoint EndPoint, bool IsHttps) +{ + public string GetResolvedAddress(bool replaceIPAnyWithLocalhost = false) + { + if (!IsAnyIPHost(BindingAddress.Host)) + { + return BindingAddress.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + BindingAddress.Host.ToLowerInvariant() + ":" + EndPoint.Port.ToString(CultureInfo.InvariantCulture); + } + + if (replaceIPAnyWithLocalhost) + { + // Clicking on an any IP host link, e.g. http://0.0.0.0:1234, doesn't work. + // Instead, write localhost so the link at least has a chance to work when the container and browser are on the same machine. + return BindingAddress.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + "localhost:" + EndPoint.Port.ToString(CultureInfo.InvariantCulture); + } + + return BindingAddress.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + EndPoint.ToString(); + + static bool IsAnyIPHost(string host) + { + // It's ok to use IPAddress.ToString here because the string is cached inside IPAddress. + return host == "*" || host == "+" || host == IPAddress.Any.ToString() || host == IPAddress.IPv6Any.ToString(); + } + } +} diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index 3d9bb3f2b28..feacabfd1b5 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -93,6 +93,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..690f58af5a3 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -3,8 +3,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net; using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; @@ -15,6 +13,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,16 +48,18 @@ 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; private readonly IOptionsMonitor _dashboardOptionsMonitor; private readonly IReadOnlyList _validationFailures; - private readonly List> _frontendEndPointAccessor = new(); - private Func? _otlpServiceGrpcEndPointAccessor; - private Func? _otlpServiceHttpEndPointAccessor; + private readonly List> _frontendEndPointAccessor = new(); + private Func? _otlpServiceGrpcEndPointAccessor; + private Func? _otlpServiceHttpEndPointAccessor; + private Func? _mcpEndPointAccessor; - public List> FrontendEndPointsAccessor + public List> FrontendEndPointsAccessor { get { @@ -71,7 +72,7 @@ public List> FrontendEndPointsAccessor } } - public Func FrontendSingleEndPointAccessor + public Func FrontendSingleEndPointAccessor { get { @@ -88,16 +89,21 @@ public Func FrontendSingleEndPointAccessor } } - public Func OtlpServiceGrpcEndPointAccessor + public Func OtlpServiceGrpcEndPointAccessor { get => _otlpServiceGrpcEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet."); } - public Func OtlpServiceHttpEndPointAccessor + 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 +261,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(); @@ -334,7 +346,7 @@ public DashboardWebApplication( _app.Lifetime.ApplicationStarted.Register(() => { - EndpointInfo? frontendEndpointInfo = null; + ResolvedEndpointInfo? frontendEndpointInfo = null; if (_frontendEndPointAccessor.Count > 0) { if (dashboardOptions.Otlp.Cors.IsCorsEnabled) @@ -372,6 +384,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 +444,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()) { @@ -536,48 +558,35 @@ private static bool TryGetDashboardOptions(WebApplicationBuilder builder, IConfi // possible from the caller. e.g., using environment variables to configure each endpoint's TLS certificate. private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardOptions dashboardOptions) { - // A single endpoint is configured if URLs are the same and the port isn't dynamic. + var endpoints = new List(); 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 initialValues = new Dictionary(); - var browserEndpointNames = new List(capacity: frontendAddresses.Count); - - if (!hasSingleEndpoint) + for (var i = 0; i < frontendAddresses.Count; i++) { - // Translate high-level config settings such as ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL and ASPNETCORE_URLS - // to Kestrel's schema for loading endpoints from configuration. - if (otlpGrpcAddress != null) - { - AddEndpointConfiguration(initialValues, "OtlpGrpc", otlpGrpcAddress.ToString(), HttpProtocols.Http2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate); - } - if (otlpHttpAddress != null) - { - AddEndpointConfiguration(initialValues, "OtlpHttp", otlpHttpAddress.ToString(), HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate); - } - - if (frontendAddresses.Count == 1) - { - browserEndpointNames.Add("Browser"); - AddEndpointConfiguration(initialValues, "Browser", frontendAddresses[0].ToString()); - } - else - { - for (var i = 0; i < frontendAddresses.Count; i++) - { - var name = $"Browser{i}"; - browserEndpointNames.Add(name); - AddEndpointConfiguration(initialValues, name, frontendAddresses[i].ToString()); - } - } + var fontendUrl = frontendAddresses[i]; + var name = $"Browser{i}"; + EndpointInfo.TryAddEndpoint(endpoints, fontendUrl, name, httpProtocols: null, requireCertificate: false, connectionType: ConnectionType.Frontend); } - else - { - // At least one gRPC endpoint must be present. - var url = otlpGrpcAddress?.ToString() ?? otlpHttpAddress?.ToString(); - AddEndpointConfiguration(initialValues, "OtlpGrpc", url!, HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate); + EndpointInfo.TryAddEndpoint(endpoints, dashboardOptions.Otlp.GetGrpcEndpointAddress(), "OtlpGrpc", httpProtocols: HttpProtocols.Http2, requireCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate, connectionType: ConnectionType.OtlpGrpc); + EndpointInfo.TryAddEndpoint(endpoints, dashboardOptions.Otlp.GetHttpEndpointAddress(), "OtlpHttp", httpProtocols: HttpProtocols.Http1AndHttp2, requireCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate, connectionType: ConnectionType.OtlpHttp); + EndpointInfo.TryAddEndpoint(endpoints, dashboardOptions.Mcp.GetEndpointAddress(), "Mcp", httpProtocols: HttpProtocols.Http1AndHttp2, requireCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate, connectionType: ConnectionType.Mcp); + + var initialValues = new Dictionary(); + foreach (var (address, addressEndpoints) in EndpointInfo.GroupEndpointsByAddress(endpoints)) + { + // If endpoint uses HTTPS then OR protocols. + // If endpoint doesn't use HTTPs then AND protocols together. If an endpoint is combined with OTLP GRPC then it will be H2 only. + var isHttps = address.Scheme == "https"; + var notNullProtocols = addressEndpoints.Select(m => m.HttpProtocols).OfType().ToList(); + var protocol = notNullProtocols.Count == 0 + ? (HttpProtocols?)null + : notNullProtocols.Aggregate((acc, p) => !isHttps ? acc & p : acc | p); + + AddEndpointConfiguration( + initialValues, + string.Join("-", addressEndpoints.Select(m => m.Name)), + address.ToString(), + protocol, + addressEndpoints.Any(m => m.RequireCertificate)); } static void AddEndpointConfiguration(Dictionary values, string endpointName, string url, HttpProtocols? protocols = null, bool requiredClientCertificate = false) @@ -606,81 +615,65 @@ static void AddEndpointConfiguration(Dictionary values, string var kestrelSection = context.Configuration.GetSection("Kestrel"); var configurationLoader = serverOptions.Configure(kestrelSection); - foreach (var browserEndpointName in browserEndpointNames) + foreach (var (address, addressEndpoints) in EndpointInfo.GroupEndpointsByAddress(endpoints)) { - configurationLoader.Endpoint(browserEndpointName, endpointConfiguration => - { - endpointConfiguration.ListenOptions.UseConnectionTypes([ConnectionType.Frontend]); - - // Only the last endpoint is accessible. Tests should only need one but - // this will need to be improved if that changes. - _frontendEndPointAccessor.Add(CreateEndPointAccessor(endpointConfiguration)); - }); - } + var name = string.Join("-", addressEndpoints.Select(m => m.Name)); + var connectionTypes = addressEndpoints.Select(m => m.ConnectionType).ToList(); - configurationLoader.Endpoint("OtlpGrpc", endpointConfiguration => - { - var connectionTypes = new List { ConnectionType.Otlp }; - - _otlpServiceGrpcEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); - if (hasSingleEndpoint) + configurationLoader.Endpoint(name, endpointConfiguration => { - logger.LogDebug("Browser and OTLP accessible on a single endpoint."); - - if (!endpointConfiguration.IsHttps) + endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes); + + logger.LogTrace( + """ + Endpoint {Name}: + - Listening on {Url} + - Connection types: {ConnectionTypes} + - IsHttps: {IsHttps} + - HttpProtocols: {HttpProtocols} + """, name, address, string.Join(", ", connectionTypes), endpointConfiguration.IsHttps, endpointConfiguration.ListenOptions.Protocols); + + if (!endpointConfiguration.IsHttps && connectionTypes.Contains(ConnectionType.Frontend) && endpointConfiguration.ListenOptions.Protocols == HttpProtocols.Http2) { 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); - } - - endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray()); - - if (endpointConfiguration.HttpsOptions.ClientCertificateMode == ClientCertificateMode.RequireCertificate) - { - // Allow invalid certificates when creating the connection. Certificate validation is done in the auth middleware. - endpointConfiguration.HttpsOptions.ClientCertificateValidation = (certificate, chain, sslPolicyErrors) => + foreach (var connectionType in connectionTypes) { - return true; - }; - } - }); - - configurationLoader.Endpoint("OtlpHttp", endpointConfiguration => - { - var connectionTypes = new List { ConnectionType.Otlp }; - - _otlpServiceHttpEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); - if (hasSingleEndpoint) - { - logger.LogDebug("Browser and OTLP accessible on a single endpoint."); + switch (connectionType) + { + case ConnectionType.Frontend: + // Only the last endpoint is accessible. Tests should only need one but + // this will need to be improved if that changes. + _frontendEndPointAccessor.Add(CreateEndPointAccessor(endpointConfiguration)); + break; + case ConnectionType.OtlpGrpc: + _otlpServiceGrpcEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); + break; + case ConnectionType.OtlpHttp: + _otlpServiceHttpEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); + break; + case ConnectionType.Mcp: + _mcpEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration); + break; + } + } - if (!endpointConfiguration.IsHttps) + if (endpointConfiguration.HttpsOptions.ClientCertificateMode == ClientCertificateMode.RequireCertificate) { - 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."); + // Allow invalid certificates when creating the connection. Certificate validation is done in the auth middleware. + endpointConfiguration.HttpsOptions.ClientCertificateValidation = (certificate, chain, sslPolicyErrors) => + { + return true; + }; } - - connectionTypes.Add(ConnectionType.Frontend); - _frontendEndPointAccessor.Add(_otlpServiceHttpEndPointAccessor); - } - - endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray()); - - if (endpointConfiguration.HttpsOptions.ClientCertificateMode == ClientCertificateMode.RequireCertificate) - { - // Allow invalid certificates when creating the connection. Certificate validation is done in the auth middleware. - endpointConfiguration.HttpsOptions.ClientCertificateValidation = (certificate, chain, sslPolicyErrors) => { return true; }; - } - }); + }); + } }); - static Func CreateEndPointAccessor(EndpointConfiguration endpointConfiguration) + static Func CreateEndPointAccessor(EndpointConfiguration endpointConfiguration) { // We want to provide a way for someone to get the IP address of an endpoint. // However, if a dynamic port is used, the port is not known until the server is started. @@ -692,16 +685,11 @@ static Func CreateEndPointAccessor(EndpointConfiguration endpointC { var endpoint = endpointConfiguration.ListenOptions.IPEndPoint!; - return new EndpointInfo(address, endpoint, endpointConfiguration.IsHttps); + return new ResolvedEndpointInfo(address, endpoint, endpointConfiguration.IsHttps); }; } } - private static bool IsSameOrNull(BindingAddress frontendAddress, BindingAddress? otlpAddress) - { - return otlpAddress == null || (frontendAddress.Equals(otlpAddress) && otlpAddress.Port != 0); - } - private static void ConfigureAuthentication(WebApplicationBuilder builder, DashboardOptions dashboardOptions) { var authentication = builder.Services @@ -709,8 +697,11 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb .AddScheme(FrontendCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) .AddScheme(OtlpCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) .AddScheme(OtlpApiKeyAuthenticationDefaults.AuthenticationScheme, o => { }) - .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeFrontend, o => o.RequiredConnectionType = ConnectionType.Frontend) - .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeOtlp, o => o.RequiredConnectionType = ConnectionType.Otlp) + .AddScheme(McpCompositeAuthenticationDefaults.AuthenticationScheme, o => { }) + .AddScheme(McpApiKeyAuthenticationHandler.AuthenticationScheme, o => { }) + .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeFrontend, o => o.RequiredConnectionTypes = [ConnectionType.Frontend]) + .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeOtlp, o => o.RequiredConnectionTypes = [ConnectionType.OtlpGrpc, ConnectionType.OtlpHttp]) + .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeMcp, o => o.RequiredConnectionTypes = [ConnectionType.Mcp]) .AddCertificate(options => { // Bind options to configuration so they can be overridden by environment variables. @@ -850,6 +841,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: @@ -932,43 +929,3 @@ public ValueTask DisposeAsync() private static bool IsHttpsOrNull(BindingAddress? address) => address == null || string.Equals(address.Scheme, "https", StringComparison.Ordinal); } - -public record EndpointInfo(BindingAddress BindingAddress, IPEndPoint EndPoint, bool IsHttps) -{ - public string GetResolvedAddress(bool replaceIPAnyWithLocalhost = false) - { - if (!IsAnyIPHost(BindingAddress.Host)) - { - return BindingAddress.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + BindingAddress.Host.ToLowerInvariant() + ":" + EndPoint.Port.ToString(CultureInfo.InvariantCulture); - } - - if (replaceIPAnyWithLocalhost) - { - // Clicking on an any IP host link, e.g. http://0.0.0.0:1234, doesn't work. - // Instead, write localhost so the link at least has a chance to work when the container and browser are on the same machine. - return BindingAddress.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + "localhost:" + EndPoint.Port.ToString(CultureInfo.InvariantCulture); - } - - return BindingAddress.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + EndPoint.ToString(); - - static bool IsAnyIPHost(string host) - { - // It's ok to use IPAddress.ToString here because the string is cached inside IPAddress. - return host == "*" || host == "+" || host == IPAddress.Any.ToString() || host == IPAddress.IPv6Any.ToString(); - } - } -} - -public static class FrontendAuthorizationDefaults -{ - public const string PolicyName = "Frontend"; - public const string BrowserTokenClaimName = "BrowserTokenClaim"; - public const string UnsecuredClaimName = "UnsecuredTokenClaim"; -} - -public static class FrontendAuthenticationDefaults -{ - public const string AuthenticationSchemeOpenIdConnect = "FrontendOpenIdConnect"; - public const string AuthenticationSchemeBrowserToken = "FrontendBrowserToken"; - public const string AuthenticationSchemeUnsecured = "FrontendUnsecured"; -} diff --git a/src/Aspire.Dashboard/Mcp/DashboardTools.cs b/src/Aspire.Dashboard/Mcp/DashboardTools.cs new file mode 100644 index 00000000000..4f9b96c2471 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/DashboardTools.cs @@ -0,0 +1,317 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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.Storage; +using Aspire.Hosting.ConsoleLogs; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace Aspire.Dashboard.Mcp; + +[McpServerToolType] +internal sealed class DashboardTools +{ + private readonly TelemetryRepository _telemetryRepository; + private readonly IDashboardClient _dashboardClient; + private readonly IEnumerable _outgoingPeerResolvers; + + public DashboardTools(TelemetryRepository telemetryRepository, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) + { + _telemetryRepository = telemetryRepository; + _dashboardClient = dashboardClient; + _outgoingPeerResolvers = outgoingPeerResolvers; + } + + [McpServerTool(Name = "list_resources")] + [Description("List the application resources. Includes information about their type (.NET project, container, executable), running state, source, HTTP endpoints, health status, commands, and relationships.")] + public string ListResources() + { + try + { + 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 DATA + + {resourceGraphData} + """; + + return response; + } + catch { } + + return "No resources found."; + } + + [McpServerTool(Name = "list_structured_logs")] + [Description("List structured logs for resources.")] + public string ListStructuredLogs( + [Description("The resource name. This limits logs returned to the specified resource. If no resource name is specified then structured logs for all resources are returned.")] + string? resourceName = null) + { + 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 = "list_traces")] + [Description("List 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 ListTraces( + [Description("The resource name. This limits traces returned to the specified resource. If no resource name is specified then distributed traces for all resources are returned.")] + string? resourceName = null) + { + 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 = "list_trace_structured_logs")] + [Description("List 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 ListTraceStructuredLogs( + [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 = "list_console_logs")] + [Description("List console logs for a resource. The console logs includes standard output from resources and resource commands. Known resource commands are 'resource-start', 'resource-stop' and 'resource-restart' which are used to start and stop resources. Don't print the full console logs in the response to the user. Console logs should be examined when determining why a resource isn't running.")] + public async Task ListConsoleLogsAsync( + [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 = "execute_command"), Description("Executes a command on a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.")] + 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 McpProtocolException($"Resource '{resourceName}' not found.", McpErrorCode.InvalidParams); + } + + var command = resource.Commands.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparisons.CommandName)); + + if (command is null) + { + throw new McpProtocolException($"Command '{commandName}' not found for resource '{resourceName}'.", McpErrorCode.InvalidParams); + } + + // Block execution when command isn't available. + if (command.State == Model.CommandViewModelState.Hidden) + { + throw new McpProtocolException($"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 McpProtocolException($"Resource '{resourceName}' is stopped. Use the 'resource-start' command instead of 'resource-restart'.", McpErrorCode.InvalidParams); + } + + throw new McpProtocolException($"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 McpProtocolException($"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 McpProtocolException($"Command '{commandName}' failed for resource '{resourceName}': {message}", McpErrorCode.InternalError); + } + } + catch (McpProtocolException) + { + throw; + } + catch (Exception ex) + { + throw new McpProtocolException($"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) + { + // 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 (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; + } +} 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/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/McpConfigPropertyViewModel.cs b/src/Aspire.Dashboard/Mcp/McpConfigPropertyViewModel.cs new file mode 100644 index 00000000000..b338cbec100 --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpConfigPropertyViewModel.cs @@ -0,0 +1,14 @@ +// 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; +using Aspire.Dashboard.Components.Controls; + +namespace Aspire.Dashboard.Mcp; + +[DebuggerDisplay("Name = {Name}, Value = {Value}")] +public sealed class McpConfigPropertyViewModel : IPropertyGridItem +{ + public required string Name { get; set; } + public required string Value { get; set; } +} diff --git a/src/Aspire.Dashboard/Mcp/McpExtensions.cs b/src/Aspire.Dashboard/Mcp/McpExtensions.cs new file mode 100644 index 00000000000..6e6c9b55af2 --- /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/McpInstallButtonServerModel.cs b/src/Aspire.Dashboard/Mcp/McpInstallButtonServerModel.cs new file mode 100644 index 00000000000..4a3616ec0db --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/McpInstallButtonServerModel.cs @@ -0,0 +1,39 @@ +// 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; + +// Used by the VS Code install button. The server name is included in the JSON object. +public sealed class McpInstallButtonServerModel +{ + public required string Name { get; init; } + public required string Type { get; init; } + public required string Url { get; init; } + public Dictionary? Headers { get; init; } +} + +// Used by the VS Code mcp.json file config. Server names are keys in a JSON object. +public sealed class McpJsonFileServerModel +{ + public required Dictionary Servers { get; init; } +} + +public sealed class McpJsonFileServerInstanceModel +{ + public required string Type { get; init; } + public required string Url { get; init; } + public Dictionary? Headers { get; init; } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(McpInstallButtonServerModel))] +[JsonSerializable(typeof(Dictionary))] +public sealed partial class McpInstallButtonModelContext : JsonSerializerContext; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] +[JsonSerializable(typeof(McpJsonFileServerModel))] +[JsonSerializable(typeof(McpJsonFileServerInstanceModel))] +[JsonSerializable(typeof(Dictionary))] +public sealed partial class McpConfigFileModelContext : JsonSerializerContext; diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index 6efaa73f362..bb5a6570e64 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; @@ -30,6 +35,7 @@ internal static class AIHelpers // Always pass English translations to AI private static readonly IStringLocalizer s_columnsLoc = new InvariantStringLocalizer(); + private static readonly IStringLocalizer s_commandsLoc = new InvariantStringLocalizer(); public static readonly TimeSpan ResponseMessageTimeout = TimeSpan.FromSeconds(60); public static readonly TimeSpan CompleteMessageTimeout = TimeSpan.FromMinutes(4); @@ -103,9 +109,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); @@ -167,7 +173,12 @@ internal static string GetResponseGraphJson(List resources) exception = report.ExceptionText }).ToList() }, - source = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value + source = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value, + commands = resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled).Select(cmd => new + { + name = cmd.Name, + description = cmd.GetDisplayDescription(s_commandsLoc) + }).ToList() }).ToList(); var resourceGraphData = SerializeJson(data); @@ -244,9 +255,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 +437,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/Model/BrowserSecurityHeadersMiddleware.cs b/src/Aspire.Dashboard/Model/BrowserSecurityHeadersMiddleware.cs index 5be96d32ba3..23cbf04fa62 100644 --- a/src/Aspire.Dashboard/Model/BrowserSecurityHeadersMiddleware.cs +++ b/src/Aspire.Dashboard/Model/BrowserSecurityHeadersMiddleware.cs @@ -62,9 +62,9 @@ private static string GenerateCspContent(IHostEnvironment environment, bool isHt public Task InvokeAsync(HttpContext context) { - // Don't set browser security headers on OTLP requests. + // Don't set browser security headers on non-frontend requests. var feature = context.Features.Get(); - if (feature == null || !feature.ConnectionTypes.Contains(ConnectionType.Otlp)) + if (feature == null || feature.ConnectionTypes.Contains(ConnectionType.Frontend)) { context.Response.Headers.ContentSecurityPolicy = context.Request.IsHttps ? _cspContentHttps diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 486d351f3a9..92274b53435 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,198 @@ 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); } } - public static string MainLayoutLaunchSettings { + /// + /// Looks up a localized string similar to .NET Aspire repo. + /// + public static string MainLayoutAspireRepoLink { get { - return ResourceManager.GetString("MainLayoutLaunchSettings", resourceCulture); + return ResourceManager.GetString("MainLayoutAspireRepoLink", resourceCulture); } } - public static string MainLayoutUnhandledErrorMessage { + /// + /// Looks up a localized string similar to Settings. + /// + public static string MainLayoutLaunchSettings { get { - return ResourceManager.GetString("MainLayoutUnhandledErrorMessage", resourceCulture); + return ResourceManager.GetString("MainLayoutLaunchSettings", resourceCulture); } } - public static string MainLayoutUnhandledErrorReload { + /// + /// Looks up a localized string similar to Close. + /// + public static string MainLayoutSettingsDialogClose { get { - return ResourceManager.GetString("MainLayoutUnhandledErrorReload", resourceCulture); + return ResourceManager.GetString("MainLayoutSettingsDialogClose", resourceCulture); } } + /// + /// Looks up a localized string similar to Settings. + /// public static string MainLayoutSettingsDialogTitle { get { return ResourceManager.GetString("MainLayoutSettingsDialogTitle", resourceCulture); } } - public static string MainLayoutSettingsDialogClose { + /// + /// Looks up a localized string similar to An unhandled error has occurred.. + /// + public static string MainLayoutUnhandledErrorMessage { get { - return ResourceManager.GetString("MainLayoutSettingsDialogClose", resourceCulture); + return ResourceManager.GetString("MainLayoutUnhandledErrorMessage", resourceCulture); } } - public static string NavMenuResourcesTab { + /// + /// Looks up a localized string similar to Reload. + /// + public static string MainLayoutUnhandledErrorReload { get { - return ResourceManager.GetString("NavMenuResourcesTab", resourceCulture); + return ResourceManager.GetString("MainLayoutUnhandledErrorReload", resourceCulture); } } - public static string NavMenuConsoleLogsTab { + /// + /// Looks up a localized string similar to More information. + /// + public static string MessageUnsecuredEndpointLink { get { - return ResourceManager.GetString("NavMenuConsoleLogsTab", resourceCulture); + return ResourceManager.GetString("MessageUnsecuredEndpointLink", resourceCulture); } } - public static string NavMenuStructuredLogsTab { + /// + /// Looks up a localized string similar to Untrusted apps can access sensitive information about the running services.. + /// + public static string MessageUnsecuredEndpointMcpBody { get { - return ResourceManager.GetString("NavMenuStructuredLogsTab", resourceCulture); + return ResourceManager.GetString("MessageUnsecuredEndpointMcpBody", resourceCulture); } } - public static string NavMenuTracesTab { + /// + /// Looks up a localized string similar to Untrusted apps can send telemetry to the dashboard.. + /// + public static string MessageUnsecuredEndpointTelemetryBody { get { - return ResourceManager.GetString("NavMenuTracesTab", resourceCulture); + return ResourceManager.GetString("MessageUnsecuredEndpointTelemetryBody", resourceCulture); } } - public static string NavMenuMetricsTab { + /// + /// Looks up a localized string similar to Endpoint is unsecured. + /// + public static string MessageUnsecuredEndpointTitle { get { - return ResourceManager.GetString("NavMenuMetricsTab", resourceCulture); + return ResourceManager.GetString("MessageUnsecuredEndpointTitle", resourceCulture); } } - public static string MainLayoutAspire { + /// + /// Looks up a localized string similar to Console. + /// + public static string NavMenuConsoleLogsTab { get { - return ResourceManager.GetString("MainLayoutAspire", resourceCulture); + return ResourceManager.GetString("NavMenuConsoleLogsTab", resourceCulture); } } - public static string MessageTelemetryBody { + /// + /// Looks up a localized string similar to Metrics. + /// + public static string NavMenuMetricsTab { get { - return ResourceManager.GetString("MessageTelemetryBody", resourceCulture); + return ResourceManager.GetString("NavMenuMetricsTab", resourceCulture); } } - public static string MessageTelemetryLink { + /// + /// Looks up a localized string similar to Resources. + /// + public static string NavMenuResourcesTab { + get { + return ResourceManager.GetString("NavMenuResourcesTab", resourceCulture); + } + } + + /// + /// 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..ca37f9ed640 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -1,17 +1,17 @@ - @@ -156,15 +156,12 @@ .NET Aspire - + Untrusted apps can send telemetry to the dashboard. - + More information - - Telemetry endpoint is unsecured - View filters @@ -180,4 +177,10 @@ Retry - + + Endpoint is unsecured + + + Untrusted apps can access sensitive information about the running services. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index 880397f41e5..c35dd6770c4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -42,19 +42,24 @@ Načíst znovu - - Untrusted apps can send telemetry to the dashboard. - Nedůvěryhodné aplikace můžou odesílat telemetrii na řídicí panel. + + More information + More information - - More information - Další informace + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - Koncový bod telemetrie je nezabezpečený + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 17f2e745db6..7918d800d73 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -42,19 +42,24 @@ Neu laden - - Untrusted apps can send telemetry to the dashboard. - Nicht vertrauenswürdige Apps können Telemetriedaten an das Dashboard senden. + + More information + More information - - More information - Weitere Informationen + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - Der Telemetrieendpunkt ist nicht gesichert. + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 57535e0bf7d..acb3984dd32 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -42,19 +42,24 @@ Recargar - - Untrusted apps can send telemetry to the dashboard. - Las aplicaciones que no son de confianza pueden enviar telemetría al panel. + + More information + More information - - More information - Más información + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - El punto de conexión de telemetría no es seguro + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index d8d38d6476f..86ab997911e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -42,19 +42,24 @@ Recharger - - 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. + + More information + More information - - More information - Informations supplémentaires + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - Le point de terminaison de télémétrie n’est pas sécurisé + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index f6b14cb3979..0b05fd9d5ba 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -42,19 +42,24 @@ Ricarica - - Untrusted apps can send telemetry to the dashboard. - Le app non attendibili possono inviare dati di telemetria al dashboard. + + More information + More information - - More information - Altre informazioni + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - L'endpoint di telemetria non è protetto + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index b3454a0180f..8f96c3e3b61 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -42,19 +42,24 @@ 再読み込み - - Untrusted apps can send telemetry to the dashboard. - 信頼されていないアプリは、テレメトリをダッシュボードに送信できます。 + + More information + More information - - More information - その他の情報 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - テレメトリ エンドポイントはセキュリティで保護されていません + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 89b7607a7fc..8b9437d9b6b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -42,19 +42,24 @@ 다시 로드 - - Untrusted apps can send telemetry to the dashboard. - 신뢰할 수 없는 앱은 대시보드에 원격 분석을 보낼 수 있습니다. + + More information + More information - - More information - 자세한 정보 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - 원격 분석 엔드포인트가 보안되지 않음 + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 12a02e3620c..75f2d34e5e2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -42,19 +42,24 @@ Załaduj ponownie - - Untrusted apps can send telemetry to the dashboard. - Niezaufane aplikacje mogą wysyłać dane telemetryczne do pulpitu nawigacyjnego. + + More information + More information - - More information - Więcej informacji + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - Punkt końcowy telemetrii jest niezabezpieczony + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index f702d60e124..6657bb03de0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -42,19 +42,24 @@ Recarregar - - Untrusted apps can send telemetry to the dashboard. - Aplicativos não confiáveis podem enviar telemetria para o painel. + + More information + More information - - More information - Mais informações + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - O ponto de extremidade de telemetria não está protegido + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 623d91a17f2..d3ef50137b7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -42,19 +42,24 @@ Перезагрузить - - Untrusted apps can send telemetry to the dashboard. - Недоверенные приложения могут отправлять телеметрию на панель мониторинга. + + More information + More information - - More information - Дополнительные сведения + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - Конечная точка телеметрии не защищена + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index e15f4e33654..7ecc850aea6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -42,19 +42,24 @@ Yeniden yükle - - Untrusted apps can send telemetry to the dashboard. - Güvenilmeyen uygulamalar panoya telemetri verileri gönderebilir. + + More information + More information - - More information - Daha fazla bilgi + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - Telemetri uç noktası güvenli değil + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 9bf9947cf95..d22d292d970 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -42,19 +42,24 @@ 重新加载 - - Untrusted apps can send telemetry to the dashboard. - 不受信任的应用可以将遥测数据发送到仪表板。 + + More information + More information - - More information - 更多信息 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - 遥测终结点处于不安全状态 + + Endpoint is unsecured + Endpoint is unsecured diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 659cadb432c..35844241115 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -42,19 +42,24 @@ 重新載入 - - Untrusted apps can send telemetry to the dashboard. - 不受信任的應用程式可以將遙測傳送至儀表板。 + + More information + More information - - More information - 更多資訊 + + Untrusted apps can access sensitive information about the running services. + Untrusted apps can access sensitive information about the running services. + + + + Untrusted apps can send telemetry to the dashboard. + Untrusted apps can send telemetry to the dashboard. - - Telemetry endpoint is unsecured - 遙測端點不安全 + + Endpoint is unsecured + Endpoint is unsecured 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..d3926c82ced 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 UnsecuredEndpointMessageDismissedKey = "Aspire_Security_UnsecuredEndpointMessageDismissed"; public const string TracesPageState = "Aspire_PageState_Traces"; public const string StructuredLogsPageState = "Aspire_PageState_StructuredLogs"; diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index ff229233c88..d07856ef2a7 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.DashboardMcpAuthModeName.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,26 @@ static ReferenceExpression GetTargetUrlExpression(EndpointReference e) => context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpHttpUrlName.EnvVarName] = GetTargetUrlExpression(otlpHttp); } + var mcp = dashboardResource.GetEndpoint(McpEndpointName); + if (!mcp.Exists) + { + // Fallback to frontend https or http endpoint if not configured. + mcp = dashboardResource.GetEndpoint("https"); + if (!mcp.Exists) + { + mcp = dashboardResource.GetEndpoint("http"); + } + } + + 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] = mcp.Url; + + 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 20803fa83c8..bc13847c0b8 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -352,6 +352,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/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/13.0/apphost.run.json b/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/13.0/apphost.run.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/13.0/apphost.run.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/13.0/apphost.run.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/9.5/apphost.run.json b/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/9.5/apphost.run.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/9.5/apphost.run.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-apphost-singlefile/9.5/apphost.run.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-apphost/13.0/Properties/launchSettings.json b/src/Aspire.ProjectTemplates/templates/aspire-apphost/13.0/Properties/launchSettings.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-apphost/13.0/Properties/launchSettings.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-apphost/13.0/Properties/launchSettings.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-apphost/9.5/Properties/launchSettings.json b/src/Aspire.ProjectTemplates/templates/aspire-apphost/9.5/Properties/launchSettings.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-apphost/9.5/Properties/launchSettings.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-apphost/9.5/Properties/launchSettings.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-empty/13.0/AspireApplication.1.AppHost/Properties/launchSettings.json b/src/Aspire.ProjectTemplates/templates/aspire-empty/13.0/AspireApplication.1.AppHost/Properties/launchSettings.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-empty/13.0/AspireApplication.1.AppHost/Properties/launchSettings.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-empty/13.0/AspireApplication.1.AppHost/Properties/launchSettings.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-empty/9.5/AspireApplication.1.AppHost/Properties/launchSettings.json b/src/Aspire.ProjectTemplates/templates/aspire-empty/9.5/AspireApplication.1.AppHost/Properties/launchSettings.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-empty/9.5/AspireApplication.1.AppHost/Properties/launchSettings.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-empty/9.5/AspireApplication.1.AppHost/Properties/launchSettings.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-starter/13.0/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json b/src/Aspire.ProjectTemplates/templates/aspire-starter/13.0/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-starter/13.0/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-starter/13.0/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Aspire.ProjectTemplates/templates/aspire-starter/9.5/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json b/src/Aspire.ProjectTemplates/templates/aspire-starter/9.5/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json index 44807cd246a..d1bee9a0486 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-starter/9.5/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-starter/9.5/Aspire-StarterApplication.1.AppHost/Properties/launchSettings.json @@ -11,6 +11,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, @@ -24,6 +25,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18000", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index a33b70b31b7..63c5a381d80 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 DashboardMcpAuthModeName = new("Dashboard:Mcp:AuthMode", "DASHBOARD__MCP__AUTHMODE"); + public static readonly ConfigName DashboardMcpPrimaryApiKeyName = new("Dashboard:Mcp:PrimaryApiKey", "DASHBOARD__MCP__PRIMARYAPIKEY"); + public static readonly ConfigName DashboardMcpDisableName = new("Dashboard:Mcp:Disabled", "DASHBOARD__MCP__DISABLED"); 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"); @@ -45,6 +50,7 @@ public static class Legacy public static readonly ConfigName DashboardConfigFilePathName = new(KnownConfigNames.Legacy.DashboardConfigFilePath); public static readonly ConfigName DashboardFileConfigDirectoryName = new(KnownConfigNames.Legacy.DashboardFileConfigDirectory); public static readonly ConfigName ResourceServiceUrlName = new(KnownConfigNames.Legacy.ResourceServiceEndpointUrl); + public static readonly ConfigName DashboardOtlpSuppressUnsecuredTelemetryMessage = new("Dashboard:Otlp:SuppressUnsecuredTelemetryMessage", "Dashboard__Otlp__SuppressUnsecuredTelemetryMessage"); } } diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index deca87a0327..965498da7dc 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.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 5e15b7a86e3..2e6b91d352d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -40,26 +40,27 @@ public async Task OnInitialize_UnsecuredOtlp_NotDismissed_DisplayMessageBar() testLocalStorage.OnGetUnprotectedAsync = key => { - if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey) + switch (key) { - return (false, false); - } - else - { - throw new InvalidOperationException("Unexpected key."); + case BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey: + case BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey: + return (false, false); + default: + throw new InvalidOperationException("Unexpected key."); } }; var dismissedSettingSetTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); testLocalStorage.OnSetUnprotectedAsync = (key, value) => { - if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey) - { - dismissedSettingSetTcs.TrySetResult((bool)value!); - } - else + switch (key) { - throw new InvalidOperationException("Unexpected key."); + case BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey: + case BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey: + dismissedSettingSetTcs.TrySetResult((bool)value!); + break; + default: + throw new InvalidOperationException("Unexpected key."); } }; @@ -79,8 +80,10 @@ public async Task OnInitialize_UnsecuredOtlp_NotDismissed_DisplayMessageBar() Assert.True(await dismissedSettingSetTcs.Task.DefaultTimeout()); } - [Fact] - public async Task OnInitialize_UnsecuredOtlp_Dismissed_NoMessageBar() + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task OnInitialize_UnsecuredOtlp_Dismissed_NoMessageBar(bool unsecuredTelemetryMessageDismissedKey, bool unsecuredEndpointMessageDismissedKey) { // Arrange var testLocalStorage = new TestLocalStorage(); @@ -97,13 +100,14 @@ public async Task OnInitialize_UnsecuredOtlp_Dismissed_NoMessageBar() testLocalStorage.OnGetUnprotectedAsync = key => { - if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey) + switch (key) { - return (true, true); - } - else - { - throw new InvalidOperationException("Unexpected key."); + case BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey: + return (unsecuredTelemetryMessageDismissedKey, unsecuredTelemetryMessageDismissedKey); + case BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey: + return (unsecuredEndpointMessageDismissedKey, unsecuredEndpointMessageDismissedKey); + default: + throw new InvalidOperationException("Unexpected key."); } }; @@ -124,15 +128,21 @@ public async Task OnInitialize_UnsecuredOtlp_Dismissed_NoMessageBar() } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task OnInitialize_UnsecuredOtlp_SuppressConfigured_NoMessageBar(bool suppressUnsecuredMessage) + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(true, false, true)] + [InlineData(false, true, true)] + public async Task OnInitialize_UnsecuredOtlp_SuppressConfigured_NoMessageBar(bool expectMessageBar, bool telemetrySuppressUnsecuredMessage, bool mcpSuppressUnsecuredMessage) { // Arrange var testLocalStorage = new TestLocalStorage(); var messageService = new MessageService(); - SetupMainLayoutServices(localStorage: testLocalStorage, messageService: messageService, suppressUnsecuredMessage: suppressUnsecuredMessage); + SetupMainLayoutServices(localStorage: testLocalStorage, messageService: messageService, configureOptions: o => + { + o.Otlp.SuppressUnsecuredMessage = telemetrySuppressUnsecuredMessage; + o.Mcp.SuppressUnsecuredMessage = mcpSuppressUnsecuredMessage; + }); var messageShownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); messageService.OnMessageItemsUpdatedAsync += () => @@ -143,13 +153,13 @@ public async Task OnInitialize_UnsecuredOtlp_SuppressConfigured_NoMessageBar(boo testLocalStorage.OnGetUnprotectedAsync = key => { - if (key == BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey) - { - return (false, false); // Message not dismissed, but should be suppressed by config if suppressUnsecuredMessage is true - } - else + switch (key) { - throw new InvalidOperationException("Unexpected key."); + case BrowserStorageKeys.UnsecuredTelemetryMessageDismissedKey: + case BrowserStorageKeys.UnsecuredEndpointMessageDismissedKey: + return (false, false); // Message not dismissed, but should be suppressed by config if suppressUnsecuredMessage is true + default: + throw new InvalidOperationException("Unexpected key."); } }; @@ -160,7 +170,7 @@ public async Task OnInitialize_UnsecuredOtlp_SuppressConfigured_NoMessageBar(boo }); // Assert - if (suppressUnsecuredMessage) + if (!expectMessageBar) { var timeoutTask = Task.Delay(100); var completedTask = await Task.WhenAny(messageShownTcs.Task, timeoutTask).DefaultTimeout(); @@ -177,7 +187,7 @@ public async Task OnInitialize_UnsecuredOtlp_SuppressConfigured_NoMessageBar(boo } } - private void SetupMainLayoutServices(TestLocalStorage? localStorage = null, MessageService? messageService = null, bool suppressUnsecuredMessage = false) + private void SetupMainLayoutServices(TestLocalStorage? localStorage = null, MessageService? messageService = null, Action? configureOptions = null) { FluentUISetupHelpers.AddCommonDashboardServices(this, localStorage: localStorage, messageService: messageService); @@ -190,7 +200,8 @@ private void SetupMainLayoutServices(TestLocalStorage? localStorage = null, Mess Services.Configure(o => { o.Otlp.AuthMode = OtlpAuthMode.Unsecured; - o.Otlp.SuppressUnsecuredTelemetryMessage = suppressUnsecuredMessage; + o.Mcp.AuthMode = McpAuthMode.Unsecured; + configureOptions?.Invoke(o); }); FluentUISetupHelpers.SetupFluentDialogProvider(this); diff --git a/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs b/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs index 99166c1a669..c112e2931fd 100644 --- a/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs +++ b/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs @@ -62,13 +62,16 @@ public async Task InvokeAsync_Scheme_ImageSourceChangesOnScheme(string scheme, s Assert.Contains(expectedContent, httpContext.Response.Headers.ContentSecurityPolicy.ToString()); } - [Fact] - public async Task InvokeAsync_Otlp_NotAdded() + [Theory] + [InlineData(ConnectionType.OtlpGrpc)] + [InlineData(ConnectionType.OtlpHttp)] + [InlineData(ConnectionType.Mcp)] + public async Task InvokeAsync_Otlp_NotAdded(ConnectionType connectionType) { // Arrange var middleware = CreateMiddleware(environmentName: "Production"); var httpContext = new DefaultHttpContext(); - httpContext.Features.Set(new TestConnectionTypeFeature { ConnectionTypes = [ConnectionType.Otlp] }); + httpContext.Features.Set(new TestConnectionTypeFeature { ConnectionTypes = [connectionType] }); // Act await middleware.InvokeAsync(httpContext).DefaultTimeout(); diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index faa0905fc10..2a97308bd52 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -252,6 +252,20 @@ public void OtlpOptions_HTTP_InvalidUrl() Assert.Equal("Failed to parse OTLP HTTP endpoint URL 'invalid'.", result.FailureMessage); } + [Fact] + public async Task OtlpOptions_SuppressUnsecuredMessage_LegacyName() + { + await using var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection( + [ + new("ASPNETCORE_URLS", "http://localhost:8000/"), + new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"), + new(DashboardConfigNames.Legacy.DashboardOtlpSuppressUnsecuredTelemetryMessage.ConfigKey, "true"), + ])); + var options = app.Services.GetService>()!; + + Assert.True(options.CurrentValue.Otlp.SuppressUnsecuredMessage); + } + #endregion #region OpenIDConnect options @@ -283,9 +297,9 @@ public void OpenIdConnectOptions_NoUserNameClaimType() } [Fact] - public void OpenIdConnectOptions_ClaimActions_MapJsonKeyTest() + public async Task OpenIdConnectOptions_ClaimActions_MapJsonKeyTestAsync() { - var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection( + await using var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection( [ new("ASPNETCORE_URLS", "http://localhost:8000/"), new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"), diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 19922963bc1..778b5b56edf 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -98,7 +98,7 @@ public async Task Get_LoginPage_ValidToken_OtlpHttpConnection_Denied() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var log = testSink.Writes.Single(s => s.LoggerName == typeof(FrontendCompositeAuthenticationHandler).FullName && s.EventId.Name == "AuthenticationSchemeNotAuthenticatedWithFailure"); - Assert.Equal("FrontendComposite was not authenticated. Failure message: Connection type Frontend is not enabled on this connection.", log.Message); + Assert.Equal("FrontendComposite was not authenticated. Failure message: Connection types 'Frontend' are not enabled on this connection.", log.Message); } [Fact] @@ -167,7 +167,7 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() await app.StartAsync().DefaultTimeout(); // Assert - var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); + var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Information).ToList(); Assert.Collection(l, w => { @@ -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/FrontendOpenIdConnectAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendOpenIdConnectAuthTests.cs index f826570f655..17ff5a48278 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendOpenIdConnectAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendOpenIdConnectAuthTests.cs @@ -87,7 +87,7 @@ public async Task Get_Unauthenticated_OtlpHttpConnection_Denied() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var log = testSink.Writes.Single(s => s.LoggerName == typeof(FrontendCompositeAuthenticationHandler).FullName && s.EventId.Name == "AuthenticationSchemeNotAuthenticatedWithFailure"); - Assert.Equal("FrontendComposite was not authenticated. Failure message: Connection type Frontend is not enabled on this connection.", log.Message); + Assert.Equal("FrontendComposite was not authenticated. Failure message: Connection types 'Frontend' are not enabled on this connection.", log.Message); await app.StopAsync().DefaultTimeout(); } 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..72d2430b46c --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs @@ -0,0 +1,174 @@ +// 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_McpEndPointDisabled_Failure() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardMcpDisableName.ConfigKey] = "true"; + }); + 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); + + // Assert + Assert.False(responseMessage.IsSuccessStatusCode); + } + + [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); + } + + internal 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; + } + + internal 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..55a4d90822a 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -29,7 +29,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..95e027f2640 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -357,6 +357,85 @@ await ServerRetryHelper.BindPortWithRetry(async port => } } + [Fact] + public async Task Configuration_BrowserAndOtlpGrpcAndMcpEndpointSame_Https_EndPointPortsAssigned() + { + // Arrange + DashboardWebApplication? app = null; + try + { + await ServerRetryHelper.BindPortWithRetry(async port => + { + app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + additionalConfiguration: initialData => + { + initialData[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = $"https://127.0.0.1:{port}"; + initialData[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = $"https://127.0.0.1:{port}"; + initialData[DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = $"https://127.0.0.1:{port}"; + initialData[DashboardConfigNames.DashboardMcpUrlName.ConfigKey] = $"https://127.0.0.1:{port}"; + }); + + // Act + await app.StartAsync().DefaultTimeout(); + }, NullLogger.Instance); + + // Assert + Assert.NotNull(app); + Assert.Equal(app.FrontendSingleEndPointAccessor().EndPoint.Port, app.OtlpServiceGrpcEndPointAccessor().EndPoint.Port); + + // Check browser access + using var browserHttpClient = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => + { + return true; + } + }) + { + BaseAddress = new Uri($"https://{app.FrontendSingleEndPointAccessor().EndPoint}") + }; + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + var response = await browserHttpClient.SendAsync(request).DefaultTimeout(); + response.EnsureSuccessStatusCode(); + + // Check OTLP service + using var channel = IntegrationTestHelpers.CreateGrpcChannel($"https://{app.FrontendSingleEndPointAccessor().EndPoint}", testOutputHelper); + var client = new LogsService.LogsServiceClient(channel); + var serviceResponse = await client.ExportAsync(new ExportLogsServiceRequest()).ResponseAsync.DefaultTimeout(); + Assert.Equal(0, serviceResponse.PartialSuccess.RejectedLogRecords); + + // Check MCP service + using var mcpHttpClient = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => + { + return true; + } + }) + { + BaseAddress = new Uri($"https://{app.McpEndPointAccessor().EndPoint}") + }; + var mcpRequest = McpServiceTests.CreateListToolsRequest(); + + var responseMessage = await mcpHttpClient.SendAsync(mcpRequest).DefaultTimeout(TestConstants.LongTimeoutDuration); + responseMessage.EnsureSuccessStatusCode(); + + var responseData = await McpServiceTests.GetDataFromSseResponseAsync(responseMessage); + + var jsonResponse = JsonNode.Parse(responseData!)!; + var tools = jsonResponse["result"]!["tools"]!.AsArray(); + + Assert.NotEmpty(tools); + } + finally + { + if (app is not null) + { + await app.DisposeAsync().DefaultTimeout(); + } + } + } + [Fact] public async Task Configuration_BrowserAndOtlpGrpcEndpointSame_NoHttps_Error() { @@ -432,7 +511,7 @@ await ServerRetryHelper.BindPortWithRetry(async port => // Assert Assert.NotNull(app); - Assert.Equal(app.FrontendSingleEndPointAccessor().EndPoint.Port, app.OtlpServiceGrpcEndPointAccessor().EndPoint.Port); + Assert.Equal(app.FrontendSingleEndPointAccessor().EndPoint.Port, app.OtlpServiceHttpEndPointAccessor().EndPoint.Port); // Check browser access using var httpClient = new HttpClient() @@ -453,7 +532,7 @@ await ServerRetryHelper.BindPortWithRetry(async port => var response = ExportLogsServiceResponse.Parser.ParseFrom(await responseMessage.Content.ReadAsByteArrayAsync().DefaultTimeout()); Assert.Equal(OtlpHttpEndpointsBuilder.ProtobufContentType, responseMessage.Content.Headers.GetValues("content-type").Single()); - Assert.False(responseMessage.Headers.Contains("content-security-policy")); + Assert.True(responseMessage.Headers.Contains("content-security-policy")); Assert.Equal(0, response.PartialSuccess.RejectedLogRecords); } finally @@ -601,7 +680,7 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() await app.StartAsync().DefaultTimeout(); // Assert - var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); + var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Information).ToList(); Assert.Collection(l, w => { @@ -632,6 +711,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) @@ -681,7 +765,7 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => // Assert Assert.NotNull(testSink); - var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList(); + var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Information).ToList(); Assert.Collection(l, w => { @@ -714,6 +798,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) @@ -879,14 +968,14 @@ public async Task Configuration_DisableAI_EnsureValueSetOnOptions(bool? value) Assert.Equal(!(value ?? false), aiContextProvider.Enabled); } - private static void AssertIPv4OrIPv6Endpoint(Func endPointAccessor) + private static void AssertIPv4OrIPv6Endpoint(Func endPointAccessor) { // Check that the address is IPv4 or IPv6 any. var ipEndPoint = endPointAccessor().EndPoint; Assert.True(ipEndPoint.Address.Equals(IPAddress.Any) || ipEndPoint.Address.Equals(IPAddress.IPv6Any), "Endpoint address should be IPv4 or IPv6."); } - private static void AssertDynamicIPEndpoint(Func endPointAccessor) + private static void AssertDynamicIPEndpoint(Func endPointAccessor) { // Check that the specified dynamic port of 0 is overridden with the actual port number. var ipEndPoint = endPointAccessor().EndPoint; 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() { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index 69ab62fa815..31051082327 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -94,7 +94,8 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string builder.Configuration.AddInMemoryCollection(new Dictionary { ["ASPNETCORE_URLS"] = "http://localhost", - [dashboardOtlpGrpcEndpointUrlKey] = "http://localhost" + [dashboardOtlpGrpcEndpointUrlKey] = "http://localhost", + [KnownConfigNames.DashboardMcpEndpointUrl] = "http://localhost" }); var container = builder.AddContainer(KnownResourceNames.AspireDashboard, "my-image"); @@ -107,7 +108,7 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string var dashboard = Assert.Single(model.Resources); - SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003); + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003, mcpPort: 5004); Assert.Same(container.Resource, dashboard); @@ -116,6 +117,11 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string .ToList(); Assert.Collection(config, + e => + { + Assert.Equal(KnownConfigNames.DashboardMcpEndpointUrl, e.Key); + Assert.Equal("http://localhost:5004", e.Value); + }, e => { Assert.Equal(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, e.Key); @@ -142,6 +148,16 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string Assert.Equal("Unsecured", e.Value); }, e => + { + Assert.Equal("DASHBOARD__MCP__AUTHMODE", e.Key); + Assert.Equal("Unsecured", e.Value); + }, + e => + { + Assert.Equal("DASHBOARD__MCP__PUBLICURL", e.Key); + Assert.Equal("http://localhost:5004", e.Value); + }, + e => { Assert.Equal("DASHBOARD__OTLP__AUTHMODE", e.Key); Assert.Equal("Unsecured", e.Value); @@ -159,6 +175,48 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string ); } + [Theory] + [InlineData(5004, "http://localhost")] // MCP port + [InlineData(5003, null)] // HTTP port + public async Task DashboardDoesNotAddResource_ConfiguresMcpEndpoint(int expectedPort, string? mcpEndpointUrl) + { + using var builder = TestDistributedApplicationBuilder.Create( + options => options.DisableDashboard = false, + testOutputHelper: testOutputHelper); + + builder.Services.AddSingleton(); + + builder.Configuration.Sources.Clear(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "http://localhost", + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost", + [KnownConfigNames.DashboardMcpEndpointUrl] = mcpEndpointUrl + }); + + var container = builder.AddContainer(KnownResourceNames.AspireDashboard, "my-image"); + + using var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout(); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources); + + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003, mcpPort: 5004); + + Assert.Same(container.Resource, dashboard); + + var config = (await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout()) + .OrderBy(c => c.Key) + .ToList(); + + Assert.Equal($"http://localhost:{expectedPort}", config.Single(e => e.Key == DashboardConfigNames.DashboardMcpPublicUrlName.EnvVarName).Value); + Assert.Equal($"http://localhost:{expectedPort}", config.Single(e => e.Key == DashboardConfigNames.DashboardMcpUrlName.EnvVarName).Value); + } + [Fact] public async Task DashboardWithDllPathLaunchesDotnet() { @@ -222,7 +280,7 @@ public async Task DashboardAuthConfigured_EnvVarsPresent(string dashboardOtlpGrp var dashboard = Assert.Single(model.Resources); - SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5000); + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5000, mcpPort: 5003); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); @@ -261,7 +319,7 @@ public async Task DashboardAuthRemoved_EnvVarsUnsecured(string dashboardOtlpGrpc var dashboard = Assert.Single(model.Resources); - SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5000); + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5000, mcpPort: 5003); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); @@ -297,7 +355,7 @@ public async Task DashboardResourceServiceUriIsSet(string dashboardOtlpGrpcEndpo var dashboard = Assert.Single(model.Resources); - SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5000); + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5000, mcpPort: 5003); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); @@ -340,7 +398,7 @@ public async Task DashboardResource_OtlpHttpEndpoint_CorsEnvVarSet(string? expli var dashboard = Assert.Single(model.Resources, r => r.Name == "aspire-dashboard"); - SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003); + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003, mcpPort: 5004); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, app.Services).DefaultTimeout(); @@ -381,7 +439,7 @@ public async Task DashboardResource_OtlpGrpcEndpoint_CorsEnvVarNotSet(string? ex var dashboard = Assert.Single(model.Resources, r => r.Name == "aspire-dashboard"); - SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003); + SetDashboardAllocatedEndpoints(dashboard, otlpGrpcPort: 5001, otlpHttpPort: 5002, httpPort: 5003, mcpPort: 5004); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, app.Services).DefaultTimeout(); @@ -551,7 +609,7 @@ public async Task DashboardIsExcludedFromManifestInPublishModeEvenIfAddedExplici Assert.Null(manifest); } - static void SetDashboardAllocatedEndpoints(IResource dashboard, int otlpGrpcPort, int otlpHttpPort, int httpPort) + static void SetDashboardAllocatedEndpoints(IResource dashboard, int otlpGrpcPort, int otlpHttpPort, int httpPort, int mcpPort) { foreach (var endpoint in dashboard.Annotations.OfType()) { @@ -563,6 +621,10 @@ static void SetDashboardAllocatedEndpoints(IResource dashboard, int otlpGrpcPort { endpoint.AllocatedEndpoint = new(endpoint, "localhost", otlpHttpPort, targetPortExpression: otlpHttpPort.ToString()); } + else if (endpoint.Name == DashboardEventHandlers.McpEndpointName) + { + endpoint.AllocatedEndpoint = new(endpoint, "localhost", mcpPort, targetPortExpression: mcpPort.ToString()); + } else if (endpoint.Name == "http") { endpoint.AllocatedEndpoint = new(endpoint, "localhost", httpPort, targetPortExpression: httpPort.ToString()); From 46144e7ca24bf069b4282a870465225932dd85be Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 27 Oct 2025 08:56:11 +0800 Subject: [PATCH 2/4] Clean up --- .../Mcp/{DashboardTools.cs => AspireMcpTools.cs} | 4 ++-- src/Aspire.Dashboard/Mcp/McpExtensions.cs | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) rename src/Aspire.Dashboard/Mcp/{DashboardTools.cs => AspireMcpTools.cs} (99%) diff --git a/src/Aspire.Dashboard/Mcp/DashboardTools.cs b/src/Aspire.Dashboard/Mcp/AspireMcpTools.cs similarity index 99% rename from src/Aspire.Dashboard/Mcp/DashboardTools.cs rename to src/Aspire.Dashboard/Mcp/AspireMcpTools.cs index 4f9b96c2471..df403e65f2f 100644 --- a/src/Aspire.Dashboard/Mcp/DashboardTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireMcpTools.cs @@ -15,13 +15,13 @@ namespace Aspire.Dashboard.Mcp; [McpServerToolType] -internal sealed class DashboardTools +internal sealed class AspireMcpTools { private readonly TelemetryRepository _telemetryRepository; private readonly IDashboardClient _dashboardClient; private readonly IEnumerable _outgoingPeerResolvers; - public DashboardTools(TelemetryRepository telemetryRepository, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) + public AspireMcpTools(TelemetryRepository telemetryRepository, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) { _telemetryRepository = telemetryRepository; _dashboardClient = dashboardClient; diff --git a/src/Aspire.Dashboard/Mcp/McpExtensions.cs b/src/Aspire.Dashboard/Mcp/McpExtensions.cs index 6e6c9b55af2..64d7397c893 100644 --- a/src/Aspire.Dashboard/Mcp/McpExtensions.cs +++ b/src/Aspire.Dashboard/Mcp/McpExtensions.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.Utils; using ModelContextProtocol.Protocol; namespace Aspire.Dashboard.Mcp; @@ -11,9 +12,13 @@ public static IMcpServerBuilder AddAspireMcpTools(this IServiceCollection servic { var builder = services.AddMcpServer(options => { - options.ServerInfo = new Implementation { Name = "Aspire MCP Server", Version = "1.0.0" }; + options.ServerInfo = new Implementation + { + Name = "Aspire MCP", + Version = VersionHelpers.DashboardDisplayVersion ?? "1.0.0" + }; options.ServerInstructions = - """ + """ ## Description This MCP Server provides various tools for managing Aspire resources, logs, traces and commands. @@ -23,10 +28,10 @@ public static IMcpServerBuilder AddAspireMcpTools(this IServiceCollection servic ## Tools - """; + """; }).WithHttpTransport(); - builder.WithTools(); + builder.WithTools(); return builder; } From a06d6833bc9be563c7235316309bbf7259dab6e2 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 27 Oct 2025 10:34:29 +0800 Subject: [PATCH 3/4] Icon support --- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 5 +++++ src/Aspire.Dashboard/Mcp/AspireMcpTools.cs | 3 ++- src/Aspire.Dashboard/Mcp/McpExtensions.cs | 16 +++++++++++++++- .../Mcp/Resources/aspire-16.png | Bin 0 -> 445 bytes .../Mcp/Resources/aspire-256.png | Bin 0 -> 3312 bytes .../Mcp/Resources/aspire-32.png | Bin 0 -> 703 bytes .../Mcp/Resources/aspire-48.png | Bin 0 -> 955 bytes .../Mcp/Resources/aspire-64.png | Bin 0 -> 1145 bytes 8 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 src/Aspire.Dashboard/Mcp/Resources/aspire-16.png create mode 100644 src/Aspire.Dashboard/Mcp/Resources/aspire-256.png create mode 100644 src/Aspire.Dashboard/Mcp/Resources/aspire-32.png create mode 100644 src/Aspire.Dashboard/Mcp/Resources/aspire-48.png create mode 100644 src/Aspire.Dashboard/Mcp/Resources/aspire-64.png diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 34dc1789567..88e1d6eaf25 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -305,6 +305,11 @@ + + + + + diff --git a/src/Aspire.Dashboard/Mcp/AspireMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireMcpTools.cs index df403e65f2f..1bf764d2e29 100644 --- a/src/Aspire.Dashboard/Mcp/AspireMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireMcpTools.cs @@ -221,7 +221,8 @@ public async Task ListConsoleLogsAsync( return consoleLogsData; } - [McpServerTool(Name = "execute_command"), Description("Executes a command on a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.")] + [McpServerTool(Name = "execute_command")] + [Description("Executes a command on a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.")] public static async Task ExecuteCommand(IDashboardClient dashboardClient, [Description("The resource name")] string resourceName, [Description("The command name")] string commandName) { var resource = dashboardClient.GetResource(resourceName); diff --git a/src/Aspire.Dashboard/Mcp/McpExtensions.cs b/src/Aspire.Dashboard/Mcp/McpExtensions.cs index 64d7397c893..ecd9ee367a9 100644 --- a/src/Aspire.Dashboard/Mcp/McpExtensions.cs +++ b/src/Aspire.Dashboard/Mcp/McpExtensions.cs @@ -12,10 +12,24 @@ public static IMcpServerBuilder AddAspireMcpTools(this IServiceCollection servic { var builder = services.AddMcpServer(options => { + // SVG isn't a required icon format for MCP. Use PNGs to ensure the icon is visible in all tools that support icons. + var sizes = new string[] { "16", "32", "48", "64", "256" }; + var icons = sizes.Select(s => + { + using var stream = typeof(McpExtensions).Assembly.GetManifestResourceStream($"Aspire.Dashboard.Mcp.Resources.aspire-{s}.png")!; + + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + var data = memoryStream.ToArray(); + + return new Icon { Source = $"data:image/png;base64,{Convert.ToBase64String(data)}", MimeType = "image/png", Sizes = [s] }; + }).ToList(); + options.ServerInfo = new Implementation { Name = "Aspire MCP", - Version = VersionHelpers.DashboardDisplayVersion ?? "1.0.0" + Version = VersionHelpers.DashboardDisplayVersion ?? "1.0.0", + Icons = icons }; options.ServerInstructions = """ diff --git a/src/Aspire.Dashboard/Mcp/Resources/aspire-16.png b/src/Aspire.Dashboard/Mcp/Resources/aspire-16.png new file mode 100644 index 0000000000000000000000000000000000000000..cfdb56357e605beaefbc1f4d45bc272019bcb8b3 GIT binary patch literal 445 zcmV;u0Yd(XP)D%4Ob)KMtZP$17$Fw{{h)le+fQ7qI|Fx7BR z+;30YVnEn*Ro!Ag*m6?bc39nmXW(W<*;zK$mw@G!edKmm-dQ!)j&4j?G%fRxnpX`ox<54TrtA%9L0000HbW%=J06%|kAn)%VpWnY<5bsZK?|FNF z6#xJL0dI0nQ~}STcbWhI0GdfeK~xyiUCmb#fV# z)&84TT|Goa41<1R3SsGY$!@mF6%Cw_x$S5L5iv*NJL7h`%-r)nx8Ik8Atd&TJ8(x* zjsX)pC|*t_GK0*`_hOtZ(llKHSJnKrvsRleWCz(}w-zd;4uIncmDnKn3{V%y6_uMu n_C1FL@BlnBs%Lr{7X9Z1kf0L9U@u|(00000NkvXXu0mjfh3Ux~ literal 0 HcmV?d00001 diff --git a/src/Aspire.Dashboard/Mcp/Resources/aspire-256.png b/src/Aspire.Dashboard/Mcp/Resources/aspire-256.png new file mode 100644 index 0000000000000000000000000000000000000000..23a244694d507735671e887503ed5d02deb7ed81 GIT binary patch literal 3312 zcmYLLdpuOz7e8}MjLBupj2a?CVLZyCmnJb}x@kokk@|MPU)ZB>Q>E1tnpU?T6v)0;c?Y+<1-|yP%I^^){FK87s006(( z+E_aQ00I^f07?q}oW1Yv0stTqvUj1vs*~;jjWkHp9)ffSAiX}Cem|t&2kG_G^oQU# zZ275@y8j*OqmlY(u!Y$D)2Iu}{j?o}H2pp&JwBulhrrJL5Qz^Nb<=jh>fldRf51t1 z1R@PW27HX09G=rrZ+*(2|~$b-&1`aMkt&mA46n+^I}y$!G#I=**+esJ)t-Mfo!BT?R?@n-}{ zfgc&a31UtQB0NS?!#~}Q8&8WEi}Mv^Tp7!Z9t(kn|F|+1>oa;iSeP0vyx{siiax>& z`RH|EFv8=*)u4~TutVfn{F%|bq=|=|*%#F!{;Q?;{a@epE_c3Id{I4LfA0&MIaz;i z?rqQVVE1xMy|{`!8|M0+&6@0LUg&(Wa3_6=|7x-S_43pEqT0K2ISCV8&5NbCrkiR+ z?G56jKtW>Q$5)LDS+Qf)#h>qHPCu&@mEE4sPMBabC+_FYq=XBLZcasckNm~`-1ty* zJ@{i`%G8^-#fm>?@~=*$T>RAfM9lA4>TFu%Fei)CraE{FEljc>BzB9uM=>{VI$C&Fb~myq7)S5Xo9aZ0s4}?w+=9$2 zI0z9O>fnOt=3dGL0Q4DKYfG1icXRpw#P<1OT5tFEO6e&zOIhCwt=sTqQ;S{%-4erX zDMFLBA`PJijAEfMiMelN-^ipzfL)YZMo!RzU%|fMk)Q(gjh}UP*%t)+O?;?6R_;>f zbmvH>p6fjq-PqUJZ{8UDB+r#s73Jigd^EFO_Vn(BlDXNJZqK4$3o`vK)i`Q}b(THK z<)2!46-tY>m+)r-7Wm+;x|-vVDXzim7N>37A(Ig3ho`Y zaoc5X+Wj^B@7?UfwT~6w>{$}sD%s}=NA+G5ppT>vIH-;!*~;v>bd4?-|4bYb}xc~<=kwU9C{v8MnG1i90evB z6$)3c*jUeQ+2KOQmryyq%V!Rq3PkFCpwOoNQs~|Pw#Q}`X+#RE)1qVQ9;b7*bR=4- zA@|l~tQPGY{@quJ!OP3QLEJ2k=ODr^+3F-x??IutDg8i}eXl>*q-Sf#xycJHgeqli ztP3Og5A9I~=?Y){!gv%dO5sHuX5h-89Z?_5956LkV7CXSDB zQSlTlo#?cR1e5)Aq70364s7x`WN|Kj4u!K;o1geBK2S$`WiIn+Tm}8;eu78G!)XF0 z{)od4CIOjhixc3{yDDI-2L7~S=~xff#tinfx8o5*2x_-c-=}0t3Ht@gNmzm3S+70Me_+0(6w=!FBJv(zjz9lYEj`YvD9uuEOlhsURZ*8T&%G7^K0g1=>Yafj z<#$g|I{s1w4PgSg<4ro|57>eYI~L^dcsOMut~hdd9}@>b>ctnTjBoNkEMQ67(aK1N zD%ttH*KtrgRWY0wDUm!ow}9oC`$yBP73(xg05iw6-H;jHL_Q+;#Og@hdzp>3q|=zG zmW}g9l<1zBm5Wyb5TE_i6!?y0H~Bj;V5i6O+E~7z!gD+ z+!Mde8KK^1?u-dO;RSA>6nf#}zFMxZxb`R{x99uH#VI{fzLfM6#~7UZ;lh}ogMQ>J z9dw!0NF}aO13xjzAYU-@s%7Hn7svyS-Z=IMLMkUGb=(!iBJ~g+;MulPW#st*hqw;@ z!}~`m?q7B?i%~8nCTd%%oB&%mv|=ZG@L#%a0l#jha)}Ht$!eB=v`C&?ZeaXpGjVH3GatYt~AHC8V=5rDV)EsOU5_0Zk`NDFjtT#3fXNM^Vg%F_pa$^ z$VMFTqRMnihz#CcwzzxXQJS2Yjswqh*%Cn4pOGxaxXXA0+XX!=p`01Tj$*D>gi=ju z6|G!V)SAy&)h4v85Feq*Lg!~U+Ib8iQnjThjVv(Mj)^rCY6B9%k0~q%bz;v;Ex;!+ z6rbvhXO{SMs(w~tZx&t_)HNV^q~dbUn{3ICAJ?#HMLk6%ynP!1`CR2T5Q|NNuM1`M z&QvQVPwJs~CDL2UR}g+#7NE87M*Ja`o&QEWpRli4Hk(&yqy-k5`y-Q-H&eruc9Vxp z5G#rbQiq}JGPrI@N=7|U>SjB?y*Nq0n;eZ}Z%rxs7l9nyQ%3NGZ_;dj94O3z0Uo z?3aj*;|vjmpQ%P3$CM@#uCyFPMYjj(Mu_3I`o2r~%NgCFw5+rkq@5UTd5`HZ9%jmMd) zS0`F2gX!k}zT_+kOWCP10h{R03^HIgqk?8mUIt+aBY$nt^0J*#MLh{5R_RilZ>@8$ zJy)9?xi3DH%We3#9O@-2Jip7Zk^}QVGr6&+|5n3r3fTgJl zW@3)gVpy8n!A$cs9b)6O`(wE30_Kv!2BcFVhDFh`cD}{uq6UzO8wm2_k4RSAAlZ+M zxM_Dy^ijApQk;?gvIKmxCujz*7Sx$yz_bz#Pv6;)7Nc}44A&mrV}^bK(=x84eTm!t z-;M~Dr!J6_^B7`K!Wo**ZcU#G0(VojH&Z9zbj(|B`%QbIg%~2Y#iCdj=J71c66i%p zA)@BD{SKxsXi*daYYvv93v$w8o=FnQkSAXEqmHZuzJ&y>Cip#Qri)&!e9R+{x}h%VEyR<7W3o9 z=2k3p&6ED;cA^Dt)u{lemyMy?8$D56dFKv}5s<3H)Pha!?w|^&NSa z(nBJrimJ*YDKRophV0aJOv>Xw0+P**u@-dF_E_wQ+K#yIEj2C0YUbxO*VM=5=W!#5 zk^)9uZIa5oC1q9Tmm5-DpWAk7SUg}K9^D`_E@ore8uJP^mmB6B0iMZ}L78%lrtyES{ZO!3T9IdClh-G`W9|N4pRAQSbmG?S0$achJjd+_ z6l?X!JKeS3)SVHV#Zn0>z>&SzPVEeQxh{j8=&*Qw@U_KkV!QDCKa=$(FM2e}I)=NX zr47q#!lk+5T2E>J)w#_0^V(|{Prq2L6UpxkU}+Lgc(D3~3l>U-vuA(wzvDb`RZ&rvJXP$17zDb`Rb)le(cP%6|>E7?&i z)le(dP%PF_E7wsg)KM$cQ!UkEKiFhK*o3$--Bo1YfIXdedKmm-i~$Rg=*n% zPup`<+<02vpNi(()%MK6^0}(+pNi*vU*5sA@U*1t!nN<$&-J^k@1>FGg=*l0YT?Si z^0%k$nS$oMukWds>6U-w)5`R8Roun7@vxukl6vH)mFe2j^~1ODteWbUfaSNS?V*k6 zpkO;n0000NbW%=J06(9PUw=>E&u<{_5Ff9<-!I?KZ(q;#);7ri0004Ra!ynM&!Tsl z0003(Nkl!$89VqfLD;7&;kY) zxt}VM+5^B+04xI;4UokOr>tU9yKrrtfU-{2#)c3fv5BeZP75_5L_K_n8-1Tjv7rF5 zMZiB*1KVCNc6PZ|RVg-oKj6ohzhb^KMizzK;9GaKtR*=Rc$;5 zz3fJJK6$F9zc9!%0WSo+R@K+%;H?L|w<9MSuo)ox`5@pkR0UWLU%kFdGrxa&BWBnv lh9=KgelEVw#rz>7P)t-s0000{ zDA!Oc)KM$cQ7F_D9=$U)KDPLP%6|=E7VXb)KV$dP%70?E7?#h z)KMncP$|<h-cTslQYzI^EYwje)KDzbQ7hD7J=bzl+;30YT{_oQFx7Nb-DybK zYf9Q)I@eh>)^JbUQ!UkEK-gkG*k?uAWJ1_&P1|@{-gZ~sTR7K>aN?JM<&}NpfMnov zRoq!N)`x82d|=*sT;6F%*j&e(q-)l?Sl6vG|JlBP4;dolziErZ1#q!+M z_RPWZxvK84pX z*U$C4tnaRz>ad{epNi*&YT&=I@1>FGmVe~Pyzz8Z+bA-yg3pk1uaOA0V&q zzd-*bL*W1b00D1uPE-NUqIa4A00H+&L_t(Y$IX@3TN*(W#aGZMim@bW)GKDO5bPOy zuqBv@4GWgoHPKY{|9|z)vIA$=g`GT^^Tfw@4tLLQ?ktw|JgSOBqmgQJe+@Z$Vd}3X zS9NCoSUpisqW3LPw81D4C$f_)+wh@c{49Mc(9a*L3<8Zr14&1NM3HcRHUDm^5a@kJ z6wa0#r<1ORh+c;EW~6pX2NFD@HoWn0vZxV8+hl}Pi+eX$^0)ASQ4EBk<;DYJ6{Y~^N{$!Uk9UQq?v+W zEcnm{_EwD--GEp!K+BDnwO_1Y?~lAvfdgiW1BCd5y@Svyq}h@`9Bo`9)mp{gPj4$A zE!X>%C(z7i{8@fk1Fd5^?L(`OZb&INFtG`NMITyjyv2T*+lIi-E+qCmx0UZ@S`Cmg zk0bUWu!1WUixM5kf({{Y^c50WK+8zeu3sLXK;jg8XNck@q-Q$dC3Oygi(+rx*wtki zT8&i8)B6h1+Rlrxfd^bm)Hm#nXumsXUvuNUCy>M5Pti7(kOuZfv^PZc7B}$uWex+k zID*X67t002ovPDHLkV1l_s-gy83 literal 0 HcmV?d00001 diff --git a/src/Aspire.Dashboard/Mcp/Resources/aspire-64.png b/src/Aspire.Dashboard/Mcp/Resources/aspire-64.png new file mode 100644 index 0000000000000000000000000000000000000000..a67bc4bb04aa380ddc2af364cc3e5fd97d574e80 GIT binary patch literal 1145 zcmV-<1cv*GP)Db`RZ&rvJXP$17xD%DUb z)KMzcP%70?E!R;i)le(dP%zI?EYwmd-cc*pP%PF^DA!Rd)KM$cS2EREHP%%y)o@ST zbXDDNPup!x+iObNUOLxZI@ePz)nY){Vn5h&Qru@n*=a}FS2NXMJlAAG*@tZ6lY8Wj zb>n~vM!!L;zDk?5zD>DSNo zx2Nryg64&4;L5-9sh8>6()GQs@12L{iErVAYT?t$^st}n#kujHispD)-OR!AsFdlf zn(D~B@r`xjqmSs*%k<2`^Pr07mVo8JvhTE|?4OJ0)XVg-pzNE4=EG8vMgRZ+A9PYq zQve@7FJI5!pO23(-@k7l?+|~VPp?1U&k*k)UvD7KSmf5i00001Z*opl0neg$ng9R- zmq|oHR9M61mFZX0KorG84Il#DP!zX7NnNP87Kxxm+$D9R?iCgHeb>L=c`r*RcP4F; ze(@ad2MFhIZ{FPBWX3T5aSh~hTl%n@gO<>cnbkjR`P!PzKVn&ZC2V8s^bG&XWe6A* zzRIj^S1aOSEJK0x;sdtOL3$cb>(A2whiuki@mMHkX>isHsLqM!Qpy3yU$cE3p}z)F z1(d15z;Dl|?s0-%c{B{LkEKOP4IZ~8(QX(@?l+|dJ*7h_BMfvR4a%~=2W8FACI2KC z;Yl0BD3hHH+am}=B}Qoi!bp!vP*Q`nQ}u<@n7}A|_gJDAEjmJHAfRd``6TiM@n1{i zcNTDjL?{X2G7sQ5XCZ)5mJ=X6=lPw+3M8EG-6J))>;ziHMNIcX$p}}&fD0FOD7#z} zMH=9e)6wOWC_4xP;b!Q5x^aV7WWS1%K8Zry>Bb1_kpSna4rNS_J7k1mkBIVGJjy7- zSd=qyBbZ8qT~KOHw>%<@?J?$}qN}KKea&s&xCy!iiH(X5B_q7;3f;kgb)2cCyQm0Z z&1K!ignKxGrblk@z8CNS7xC~BBs^A8QiCnmR~tuof{Uo(MKw;5?I2*|DJDFFfah?! zF~UtZ)C&xFi6bmUQF4RrPJsI=5z6qQVuY_Hzq^4wu49jHAO2Dl%3^|=~4XoOR=vZ$KQXV z_BtO-BgU?R)rY2b8^)e~0{D2}n|X#Q|EAwJyg!S5vZP)P|EK-}$`#4wc9G&s00000 LNkvXXu0mjfy^K2~ literal 0 HcmV?d00001 From 96c12e0bb4c768a9d882f5ff8b3b59b6966ca4bf Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 27 Oct 2025 10:54:38 +0800 Subject: [PATCH 4/4] Add MCP to notices --- THIRD-PARTY-NOTICES.TXT | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/THIRD-PARTY-NOTICES.TXT b/THIRD-PARTY-NOTICES.TXT index 1854bde8a5f..d614460a271 100644 --- a/THIRD-PARTY-NOTICES.TXT +++ b/THIRD-PARTY-NOTICES.TXT @@ -101,3 +101,96 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +License notice for Humanizer +---------------------------------------------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +License notice for D3 +---------------------------------------------------------------------------------------------- + +Copyright 2010-2023 Mike Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +License notice for OpenAI .NET API library +---------------------------------------------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2024 OpenAI (https://openai.com) + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +License notice for MCP C# SDK +---------------------------------------------------------------------------------------------- + +MIT License + +Copyright (c) Anthropic and Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.