From 9c2749cae5841959aa1df1bebd668b012f045fd7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 08:50:51 -0800 Subject: [PATCH 1/5] Update MCP error to suggest 'aspire run --detach' --- src/Aspire.Cli/Mcp/McpErrorMessages.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Mcp/McpErrorMessages.cs b/src/Aspire.Cli/Mcp/McpErrorMessages.cs index 3f722db771a..e43d1cba90a 100644 --- a/src/Aspire.Cli/Mcp/McpErrorMessages.cs +++ b/src/Aspire.Cli/Mcp/McpErrorMessages.cs @@ -13,7 +13,7 @@ internal static class McpErrorMessages /// public const string NoAppHostRunning = "No Aspire AppHost is currently running. " + - "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + + "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run --detach' in your AppHost project directory. " + "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands."; /// From 486bdcfd2c148818744e39bfc2b3036b838c3ef5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 08:52:27 -0800 Subject: [PATCH 2/5] Add test assertions for --detach in MCP error message --- tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs | 1 + tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs | 1 + tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index d941afded2c..a83f51ff414 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -34,6 +34,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenNoAppHostRunnin () => tool.CallToolAsync(null!, CreateArguments("test-resource", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs index 0cd6eba5427..7f7b23bf0cb 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -28,6 +28,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenNoAppHostRunning() () => tool.CallToolAsync(null!, arguments, CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs index 3dffdbf757f..26384f0af29 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs @@ -22,6 +22,7 @@ public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning() () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] From 58188ffe83fb0b5212835137c165c8efbb29828b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 13:15:28 -0800 Subject: [PATCH 3/5] Show help when aspire is invoked without arguments --- src/Aspire.Cli/Commands/RootCommand.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 4786e3f67ed..f82162cd0c6 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Help; #if DEBUG using System.Globalization; @@ -121,13 +122,19 @@ public RootCommand( Options.Add(WaitForDebuggerOption); Options.Add(CliWaitForDebuggerOption); - // Handle standalone 'aspire --banner' (no subcommand) + // Handle standalone 'aspire' or 'aspire --banner' (no subcommand) this.SetAction((context, cancellationToken) => { var bannerRequested = context.GetValue(BannerOption); - // If --banner was passed, we've already shown it in Main, just exit successfully - // Otherwise, show the standard "no command" error - return Task.FromResult(bannerRequested ? 0 : 1); + if (bannerRequested) + { + // If --banner was passed, we've already shown it in Main, just exit successfully + return Task.FromResult(0); + } + + // No subcommand provided - show help + new HelpAction().Invoke(context); + return Task.FromResult(0); }); Subcommands.Add(newCommand); From 324900b38dcadb3e4bc52c6d7603830f578f4457 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 20:15:14 -0800 Subject: [PATCH 4/5] Return InvalidCommand exit code for consistency with other parent commands --- src/Aspire.Cli/Commands/RootCommand.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index f82162cd0c6..2d03b6e6920 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -129,12 +129,13 @@ public RootCommand( if (bannerRequested) { // If --banner was passed, we've already shown it in Main, just exit successfully - return Task.FromResult(0); + return Task.FromResult(ExitCodeConstants.Success); } - // No subcommand provided - show help + // No subcommand provided - show help but return InvalidCommand to signal usage error + // This is consistent with other parent commands (DocsCommand, SdkCommand, etc.) new HelpAction().Invoke(context); - return Task.FromResult(0); + return Task.FromResult(ExitCodeConstants.InvalidCommand); }); Subcommands.Add(newCommand); From 47ce4401a48a15efab81bf4af618f86f80c9029b Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 20:18:43 -0800 Subject: [PATCH 5/5] Add tests for parent command exit codes to prevent regression --- .../Commands/CacheCommandTests.cs | 41 +++++++++++++++++++ .../Commands/McpCommandTests.cs | 30 ++++++++++++++ .../Commands/SdkCommandTests.cs | 41 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs diff --git a/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs new file mode 100644 index 00000000000..53929cf7da7 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class CacheCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task CacheCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task CacheCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs index ee4354cd54a..61a8dd0397d 100644 --- a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs @@ -10,6 +10,21 @@ namespace Aspire.Cli.Tests.Commands; public class McpCommandTests(ITestOutputHelper outputHelper) { + [Fact] + public async Task McpCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("mcp"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + [Fact] public async Task McpCommandWithHelpArgumentReturnsZero() { @@ -82,6 +97,21 @@ public async Task McpCommandIsHidden() Assert.True(mcpCommand.Hidden, "The mcp command should be hidden for backward compatibility"); } + [Fact] + public async Task AgentCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + [Fact] public async Task AgentCommandWithHelpArgumentReturnsZero() { diff --git a/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs new file mode 100644 index 00000000000..e39d7afb833 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class SdkCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task SdkCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("sdk"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task SdkCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("sdk --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } +}