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 beb22d8e0..03a9ad4bc 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs @@ -1,9 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine.Parsing; using System.Runtime.InteropServices; 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.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; @@ -23,7 +27,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-only to return only tool names, and --namespace to filter by specific namespaces. """; public override string Title => CommandTitle; @@ -41,14 +45,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.NameOnly); } 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), + NameOnly = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly), + Namespaces = namespaces.ToList() }; } @@ -59,8 +68,8 @@ 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 --namespace-mode flag is set, return distinct top‑level namespaces (e.g. child groups beneath root 'azmcp'). + if (options.NamespaceMode) { var ignored = new HashSet(StringComparer.OrdinalIgnoreCase) { "server", "tools" }; var surfaced = new HashSet(StringComparer.OrdinalIgnoreCase) { "extension" }; @@ -68,6 +77,8 @@ public override async Task ExecuteAsync(CommandContext context, var namespaceCommands = rootGroup.SubGroup .Where(g => !ignored.Contains(g.Name) && !surfaced.Contains(g.Name)) + // Apply namespace filtering if specified + .Where(g => options.Namespaces.Count == 0 || options.Namespaces.Contains(g.Name, StringComparer.OrdinalIgnoreCase)) .Select(g => new CommandInfo { Name = g.Name, @@ -82,6 +93,10 @@ public override async Task ExecuteAsync(CommandContext context, // For commands in the surfaced list, each command is exposed as a separate tool in the namespace mode. foreach (var name in surfaced) { + // Apply namespace filtering for surfaced commands too + if (options.Namespaces.Count > 0 && !options.Namespaces.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + var subgroup = rootGroup.SubGroup.FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase)); if (subgroup is not null) { @@ -91,13 +106,49 @@ public override async Task ExecuteAsync(CommandContext context, } } + // If --name-only is also specified, return only the names + if (options.NameOnly) + { + var namespaceNames = namespaceCommands.Select(nc => nc.Command).ToList(); + var result = new ToolNamesResult(namespaceNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + context.Response.Results = ResponseResult.Create(namespaceCommands, ModelsJsonContext.Default.ListCommandInfo); return context.Response; } - var tools = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands) - .Select(kvp => CreateCommand(kvp.Key, kvp.Value)) - .ToList()); + // If the --name-only flag is set (without namespace mode), return only tool names + if (options.NameOnly) + { + // 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 (using underscore separator for tokenized names) + allToolNames = ApplyNamespaceFilterToNames(allToolNames, options.Namespaces, CommandFactory.Separator); + + 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; + } + + // Get all tools with full details + var allTools = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => CreateCommand(kvp.Key, kvp.Value)); + + // Apply namespace filtering if specified + 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()); context.Response.Results = ResponseResult.Create(tools, ModelsJsonContext.Default.ListCommandInfo); return context.Response; @@ -111,6 +162,19 @@ 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 => $"{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(); @@ -122,17 +186,20 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm required: arg.Required)) .ToList(); + var fullCommand = tokenizedName.Replace(CommandFactory.Separator, ' '); + return new CommandInfo { Id = command.Id, Name = commandDetails.Name, Description = commandDetails.Description ?? string.Empty, - Command = tokenizedName.Replace(CommandFactory.Separator, ' '), + Command = fullCommand, Options = optionInfos, Metadata = command.Metadata }; } + public record ToolNamesResult(List Names); private void searchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List foundCommands) { var commands = CommandFactory.GetVisibleCommands(searchedGroup.Commands).Select(kvp => 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..845250512 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs @@ -5,11 +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 NameOnlyOptionName = "name-only"; - 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}") + { + 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 NameOnly = new($"--{NameOnlyOptionName}") + { + 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..db268bd9f 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 NameOnly { 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/Models/ModelsJsonContext.cs b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs index ad710c31e..54de67261 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(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 b607fb7a8..ccbad378d 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,19 @@ 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); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + return JsonSerializer.Deserialize(json, options) ?? 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 +327,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, TestContext.Current.CancellationToken); @@ -458,4 +474,388 @@ public async Task ExecuteAsync_IncludesMetadataForAllCommands() Assert.True(metadata.LocalRequired || !metadata.LocalRequired, "LocalRequired should be defined"); } } + + /// + /// Verifies that the --name-only option returns only tool names without descriptions. + /// + [Fact] + public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // 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-only option"); + Assert.False(jsonElement.TryGetProperty("command", out _), "Response should not contain 'command' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("options", out _), "Response should not contain 'options' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("metadata", out _), "Response should not contain 'metadata' property when using --name-only 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.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, TestContext.Current.CancellationToken); + + // 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("storage", command.Command); + } + + // Should contain some well-known storage commands + Assert.Contains(result, cmd => cmd.Command == "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, TestContext.Current.CancellationToken); + + // 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("storage"); + var isKeyvaultCommand = command.Command.StartsWith("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("storage")); + Assert.Contains(result, cmd => cmd.Command.StartsWith("keyvault")); + } + + /// + /// Verifies that --name-only and --namespace options work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // 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("storage_", name); + } + + // Should contain some well-known storage commands + Assert.Contains(result.Names, name => name.Contains("account_get")); + } + + /// + /// Verifies that --name-only with multiple --namespace options works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // 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("storage_"); + var isKeyvaultName = name.StartsWith("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("storage_")); + Assert.Contains(result.Names, name => name.StartsWith("keyvault_")); + } + + /// + /// Verifies that option binding works correctly for the new options. + /// + [Fact] + public void BindOptions_WithNewOptions_BindsCorrectly() + { + // Arrange + var parseResult = _commandDefinition.Parse(new[] { "--name-only", "--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.NameOnly); + 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-only"]); + var parseResult2 = _commandDefinition.Parse(["--namespace", "storage"]); + var parseResult3 = _commandDefinition.Parse(["--name-only", "--namespace", "storage", "--namespace", "keyvault"]); + + // Assert + Assert.False(parseResult1.Errors.Any(), $"Parse errors for --name-only: {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.NameOnly.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); + } + + /// + /// Verifies that --namespace-mode and --name-only work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeAndNameOnly_ReturnsNamespaceNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--name-only" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // 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); + + // Should contain only namespace names (not individual commands) + Assert.Contains(result.Names, name => name.Equals("subscription", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Names, name => name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Names, name => name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies that --namespace-mode, --name-only, and --namespace filtering work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeNameOnlyAndNamespaceFilter_ReturnsFilteredNamespaceNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--name-only", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // 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); + + // Should contain only storage namespace (and possibly surfaced storage-related commands) + foreach (var name in result.Names) + { + Assert.True(name.Equals("storage", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("storage ", StringComparison.OrdinalIgnoreCase), + $"Name '{name}' should be from storage namespace"); + } + } + + /// + /// Verifies that --namespace-mode with multiple namespace filters works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeAndMultipleNamespaces_ReturnsFilteredNamespaces() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // 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); + + // Should contain only storage and keyvault namespaces + foreach (var command in result) + { + var isStorageNamespace = command.Name.Equals("storage", StringComparison.OrdinalIgnoreCase); + var isKeyvaultNamespace = command.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase); + var isStorageCommand = command.Command.StartsWith("storage ", StringComparison.OrdinalIgnoreCase); + var isKeyvaultCommand = command.Command.StartsWith("keyvault ", StringComparison.OrdinalIgnoreCase); + + Assert.True(isStorageNamespace || isKeyvaultNamespace || isStorageCommand || isKeyvaultCommand, + $"Command '{command.Command}' (Name: '{command.Name}') should be from storage or keyvault namespace"); + } + + // Should contain both namespaces + Assert.Contains(result, cmd => cmd.Name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result, cmd => cmd.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 26345d802..4d6c03c62 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -10,11 +10,18 @@ The Azure MCP Server updates automatically by default whenever a new release com - Added the following features for deploying as a `Remote MCP Server`: - Added support for HTTP transport, including both incoming and outgoing authentication. Incoming authentication uses Entra ID, while outgoing authentication can either use Entra On-Behalf-Of (OBO) or the authentication configured in the host environment. [[#1020](https://github.com/microsoft/mcp/pull/1020)] - Added support for the `--dangerously-disable-http-incoming-auth` command-line option to disable the built-in incoming authentication. Use this option only if you plan to provide your own incoming authentication mechanism, and with caution, as it exposes the server to unauthenticated access. [[#1037](https://github.com/microsoft/mcp/pull/1037)] - +- Enhanced `azmcp tools list` command with new filtering and output options: [[#741](https://github.com/microsoft/mcp/pull/741)] + - Added `--namespace` option to filter tools by one or more service namespaces (e.g., 'storage', 'keyvault') + - Added `--name-only` option to return only tool names without descriptions or metadata +- Add support for User-Assigned Managed Identity via `AZURE_CLIENT_ID` environment variable [[#1030](https://github.com/microsoft/mcp/issues/1030)] +- Adds support for HTTP transport, including both incoming and outgoing authentication. Incoming authentication uses Entra ID, while outgoing authentication can either use Entra On-Behalf-Of (OBO) or the authentication configured in the host environment. [[1020](https://github.com/microsoft/mcp/pull/1020)] +- Adds support for the `--dangerously-disable-http-incoming-auth` command-line option to disable the built-in incoming authentication. Use this option only if you plan to provide your own incoming authentication mechanism, and with caution, as it exposes the server to unauthenticated access [[1037](https://github.com/microsoft/mcp/pull/1037)]. - Added `foundry_agents_create`, `foundry_agents_get-sdk-sample`, `foundry_thread_create`, `foundry_thread_list`, `foundry_thread_get-messages` tools for AI Foundry scenarios. [[#945](https://github.com/microsoft/mcp/pull/945)] ### Breaking Changes +- Renamed `azmcp tools list` command `--namespaces` switch to `--namespace-mode` for better clarity when listing top-level service namespaces [[#741](https://github.com/microsoft/mcp/pull/741)] + ### Bugs Fixed - Avoid spawning child processes per namespace for consolidated mode [[#1002](https://github.com/microsoft/mcp/pull/1002)] diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index b2187425c..32b8e2631 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -1346,12 +1346,41 @@ azmcp get bestpractices get --resource --action ### Azure MCP Tools +The `azmcp tools list` command provides flexible ways to explore and discover available tools in the Azure MCP server. It supports multiple modes and filtering options that can be combined for precise control over the output format and content. + +**Available Options:** +- `--namespace-mode`: List only top-level service namespaces instead of individual tools +- `--name-only`: Return only tool/namespace names without descriptions, options, or metadata +- `--namespace `: Filter results to specific namespace(s). Can be used multiple times to include multiple namespaces + +**Option Combinations:** +- Use `--name-only` alone to get a simple list of all tool names +- Use `--namespace-mode` alone to see available service namespaces with full details +- Combine `--namespace-mode` and `--name-only` to get just the namespace names +- Use `--namespace` with any other option to filter results to specific services +- All options can be combined for maximum flexibility + ```bash # List all available tools in the Azure MCP server azmcp tools list # List only the available top-level service namespaces -azmcp tools list --namespaces +azmcp tools list --namespace-mode + +# List only tool names without descriptions or metadata +azmcp tools list --name-only + +# Filter tools by specific namespace(s) +azmcp tools list --namespace storage +azmcp tools list --namespace storage --namespace keyvault + +# Combine options: get namespace names only for specific namespaces +azmcp tools list --namespace-mode --name-only +azmcp tools list --namespace-mode --name-only --namespace storage + +# Combine options: get tool names only for specific namespace(s) +azmcp tools list --name-only --namespace storage +azmcp tools list --name-only --namespace storage --namespace keyvault ``` ### Azure Monitor Operations @@ -2065,7 +2094,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 | |-------|-------------|