diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index e48efe90f10..e35864e20be 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -378,6 +378,11 @@ public DashboardWebApplication( // This isn't used by dotnet watch but still useful to have for debugging _logger.LogInformation("OTLP/HTTP listening on: {OtlpEndpointUri}", _otlpServiceHttpEndPointAccessor().GetResolvedAddress()); } + if (_mcpEndPointAccessor != null) + { + // This isn't used by dotnet watch but still useful to have for debugging + _logger.LogInformation("MCP listening on: {McpEndpointUri}", _mcpEndPointAccessor().GetResolvedAddress()); + } if (_dashboardOptionsMonitor.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured) { diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 778b5b56edf..bf59b0d1dc1 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -195,6 +195,13 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() Assert.NotEqual(0, uri.Port); }, w => + { + Assert.Equal("MCP listening on: {McpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "McpEndpointUri")!); + Assert.NotEqual(0, uri.Port); + }, + w => { 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); diff --git a/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs b/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs index 1f246deb632..7e3396d9ff1 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs @@ -39,7 +39,15 @@ public static async Task BindPortsWithRetry(Func, Task> retryFunc, ILo var port = GetAvailablePort(nextPortAttempt, logger); ports.Add(port); - nextPortAttempt = port + Random.Shared.Next(100); + // Use a minimum gap of 10 between port allocations to reduce the risk of port collisions. + // Allocating consecutive ports (gap of 0) can lead to conflicts if the OS or other processes + // allocate ports in the same range. The random gap further reduces the chance of collision. + nextPortAttempt = port + Random.Shared.Next(10, 100); + } + + if (ports.Count != ports.Distinct().Count()) + { + throw new InvalidOperationException($"Generated ports list contains duplicate numbers: {string.Join(", ", ports)}"); } try diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 95e027f2640..a1dbc8f5162 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.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.Collections.Concurrent; using System.Net; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Nodes; @@ -708,6 +709,13 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() Assert.NotEqual(0, uri.Port); }, w => + { + Assert.Equal("MCP listening on: {McpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "McpEndpointUri")!); + Assert.NotEqual(0, uri.Port); + }, + w => { 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); @@ -729,31 +737,42 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() public async Task LogOutput_LocalhostAddress_LocalhostInLogOutput() { // Arrange - TestSink? testSink = null; + var testSink = new TestSink(); + var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(testOutputHelper, testSink); + DashboardWebApplication? app = null; int? frontendPort1 = null; int? frontendPort2 = null; - int? otlpPort = null; + int? otlpGrpcPort = null; + int? otlpHttpPort = null; try { await ServerRetryHelper.BindPortsWithRetry(async ports => { frontendPort1 = ports[0]; frontendPort2 = ports[1]; - otlpPort = ports[2]; + otlpGrpcPort = ports[2]; + otlpHttpPort = ports[3]; - testSink = new TestSink(); - app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + // Reset sink writes. Required to clear out data from a previous failed retry. + // The following cast relies on the internal implementation detail that TestSink.Writes is a ConcurrentQueue. + // If the implementation of TestSink changes, this may break. There is no public API to clear the writes. + var writes = (ConcurrentQueue)testSink.Writes; + writes.Clear(); + + app = IntegrationTestHelpers.CreateDashboardWebApplication(loggerFactory, additionalConfiguration: data => { data[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = $"https://localhost:{frontendPort1};http://localhost:{frontendPort2}"; - data[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = $"http://localhost:{otlpPort}"; - }, testSink: testSink); + data[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = $"http://localhost:{otlpGrpcPort}"; + data[DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = $"http://localhost:{otlpHttpPort}"; + data[DashboardConfigNames.DashboardMcpUrlName.ConfigKey] = "http://127.0.0.1:0"; // Test that a dynamic port has a set value in logs. + }); // Act await app.StartAsync().DefaultTimeout(); - }, NullLogger.Instance, portCount: 3); + }, loggerFactory.CreateLogger(GetType()), portCount: 4); } finally { @@ -764,7 +783,6 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => } // Assert - Assert.NotNull(testSink); var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Information).ToList(); Assert.Collection(l, w => @@ -785,14 +803,21 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => Assert.Equal("OTLP/gRPC listening on: {OtlpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); var uri = new Uri((string)GetValue(w.State, "OtlpEndpointUri")!); - Assert.NotEqual(0, uri.Port); + Assert.Equal(otlpGrpcPort, uri.Port); }, w => { Assert.Equal("OTLP/HTTP listening on: {OtlpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); var uri = new Uri((string)GetValue(w.State, "OtlpEndpointUri")!); - Assert.NotEqual(0, uri.Port); + Assert.Equal(otlpHttpPort, uri.Port); + }, + w => + { + Assert.Equal("MCP listening on: {McpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "McpEndpointUri")!); + Assert.NotEqual(0, uri.Port); // Check that allocated port is in log message }, w => {