From e47f4677ae2e26d8eb1988c1e6c63b10713abb56 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Wed, 8 Oct 2025 13:36:20 -0400 Subject: [PATCH 1/8] Add a command listing all tool names --- .../Tools/Commands/ToolsListNamesCommand.cs | 71 +++++++++++++++++++ .../Tools/Options/ToolsListNamesOptions.cs | 9 +++ .../src/Areas/Tools/ToolsSetup.cs | 4 ++ .../src/Models/ModelsJsonContext.cs | 2 + 4 files changed, 86 insertions(+) create mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs new file mode 100644 index 000000000..fa8d5d7d9 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Tools.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models; +using Azure.Mcp.Core.Models.Command; +using Microsoft.Extensions.Logging; +using System.CommandLine.Parsing; + +namespace Azure.Mcp.Core.Areas.Tools.Commands; + +[HiddenCommand] +public sealed class ToolsListNamesCommand(ILogger logger) : BaseCommand +{ + private const string CommandTitle = "List Tool Names"; + + public override string Name => "list-names"; + + public override string Description => + """ + List all available tool names in the Azure MCP server. This command returns a simple list of tool names + without descriptions or metadata, useful for quick discovery or automated tool enumeration. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + protected override ToolsListNamesOptions BindOptions(ParseResult parseResult) + { + return new ToolsListNamesOptions(); + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + try + { + var factory = context.GetService(); + var options = BindOptions(parseResult); + + // Get all visible commands and extract their tokenized names (full command paths) + var toolNames = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name + .Where(name => !string.IsNullOrEmpty(name)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList()); + + var result = new ToolNamesResult(toolNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + catch (Exception ex) + { + logger.LogError(ex, "An exception occurred while processing tool names listing."); + HandleException(context, ex); + + return context.Response; + } + } + + public record ToolNamesResult(List Names); +} \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs new file mode 100644 index 000000000..6319dce19 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Tools.Options; + +public sealed class ToolsListNamesOptions +{ + // This command requires no additional options - it simply lists all tool names +} \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs index 10fc821a5..c62ae7e81 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs @@ -14,6 +14,7 @@ public sealed class ToolsSetup : IAreaSetup public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -24,6 +25,9 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var list = serviceProvider.GetRequiredService(); tools.AddCommand(list.Name, list); + var listNames = serviceProvider.GetRequiredService(); + tools.AddCommand(listNames.Name, listNames); + return tools; } } diff --git a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs index ad710c31e..62bdefff5 100644 --- a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas.Tools.Commands; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Models.Elicitation; @@ -13,6 +14,7 @@ namespace Azure.Mcp.Core.Models; [JsonSerializable(typeof(ElicitationSchemaRoot))] [JsonSerializable(typeof(ElicitationSchemaProperty))] [JsonSerializable(typeof(ToolMetadata))] +[JsonSerializable(typeof(ToolsListNamesCommand.ToolNamesResult))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] public sealed partial class ModelsJsonContext : JsonSerializerContext { From f311a5cfa82d7616ec08ca1981144324a1fb4363 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Wed, 8 Oct 2025 15:34:53 -0400 Subject: [PATCH 2/8] Add --namespace option to scope it down to a specific namespace --- .../Tools/Commands/ToolsListNamesCommand.cs | 24 ++++- .../Tools/Options/ToolsListNamesOptions.cs | 5 +- .../Options/ToolsListOptionDefinitions.cs | 7 ++ .../UnitTests/ToolsListNamesCommandTests.cs | 92 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs index fa8d5d7d9..bac5401ad 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs @@ -3,6 +3,7 @@ using Azure.Mcp.Core.Areas.Tools.Options; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; using Azure.Mcp.Core.Models; using Azure.Mcp.Core.Models.Command; using Microsoft.Extensions.Logging; @@ -35,9 +36,17 @@ List all available tool names in the Azure MCP server. This command returns a si Secret = false }; + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(ToolsListOptionDefinitions.Namespace); + } + protected override ToolsListNamesOptions BindOptions(ParseResult parseResult) { - return new ToolsListNamesOptions(); + var options = new ToolsListNamesOptions(); + options.Namespace = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + return options; } public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) @@ -48,9 +57,18 @@ public override async Task ExecuteAsync(CommandContext context, var options = BindOptions(parseResult); // Get all visible commands and extract their tokenized names (full command paths) - var toolNames = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands) + var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands) .Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name - .Where(name => !string.IsNullOrEmpty(name)) + .Where(name => !string.IsNullOrEmpty(name)); + + // Apply namespace filtering if specified + if (!string.IsNullOrEmpty(options.Namespace)) + { + var namespacePrefix = $"azmcp_{options.Namespace}_"; + allToolNames = allToolNames.Where(name => name.StartsWith(namespacePrefix, StringComparison.OrdinalIgnoreCase)); + } + + var toolNames = await Task.Run(() => allToolNames .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) .ToList()); diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs index 6319dce19..09d3d9b4b 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs @@ -5,5 +5,8 @@ namespace Azure.Mcp.Core.Areas.Tools.Options; public sealed class ToolsListNamesOptions { - // This command requires no additional options - it simply lists all tool names + /// + /// Optional namespace to filter tool names. If provided, only tools from this namespace will be returned. + /// + public string? Namespace { get; set; } } \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs index 57af1bcf6..cf8b181d0 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs @@ -6,10 +6,17 @@ namespace Azure.Mcp.Core.Areas.Tools.Options; public static class ToolsListOptionDefinitions { public const string NamespacesOptionName = "namespaces"; + public const string NamespaceOptionName = "namespace"; public static readonly Option Namespaces = new($"--{NamespacesOptionName}") { Description = "If specified, returns a list of top-level service namespaces instead of individual tools.", Required = false }; + + public static readonly Option Namespace = new($"--{NamespaceOptionName}") + { + Description = "Filter tools by namespace (e.g., 'storage', 'keyvault'). If specified, only tools from this namespace will be returned.", + Required = false + }; } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs new file mode 100644 index 000000000..e638646b6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Areas.Tools.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Core.UnitTests.Areas.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Tools.UnitTests; + +public class ToolsListNamesCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly CommandContext _context; + private readonly ToolsListNamesCommand _command; + private readonly Command _commandDefinition; + + public ToolsListNamesCommandTests() + { + var collection = new ServiceCollection(); + collection.AddLogging(); + + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); + collection.AddSingleton(commandFactory); + + _serviceProvider = collection.BuildServiceProvider(); + _context = new(_serviceProvider); + _logger = Substitute.For>(); + _command = new(_logger); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + // Act & Assert + Assert.Equal("list-names", _command.Name); + Assert.Contains("List all available tool names", _command.Description); + Assert.Equal("List Tool Names", _command.Title); + Assert.False(_command.Metadata.Destructive); + Assert.True(_command.Metadata.ReadOnly); + Assert.True(_command.Metadata.Idempotent); + Assert.False(_command.Metadata.Secret); + } + + [Fact] + public void CanParseNamespaceOption() + { + // Arrange + var commandDefinition = _command.GetCommand(); + + // Act + var parseResult = commandDefinition.Parse(["--namespace", "storage"]); + + // Assert + Assert.NotNull(parseResult); + Assert.False(parseResult.Errors.Any(), $"Parse errors: {string.Join(", ", parseResult.Errors)}"); + + var namespaceValue = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.Equal("storage", namespaceValue); + } + + [Fact] + public async Task ExecuteAsync_WithNamespaceOption_FiltersCorrectly() + { + // Arrange + var commandDefinition = _command.GetCommand(); + var parseResult = commandDefinition.Parse(["--namespace", "storage"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + // Note: We're not testing response.Results here since it's null in the test environment + // but we can verify the command executes without errors + } +} \ No newline at end of file From 939c2b4e4e504b9d10c7852c4500c5395a4148ed Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Wed, 8 Oct 2025 15:37:03 -0400 Subject: [PATCH 3/8] Fix format --- .../src/Areas/Tools/Commands/ToolsListNamesCommand.cs | 4 ++-- .../src/Areas/Tools/Options/ToolsListNamesOptions.cs | 2 +- .../Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs index bac5401ad..c0e97219b 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine.Parsing; using Azure.Mcp.Core.Areas.Tools.Options; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Extensions; using Azure.Mcp.Core.Models; using Azure.Mcp.Core.Models.Command; using Microsoft.Extensions.Logging; -using System.CommandLine.Parsing; namespace Azure.Mcp.Core.Areas.Tools.Commands; @@ -86,4 +86,4 @@ public override async Task ExecuteAsync(CommandContext context, } public record ToolNamesResult(List Names); -} \ No newline at end of file +} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs index 09d3d9b4b..b60825b55 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs @@ -9,4 +9,4 @@ public sealed class ToolsListNamesOptions /// Optional namespace to filter tool names. If provided, only tools from this namespace will be returned. /// public string? Namespace { get; set; } -} \ No newline at end of file +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs index e638646b6..dd8b8357f 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs @@ -68,7 +68,7 @@ public void CanParseNamespaceOption() // Assert Assert.NotNull(parseResult); Assert.False(parseResult.Errors.Any(), $"Parse errors: {string.Join(", ", parseResult.Errors)}"); - + var namespaceValue = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); Assert.Equal("storage", namespaceValue); } @@ -89,4 +89,4 @@ public async Task ExecuteAsync_WithNamespaceOption_FiltersCorrectly() // Note: We're not testing response.Results here since it's null in the test environment // but we can verify the command executes without errors } -} \ No newline at end of file +} From 117e3a1d35608fe2066c36957e65787666d4db23 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Thu, 9 Oct 2025 10:58:32 -0400 Subject: [PATCH 4/8] Add --name switch to tool list command to only return tool names --- .../Areas/Tools/Commands/ToolsListCommand.cs | 62 +++- .../Tools/Commands/ToolsListNamesCommand.cs | 89 ------ .../Tools/Options/ToolsListNamesOptions.cs | 12 - .../Options/ToolsListOptionDefinitions.cs | 16 +- .../Areas/Tools/Options/ToolsListOptions.cs | 12 +- .../src/Areas/Tools/ToolsSetup.cs | 4 - .../src/Models/ModelsJsonContext.cs | 2 +- .../Tools/UnitTests/ToolsListCommandTests.cs | 282 +++++++++++++++++- .../UnitTests/ToolsListNamesCommandTests.cs | 92 ------ .../Azure.Mcp.Server/docs/azmcp-commands.md | 4 +- 10 files changed, 360 insertions(+), 215 deletions(-) delete mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs delete mode 100644 core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs delete mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs index f35e66da2..2a8ec2829 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine.Parsing; using Azure.Mcp.Core.Areas.Tools.Options; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models; +using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Models.Option; using Microsoft.Extensions.Logging; @@ -19,7 +23,7 @@ public sealed class ToolsListCommand(ILogger logger) : BaseCom """ List all available commands and their tools in a hierarchical structure. This command returns detailed information about each command, including its name, description, full command path, available subcommands, and all supported - arguments. Use this to explore the CLI's functionality or to build interactive command interfaces. + arguments. Use --name to return only tool names, and --namespace to filter by specific namespaces. """; public override string Title => CommandTitle; @@ -37,14 +41,19 @@ arguments. Use this to explore the CLI's functionality or to build interactive c protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(ToolsListOptionDefinitions.Namespaces); + command.Options.Add(ToolsListOptionDefinitions.NamespaceMode); + command.Options.Add(ToolsListOptionDefinitions.Namespace); + command.Options.Add(ToolsListOptionDefinitions.Name); } protected override ToolsListOptions BindOptions(ParseResult parseResult) { + var namespaces = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name) ?? []; return new ToolsListOptions { - Namespaces = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespaces) + NamespaceMode = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NamespaceMode), + Name = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Name), + Namespaces = namespaces.ToList() }; } @@ -55,8 +64,33 @@ public override async Task ExecuteAsync(CommandContext context, var factory = context.GetService(); var options = BindOptions(parseResult); - // If the --namespaces flag is set, return distinct top‑level namespaces (child groups beneath root 'azmcp'). - if (options.Namespaces) + // If the --name flag is set, return only tool names + if (options.Name) + { + // Get all visible commands and extract their tokenized names (full command paths) + var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name + .Where(name => !string.IsNullOrEmpty(name)); + + // Apply namespace filtering if specified + if (options.Namespaces.Count > 0) + { + var namespacePrefixes = options.Namespaces.Select(ns => $"azmcp_{ns}_").ToList(); + allToolNames = allToolNames.Where(name => + namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); + } + + var toolNames = await Task.Run(() => allToolNames + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList()); + + var result = new ToolNamesResult(toolNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + + // If the --namespace-mode flag is set, return distinct top‑level namespaces (child groups beneath root 'azmcp'). + if (options.NamespaceMode) { var ignored = new HashSet(StringComparer.OrdinalIgnoreCase) { "server", "tools" }; var surfaced = new HashSet(StringComparer.OrdinalIgnoreCase) { "extension" }; @@ -99,9 +133,19 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - var tools = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands) - .Select(kvp => CreateCommand(kvp.Key, kvp.Value)) - .ToList()); + // Get all tools with full details + var allTools = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => CreateCommand(kvp.Key, kvp.Value)); + + // Apply namespace filtering if specified + if (options.Namespaces.Count > 0) + { + var namespacePrefixes = options.Namespaces.Select(ns => $"azmcp {ns}").ToList(); + allTools = allTools.Where(tool => + namespacePrefixes.Any(prefix => tool.Command.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); + } + + var tools = await Task.Run(() => allTools.ToList()); context.Response.Results = ResponseResult.Create(tools, ModelsJsonContext.Default.ListCommandInfo); return context.Response; @@ -135,4 +179,6 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm Metadata = command.Metadata }; } + + public record ToolNamesResult(List Names); } diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs deleted file mode 100644 index c0e97219b..000000000 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListNamesCommand.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.CommandLine.Parsing; -using Azure.Mcp.Core.Areas.Tools.Options; -using Azure.Mcp.Core.Commands; -using Azure.Mcp.Core.Extensions; -using Azure.Mcp.Core.Models; -using Azure.Mcp.Core.Models.Command; -using Microsoft.Extensions.Logging; - -namespace Azure.Mcp.Core.Areas.Tools.Commands; - -[HiddenCommand] -public sealed class ToolsListNamesCommand(ILogger logger) : BaseCommand -{ - private const string CommandTitle = "List Tool Names"; - - public override string Name => "list-names"; - - public override string Description => - """ - List all available tool names in the Azure MCP server. This command returns a simple list of tool names - without descriptions or metadata, useful for quick discovery or automated tool enumeration. - """; - - public override string Title => CommandTitle; - - public override ToolMetadata Metadata => new() - { - Destructive = false, - Idempotent = true, - OpenWorld = false, - ReadOnly = true, - LocalRequired = false, - Secret = false - }; - - protected override void RegisterOptions(Command command) - { - base.RegisterOptions(command); - command.Options.Add(ToolsListOptionDefinitions.Namespace); - } - - protected override ToolsListNamesOptions BindOptions(ParseResult parseResult) - { - var options = new ToolsListNamesOptions(); - options.Namespace = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); - return options; - } - - public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) - { - try - { - var factory = context.GetService(); - var options = BindOptions(parseResult); - - // Get all visible commands and extract their tokenized names (full command paths) - var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands) - .Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name - .Where(name => !string.IsNullOrEmpty(name)); - - // Apply namespace filtering if specified - if (!string.IsNullOrEmpty(options.Namespace)) - { - var namespacePrefix = $"azmcp_{options.Namespace}_"; - allToolNames = allToolNames.Where(name => name.StartsWith(namespacePrefix, StringComparison.OrdinalIgnoreCase)); - } - - var toolNames = await Task.Run(() => allToolNames - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .ToList()); - - var result = new ToolNamesResult(toolNames); - context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); - return context.Response; - } - catch (Exception ex) - { - logger.LogError(ex, "An exception occurred while processing tool names listing."); - HandleException(context, ex); - - return context.Response; - } - } - - public record ToolNamesResult(List Names); -} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs deleted file mode 100644 index b60825b55..000000000 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListNamesOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Azure.Mcp.Core.Areas.Tools.Options; - -public sealed class ToolsListNamesOptions -{ - /// - /// Optional namespace to filter tool names. If provided, only tools from this namespace will be returned. - /// - public string? Namespace { get; set; } -} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs index cf8b181d0..50cf127e3 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs @@ -5,18 +5,26 @@ namespace Azure.Mcp.Core.Areas.Tools.Options; public static class ToolsListOptionDefinitions { - public const string NamespacesOptionName = "namespaces"; + public const string NamespaceModeOptionName = "namespace-mode"; public const string NamespaceOptionName = "namespace"; + public const string NameOptionName = "name"; - public static readonly Option Namespaces = new($"--{NamespacesOptionName}") + public static readonly Option NamespaceMode = new($"--{NamespaceModeOptionName}") { Description = "If specified, returns a list of top-level service namespaces instead of individual tools.", Required = false }; - public static readonly Option Namespace = new($"--{NamespaceOptionName}") + public static readonly Option Namespace = new($"--{NamespaceOptionName}") { - Description = "Filter tools by namespace (e.g., 'storage', 'keyvault'). If specified, only tools from this namespace will be returned.", + Description = "Filter tools by namespace (e.g., 'storage', 'keyvault'). Can be specified multiple times to include multiple namespaces.", + Required = false, + AllowMultipleArgumentsPerToken = true + }; + + public static readonly Option Name = new($"--{NameOptionName}") + { + Description = "If specified, returns only tool names without descriptions or metadata.", Required = false }; } diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs index ecf87aa6b..f779b4c0c 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs @@ -5,5 +5,15 @@ namespace Azure.Mcp.Core.Areas.Tools.Options; public sealed class ToolsListOptions { - public bool Namespaces { get; set; } = false; + public bool NamespaceMode { get; set; } = false; + + /// + /// If true, returns only tool names without descriptions or metadata. + /// + public bool Name { get; set; } = false; + + /// + /// Optional namespaces to filter tools. If provided, only tools from these namespaces will be returned. + /// + public List Namespaces { get; set; } = new(); } diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs index c62ae7e81..10fc821a5 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs @@ -14,7 +14,6 @@ public sealed class ToolsSetup : IAreaSetup public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); - services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -25,9 +24,6 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var list = serviceProvider.GetRequiredService(); tools.AddCommand(list.Name, list); - var listNames = serviceProvider.GetRequiredService(); - tools.AddCommand(listNames.Name, listNames); - return tools; } } diff --git a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs index 62bdefff5..54de67261 100644 --- a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs @@ -14,7 +14,7 @@ namespace Azure.Mcp.Core.Models; [JsonSerializable(typeof(ElicitationSchemaRoot))] [JsonSerializable(typeof(ElicitationSchemaProperty))] [JsonSerializable(typeof(ToolMetadata))] -[JsonSerializable(typeof(ToolsListNamesCommand.ToolNamesResult))] +[JsonSerializable(typeof(ToolsListCommand.ToolNamesResult))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] public sealed partial class ModelsJsonContext : JsonSerializerContext { diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs index 1ae6b718d..c103320c2 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs @@ -2,11 +2,14 @@ // Licensed under the MIT License. using System.CommandLine; +using System.CommandLine.Parsing; using System.Net; using System.Text.Json; using Azure.Mcp.Core.Areas; using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Areas.Tools.Options; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Services.Telemetry; using Azure.Mcp.Core.UnitTests.Areas.Server; @@ -51,6 +54,15 @@ private static List DeserializeResults(object results) return JsonSerializer.Deserialize>(json) ?? new List(); } + /// + /// Helper method to deserialize response results to ToolNamesResult + /// + private static ToolsListCommand.ToolNamesResult DeserializeToolNamesResult(object results) + { + var json = JsonSerializer.Serialize(results); + return JsonSerializer.Deserialize(json) ?? new ToolsListCommand.ToolNamesResult(new List()); + } + /// /// Verifies that the command returns a valid list of CommandInfo objects /// when executed with a properly configured context. @@ -311,13 +323,13 @@ public async Task ExecuteAsync_CommandPathFormattingIsCorrect() } /// - /// Verifies that the --namespaces switch returns only distinct top-level namespaces. + /// Verifies that the --namespace-mode switch returns only distinct top-level namespaces. /// [Fact] public async Task ExecuteAsync_WithNamespaceSwitch_ReturnsNamespacesOnly() { // Arrange - var args = _commandDefinition.Parse(new[] { "--namespaces" }); + var args = _commandDefinition.Parse(new[] { "--namespace-mode" }); // Act var response = await _command.ExecuteAsync(_context, args); @@ -444,4 +456,270 @@ public async Task ExecuteAsync_IncludesMetadataForAllCommands() Assert.True(metadata.LocalRequired || !metadata.LocalRequired, "LocalRequired should be defined"); } } + + /// + /// Verifies that the --name option returns only tool names without descriptions. + /// + [Fact] + public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name" }); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Explicitly verify that description and command fields are not present + Assert.False(jsonElement.TryGetProperty("description", out _), "Response should not contain 'description' property when using --name option"); + Assert.False(jsonElement.TryGetProperty("command", out _), "Response should not contain 'command' property when using --name option"); + Assert.False(jsonElement.TryGetProperty("options", out _), "Response should not contain 'options' property when using --name option"); + Assert.False(jsonElement.TryGetProperty("metadata", out _), "Response should not contain 'metadata' property when using --name option"); + + // Verify that all names are properly formatted tokenized names + foreach (var name in result.Names) + { + Assert.False(string.IsNullOrWhiteSpace(name), "Tool name should not be empty"); + Assert.StartsWith("azmcp_", name); + Assert.DoesNotContain(" ", name); + } + + // Should contain some well-known commands + Assert.Contains(result.Names, name => name.Contains("subscription")); + Assert.Contains(result.Names, name => name.Contains("storage")); + Assert.Contains(result.Names, name => name.Contains("keyvault")); + } + + /// + /// Verifies that the --namespace option filters tools correctly for a single namespace. + /// + [Fact] + public async Task ExecuteAsync_WithSingleNamespaceOption_FiltersCorrectly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // All commands should be from the storage namespace + foreach (var command in result) + { + Assert.StartsWith("azmcp storage", command.Command); + } + + // Should contain some well-known storage commands + Assert.Contains(result, cmd => cmd.Command == "azmcp storage account get"); + } + + /// + /// Verifies that multiple --namespace options work correctly. + /// + [Fact] + public async Task ExecuteAsync_WithMultipleNamespaceOptions_FiltersCorrectly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // All commands should be from either storage or keyvault namespaces + foreach (var command in result) + { + var isStorageCommand = command.Command.StartsWith("azmcp storage"); + var isKeyvaultCommand = command.Command.StartsWith("azmcp keyvault"); + Assert.True(isStorageCommand || isKeyvaultCommand, + $"Command '{command.Command}' should be from storage or keyvault namespace"); + } + + // Should contain commands from both namespaces + Assert.Contains(result, cmd => cmd.Command.StartsWith("azmcp storage")); + Assert.Contains(result, cmd => cmd.Command.StartsWith("azmcp keyvault")); + } + + /// + /// Verifies that --name and --namespace options work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // All names should be from the storage namespace + foreach (var name in result.Names) + { + Assert.StartsWith("azmcp_storage_", name); + } + + // Should contain some well-known storage commands + Assert.Contains(result.Names, name => name.Contains("account_get")); + } + + /// + /// Verifies that --name with multiple --namespace options works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // All names should be from either storage or keyvault namespaces + foreach (var name in result.Names) + { + var isStorageName = name.StartsWith("azmcp_storage_"); + var isKeyvaultName = name.StartsWith("azmcp_keyvault_"); + Assert.True(isStorageName || isKeyvaultName, + $"Tool name '{name}' should be from storage or keyvault namespace"); + } + + // Should contain names from both namespaces + Assert.Contains(result.Names, name => name.StartsWith("azmcp_storage_")); + Assert.Contains(result.Names, name => name.StartsWith("azmcp_keyvault_")); + } + + /// + /// Verifies that option binding works correctly for the new options. + /// + [Fact] + public void BindOptions_WithNewOptions_BindsCorrectly() + { + // Arrange + var parseResult = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage", "--namespace", "keyvault" }); + + // Use reflection to call the protected BindOptions method + var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(bindOptionsMethod); + + // Act + var options = bindOptionsMethod.Invoke(_command, new object?[] { parseResult }) as ToolsListOptions; + + // Assert + Assert.NotNull(options); + Assert.True(options.Name); + Assert.False(options.NamespaceMode); + Assert.Equal(2, options.Namespaces.Count); + Assert.Contains("storage", options.Namespaces); + Assert.Contains("keyvault", options.Namespaces); + } + + /// + /// Verifies that parsing the new options works correctly. + /// + [Fact] + public void CanParseNewOptions() + { + // Arrange & Act + var parseResult1 = _commandDefinition.Parse(["--name"]); + var parseResult2 = _commandDefinition.Parse(["--namespace", "storage"]); + var parseResult3 = _commandDefinition.Parse(["--name", "--namespace", "storage", "--namespace", "keyvault"]); + + // Assert + Assert.False(parseResult1.Errors.Any(), $"Parse errors for --name: {string.Join(", ", parseResult1.Errors)}"); + Assert.False(parseResult2.Errors.Any(), $"Parse errors for --namespace: {string.Join(", ", parseResult2.Errors)}"); + Assert.False(parseResult3.Errors.Any(), $"Parse errors for combined options: {string.Join(", ", parseResult3.Errors)}"); + + // Verify values + Assert.True(parseResult1.GetValueOrDefault(ToolsListOptionDefinitions.Name.Name)); + + var namespaces2 = parseResult2.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.NotNull(namespaces2); + Assert.Single(namespaces2); + Assert.Equal("storage", namespaces2[0]); + + var namespaces3 = parseResult3.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.NotNull(namespaces3); + Assert.Equal(2, namespaces3.Length); + Assert.Contains("storage", namespaces3); + Assert.Contains("keyvault", namespaces3); + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs deleted file mode 100644 index dd8b8357f..000000000 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListNamesCommandTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.CommandLine; -using System.CommandLine.Parsing; -using System.Net; -using System.Text.Json; -using Azure.Mcp.Core.Areas; -using Azure.Mcp.Core.Areas.Tools.Commands; -using Azure.Mcp.Core.Areas.Tools.Options; -using Azure.Mcp.Core.Commands; -using Azure.Mcp.Core.Extensions; -using Azure.Mcp.Core.Models.Command; -using Azure.Mcp.Core.Services.Telemetry; -using Azure.Mcp.Core.UnitTests.Areas.Server; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; - -namespace Azure.Mcp.Core.UnitTests.Areas.Tools.UnitTests; - -public class ToolsListNamesCommandTests -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly CommandContext _context; - private readonly ToolsListNamesCommand _command; - private readonly Command _commandDefinition; - - public ToolsListNamesCommandTests() - { - var collection = new ServiceCollection(); - collection.AddLogging(); - - var commandFactory = CommandFactoryHelpers.CreateCommandFactory(); - collection.AddSingleton(commandFactory); - - _serviceProvider = collection.BuildServiceProvider(); - _context = new(_serviceProvider); - _logger = Substitute.For>(); - _command = new(_logger); - _commandDefinition = _command.GetCommand(); - } - - [Fact] - public void Constructor_InitializesCommandCorrectly() - { - // Act & Assert - Assert.Equal("list-names", _command.Name); - Assert.Contains("List all available tool names", _command.Description); - Assert.Equal("List Tool Names", _command.Title); - Assert.False(_command.Metadata.Destructive); - Assert.True(_command.Metadata.ReadOnly); - Assert.True(_command.Metadata.Idempotent); - Assert.False(_command.Metadata.Secret); - } - - [Fact] - public void CanParseNamespaceOption() - { - // Arrange - var commandDefinition = _command.GetCommand(); - - // Act - var parseResult = commandDefinition.Parse(["--namespace", "storage"]); - - // Assert - Assert.NotNull(parseResult); - Assert.False(parseResult.Errors.Any(), $"Parse errors: {string.Join(", ", parseResult.Errors)}"); - - var namespaceValue = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); - Assert.Equal("storage", namespaceValue); - } - - [Fact] - public async Task ExecuteAsync_WithNamespaceOption_FiltersCorrectly() - { - // Arrange - var commandDefinition = _command.GetCommand(); - var parseResult = commandDefinition.Parse(["--namespace", "storage"]); - - // Act - var response = await _command.ExecuteAsync(_context, parseResult); - - // Assert - Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.Status); - // Note: We're not testing response.Results here since it's null in the test environment - // but we can verify the command executes without errors - } -} diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index e9b7eba10..dffcf0331 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -1001,7 +1001,7 @@ azmcp bestpractices get --resource --action azmcp tools list # List only the available top-level service namespaces -azmcp tools list --namespaces +azmcp tools list --namespace-mode ``` ### Azure Monitor Operations @@ -1580,7 +1580,7 @@ All responses follow a consistent JSON format: ### Tool and Namespace Result Objects -When invoking `azmcp tools list` (with or without `--namespaces`), each returned object now includes a `count` field: +When invoking `azmcp tools list` (with or without `--namespace-mode`), each returned object now includes a `count` field: | Field | Description | |-------|-------------| From 7459e1f1d01632492aad9aea2c7e90f2f3259888 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Thu, 9 Oct 2025 14:33:38 -0400 Subject: [PATCH 5/8] Update CHANGELOG --- servers/Azure.Mcp.Server/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index eab83355e..5207cd3d1 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -6,9 +6,14 @@ The Azure MCP Server updates automatically by default whenever a new release com ### Features Added +- Enhanced `azmcp tools list` command with new filtering and output options: + - Added `--namespace` option to filter tools by one or more service namespaces (e.g., 'storage', 'keyvault') + - Added `--name` option to return only tool names without descriptions or metadata + - Renamed `--namespaces` to `--namespace-mode` for better clarity when listing top-level service namespaces - Added `--tool` option to start Azure MCP server with only specific tools by name, providing fine-grained control over tool exposure. This option switches server mode to all automatically. The `--namespace` and `--tool` options cannot be used together. [[#685](https://github.com/microsoft/mcp/issues/685)] - Updated `ToolArea` telemetry field to be populated for namespace (and intent/learn) calls. [[#739](https://github.com/microsoft/mcp/pull/739)] + ### Breaking Changes - Unified required parameter validation: null or empty values now always throw `ArgumentException` with an improved message listing all invalid parameters. Previously this would throw either `ArgumentNullException` or `ArgumentException` for only the first invalid value. [[#718](https://github.com/microsoft/mcp/pull/718)] From 325dc3f217cefe140d4245e0ba9aac4b5174cd2a Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Thu, 9 Oct 2025 15:15:09 -0400 Subject: [PATCH 6/8] Create a helper function to reuse the namespace filtering logic --- .../Areas/Tools/Commands/ToolsListCommand.cs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs index 2a8ec2829..4ca723b88 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs @@ -73,12 +73,7 @@ public override async Task ExecuteAsync(CommandContext context, .Where(name => !string.IsNullOrEmpty(name)); // Apply namespace filtering if specified - if (options.Namespaces.Count > 0) - { - var namespacePrefixes = options.Namespaces.Select(ns => $"azmcp_{ns}_").ToList(); - allToolNames = allToolNames.Where(name => - namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); - } + allToolNames = ApplyNamespaceFilterToNames(allToolNames, options.Namespaces, CommandFactory.Separator); var toolNames = await Task.Run(() => allToolNames .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) @@ -138,12 +133,9 @@ public override async Task ExecuteAsync(CommandContext context, .Select(kvp => CreateCommand(kvp.Key, kvp.Value)); // Apply namespace filtering if specified - if (options.Namespaces.Count > 0) - { - var namespacePrefixes = options.Namespaces.Select(ns => $"azmcp {ns}").ToList(); - allTools = allTools.Where(tool => - namespacePrefixes.Any(prefix => tool.Command.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); - } + var filteredToolNames = ApplyNamespaceFilterToNames(allTools.Select(t => t.Command), options.Namespaces, ' '); + var filteredToolNamesSet = filteredToolNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + allTools = allTools.Where(tool => filteredToolNamesSet.Contains(tool.Command)); var tools = await Task.Run(() => allTools.ToList()); @@ -159,6 +151,18 @@ public override async Task ExecuteAsync(CommandContext context, } } + private static IEnumerable ApplyNamespaceFilterToNames(IEnumerable names, List namespaces, char separator) + { + if (namespaces.Count == 0) + { + return names; + } + + var namespacePrefixes = namespaces.Select(ns => $"azmcp{separator}{ns}{separator}").ToList(); + return names.Where(name => + namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); + } + private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand command) { var commandDetails = command.GetCommand(); From 785390b880f200fb7375b5b4794449ffe1892a55 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Thu, 9 Oct 2025 16:04:00 -0400 Subject: [PATCH 7/8] Fix format --- .../Areas/Tools/Commands/ToolsListCommand.cs | 2 +- .../Options/ToolsListOptionDefinitions.cs | 2 +- .../Areas/Tools/Options/ToolsListOptions.cs | 4 +-- .../Tools/UnitTests/ToolsListCommandTests.cs | 36 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs index 4ca723b88..48c04791c 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs @@ -159,7 +159,7 @@ private static IEnumerable ApplyNamespaceFilterToNames(IEnumerable $"azmcp{separator}{ns}{separator}").ToList(); - return names.Where(name => + return names.Where(name => namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); } diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs index 50cf127e3..705deb07c 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs @@ -21,7 +21,7 @@ public static class ToolsListOptionDefinitions Required = false, AllowMultipleArgumentsPerToken = true }; - + public static readonly Option Name = new($"--{NameOptionName}") { Description = "If specified, returns only tool names without descriptions or metadata.", diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs index f779b4c0c..e1b697b38 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs @@ -6,12 +6,12 @@ namespace Azure.Mcp.Core.Areas.Tools.Options; public sealed class ToolsListOptions { public bool NamespaceMode { get; set; } = false; - + /// /// If true, returns only tool names without descriptions or metadata. /// public bool Name { get; set; } = false; - + /// /// Optional namespaces to filter tools. If provided, only tools from these namespaces will be returned. /// diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs index c103320c2..67f9f94d3 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs @@ -464,7 +464,7 @@ public async Task ExecuteAsync_IncludesMetadataForAllCommands() public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() { // Arrange - var args = _commandDefinition.Parse(new[] { "--name" }); + var args = _commandDefinition.Parse(new[] { "--name" }); // Act var response = await _command.ExecuteAsync(_context, args); @@ -482,14 +482,14 @@ public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() // Validate that the response only contains Names field and no other fields var json = JsonSerializer.Serialize(response.Results); var jsonElement = JsonSerializer.Deserialize(json); - + // Verify that only the "names" property exists Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); - + // Count the number of properties - should only be 1 (names) var propertyCount = jsonElement.EnumerateObject().Count(); Assert.Equal(1, propertyCount); - + // Explicitly verify that description and command fields are not present Assert.False(jsonElement.TryGetProperty("description", out _), "Response should not contain 'description' property when using --name option"); Assert.False(jsonElement.TryGetProperty("command", out _), "Response should not contain 'command' property when using --name option"); @@ -517,7 +517,7 @@ public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() public async Task ExecuteAsync_WithSingleNamespaceOption_FiltersCorrectly() { // Arrange - var args = _commandDefinition.Parse(new[] { "--namespace", "storage" }); + var args = _commandDefinition.Parse(new[] { "--namespace", "storage" }); // Act var response = await _command.ExecuteAsync(_context, args); @@ -548,7 +548,7 @@ public async Task ExecuteAsync_WithSingleNamespaceOption_FiltersCorrectly() public async Task ExecuteAsync_WithMultipleNamespaceOptions_FiltersCorrectly() { // Arrange - var args = _commandDefinition.Parse(new[] { "--namespace", "storage", "--namespace", "keyvault" }); + var args = _commandDefinition.Parse(new[] { "--namespace", "storage", "--namespace", "keyvault" }); // Act var response = await _command.ExecuteAsync(_context, args); @@ -567,7 +567,7 @@ public async Task ExecuteAsync_WithMultipleNamespaceOptions_FiltersCorrectly() { var isStorageCommand = command.Command.StartsWith("azmcp storage"); var isKeyvaultCommand = command.Command.StartsWith("azmcp keyvault"); - Assert.True(isStorageCommand || isKeyvaultCommand, + Assert.True(isStorageCommand || isKeyvaultCommand, $"Command '{command.Command}' should be from storage or keyvault namespace"); } @@ -583,7 +583,7 @@ public async Task ExecuteAsync_WithMultipleNamespaceOptions_FiltersCorrectly() public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsNamesOnly() { // Arrange - var args = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage" }); + var args = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage" }); // Act var response = await _command.ExecuteAsync(_context, args); @@ -601,10 +601,10 @@ public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsName // Validate that the response only contains Names field and no other fields var json = JsonSerializer.Serialize(response.Results); var jsonElement = JsonSerializer.Deserialize(json); - + // Verify that only the "names" property exists Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); - + // Count the number of properties - should only be 1 (names) var propertyCount = jsonElement.EnumerateObject().Count(); Assert.Equal(1, propertyCount); @@ -626,7 +626,7 @@ public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsName public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndReturnsNamesOnly() { // Arrange - var args = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage", "--namespace", "keyvault" }); + var args = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage", "--namespace", "keyvault" }); // Act var response = await _command.ExecuteAsync(_context, args); @@ -644,10 +644,10 @@ public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndRet // Validate that the response only contains Names field and no other fields var json = JsonSerializer.Serialize(response.Results); var jsonElement = JsonSerializer.Deserialize(json); - + // Verify that only the "names" property exists Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); - + // Count the number of properties - should only be 1 (names) var propertyCount = jsonElement.EnumerateObject().Count(); Assert.Equal(1, propertyCount); @@ -657,7 +657,7 @@ public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndRet { var isStorageName = name.StartsWith("azmcp_storage_"); var isKeyvaultName = name.StartsWith("azmcp_keyvault_"); - Assert.True(isStorageName || isKeyvaultName, + Assert.True(isStorageName || isKeyvaultName, $"Tool name '{name}' should be from storage or keyvault namespace"); } @@ -673,15 +673,15 @@ public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndRet public void BindOptions_WithNewOptions_BindsCorrectly() { // Arrange - var parseResult = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage", "--namespace", "keyvault" }); + var parseResult = _commandDefinition.Parse(new[] { "--name", "--namespace", "storage", "--namespace", "keyvault" }); // Use reflection to call the protected BindOptions method - var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions", + var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); Assert.NotNull(bindOptionsMethod); // Act - var options = bindOptionsMethod.Invoke(_command, new object?[] { parseResult }) as ToolsListOptions; + var options = bindOptionsMethod.Invoke(_command, new object?[] { parseResult }) as ToolsListOptions; // Assert Assert.NotNull(options); @@ -710,7 +710,7 @@ public void CanParseNewOptions() // Verify values Assert.True(parseResult1.GetValueOrDefault(ToolsListOptionDefinitions.Name.Name)); - + var namespaces2 = parseResult2.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); Assert.NotNull(namespaces2); Assert.Single(namespaces2); From f54b5d810297c33fef24c1d39696c525a5b91a9f Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Tue, 14 Oct 2025 11:27:27 -0400 Subject: [PATCH 8/8] Fix test failure --- .../Areas/Tools/UnitTests/ToolsListCommandTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs index 67f9f94d3..2bb5004ae 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs @@ -60,7 +60,11 @@ private static List DeserializeResults(object results) private static ToolsListCommand.ToolNamesResult DeserializeToolNamesResult(object results) { var json = JsonSerializer.Serialize(results); - return JsonSerializer.Deserialize(json) ?? new ToolsListCommand.ToolNamesResult(new List()); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + return JsonSerializer.Deserialize(json, options) ?? new ToolsListCommand.ToolNamesResult(new List()); } ///