Skip to content

Commit f22d0f6

Browse files
Add a command listing all the tool names (#741)
* Add a command listing all tool names * Add --namespace option to scope it down to a specific namespace * Fix format * Add --name switch to tool list command to only return tool names * Update CHANGELOG * Create a helper function to reuse the namespace filtering logic * Fix format * Fix test failure * remove azmcp prefix * Fix format * Update var names * Update core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs Co-authored-by: vcolin7 <vicolina@microsoft.com> * Address review feedbacks * fix format * Address more review feedbacks --------- Co-authored-by: vcolin7 <vicolina@microsoft.com>
1 parent 61185a0 commit f22d0f6

File tree

7 files changed

+547
-17
lines changed

7 files changed

+547
-17
lines changed

core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.CommandLine.Parsing;
45
using System.Runtime.InteropServices;
56
using Azure.Mcp.Core.Areas.Tools.Options;
67
using Azure.Mcp.Core.Commands;
8+
using Azure.Mcp.Core.Extensions;
9+
using Azure.Mcp.Core.Models;
10+
using Azure.Mcp.Core.Models.Command;
711
using Azure.Mcp.Core.Models.Option;
812
using Microsoft.AspNetCore.Mvc.ModelBinding;
913
using Microsoft.Extensions.Logging;
@@ -23,7 +27,7 @@ public sealed class ToolsListCommand(ILogger<ToolsListCommand> logger) : BaseCom
2327
"""
2428
List all available commands and their tools in a hierarchical structure. This command returns detailed information
2529
about each command, including its name, description, full command path, available subcommands, and all supported
26-
arguments. Use this to explore the CLI's functionality or to build interactive command interfaces.
30+
arguments. Use --name-only to return only tool names, and --namespace to filter by specific namespaces.
2731
""";
2832

2933
public override string Title => CommandTitle;
@@ -41,14 +45,19 @@ arguments. Use this to explore the CLI's functionality or to build interactive c
4145
protected override void RegisterOptions(Command command)
4246
{
4347
base.RegisterOptions(command);
44-
command.Options.Add(ToolsListOptionDefinitions.Namespaces);
48+
command.Options.Add(ToolsListOptionDefinitions.NamespaceMode);
49+
command.Options.Add(ToolsListOptionDefinitions.Namespace);
50+
command.Options.Add(ToolsListOptionDefinitions.NameOnly);
4551
}
4652

4753
protected override ToolsListOptions BindOptions(ParseResult parseResult)
4854
{
55+
var namespaces = parseResult.GetValueOrDefault<string[]>(ToolsListOptionDefinitions.Namespace.Name) ?? [];
4956
return new ToolsListOptions
5057
{
51-
Namespaces = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespaces)
58+
NamespaceMode = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NamespaceMode),
59+
NameOnly = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly),
60+
Namespaces = namespaces.ToList()
5261
};
5362
}
5463

@@ -59,15 +68,17 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
5968
var factory = context.GetService<CommandFactory>();
6069
var options = BindOptions(parseResult);
6170

62-
// If the --namespaces flag is set, return distinct top‑level namespaces (child groups beneath root 'azmcp').
63-
if (options.Namespaces)
71+
// If the --namespace-mode flag is set, return distinct top‑level namespaces (e.g. child groups beneath root 'azmcp').
72+
if (options.NamespaceMode)
6473
{
6574
var ignored = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "server", "tools" };
6675
var surfaced = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "extension" };
6776
var rootGroup = factory.RootGroup; // azmcp
6877

6978
var namespaceCommands = rootGroup.SubGroup
7079
.Where(g => !ignored.Contains(g.Name) && !surfaced.Contains(g.Name))
80+
// Apply namespace filtering if specified
81+
.Where(g => options.Namespaces.Count == 0 || options.Namespaces.Contains(g.Name, StringComparer.OrdinalIgnoreCase))
7182
.Select(g => new CommandInfo
7283
{
7384
Name = g.Name,
@@ -82,6 +93,10 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
8293
// For commands in the surfaced list, each command is exposed as a separate tool in the namespace mode.
8394
foreach (var name in surfaced)
8495
{
96+
// Apply namespace filtering for surfaced commands too
97+
if (options.Namespaces.Count > 0 && !options.Namespaces.Contains(name, StringComparer.OrdinalIgnoreCase))
98+
continue;
99+
85100
var subgroup = rootGroup.SubGroup.FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase));
86101
if (subgroup is not null)
87102
{
@@ -91,13 +106,49 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
91106
}
92107
}
93108

109+
// If --name-only is also specified, return only the names
110+
if (options.NameOnly)
111+
{
112+
var namespaceNames = namespaceCommands.Select(nc => nc.Command).ToList();
113+
var result = new ToolNamesResult(namespaceNames);
114+
context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult);
115+
return context.Response;
116+
}
117+
94118
context.Response.Results = ResponseResult.Create(namespaceCommands, ModelsJsonContext.Default.ListCommandInfo);
95119
return context.Response;
96120
}
97121

98-
var tools = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands)
99-
.Select(kvp => CreateCommand(kvp.Key, kvp.Value))
100-
.ToList());
122+
// If the --name-only flag is set (without namespace mode), return only tool names
123+
if (options.NameOnly)
124+
{
125+
// Get all visible commands and extract their tokenized names (full command paths)
126+
var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands)
127+
.Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name
128+
.Where(name => !string.IsNullOrEmpty(name));
129+
130+
// Apply namespace filtering if specified (using underscore separator for tokenized names)
131+
allToolNames = ApplyNamespaceFilterToNames(allToolNames, options.Namespaces, CommandFactory.Separator);
132+
133+
var toolNames = await Task.Run(() => allToolNames
134+
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
135+
.ToList());
136+
137+
var result = new ToolNamesResult(toolNames);
138+
context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult);
139+
return context.Response;
140+
}
141+
142+
// Get all tools with full details
143+
var allTools = CommandFactory.GetVisibleCommands(factory.AllCommands)
144+
.Select(kvp => CreateCommand(kvp.Key, kvp.Value));
145+
146+
// Apply namespace filtering if specified
147+
var filteredToolNames = ApplyNamespaceFilterToNames(allTools.Select(t => t.Command), options.Namespaces, ' ');
148+
var filteredToolNamesSet = filteredToolNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
149+
allTools = allTools.Where(tool => filteredToolNamesSet.Contains(tool.Command));
150+
151+
var tools = await Task.Run(() => allTools.ToList());
101152

102153
context.Response.Results = ResponseResult.Create(tools, ModelsJsonContext.Default.ListCommandInfo);
103154
return context.Response;
@@ -111,6 +162,19 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
111162
}
112163
}
113164

165+
private static IEnumerable<string> ApplyNamespaceFilterToNames(IEnumerable<string> names, List<string> namespaces, char separator)
166+
{
167+
if (namespaces.Count == 0)
168+
{
169+
return names;
170+
}
171+
172+
var namespacePrefixes = namespaces.Select(ns => $"{ns}{separator}").ToList();
173+
174+
return names.Where(name =>
175+
namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)));
176+
}
177+
114178
private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand command)
115179
{
116180
var commandDetails = command.GetCommand();
@@ -122,17 +186,20 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm
122186
required: arg.Required))
123187
.ToList();
124188

189+
var fullCommand = tokenizedName.Replace(CommandFactory.Separator, ' ');
190+
125191
return new CommandInfo
126192
{
127193
Id = command.Id,
128194
Name = commandDetails.Name,
129195
Description = commandDetails.Description ?? string.Empty,
130-
Command = tokenizedName.Replace(CommandFactory.Separator, ' '),
196+
Command = fullCommand,
131197
Options = optionInfos,
132198
Metadata = command.Metadata
133199
};
134200
}
135201

202+
public record ToolNamesResult(List<string> Names);
136203
private void searchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List<CommandInfo> foundCommands)
137204
{
138205
var commands = CommandFactory.GetVisibleCommands(searchedGroup.Commands).Select(kvp =>

core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,26 @@ namespace Azure.Mcp.Core.Areas.Tools.Options;
55

66
public static class ToolsListOptionDefinitions
77
{
8-
public const string NamespacesOptionName = "namespaces";
8+
public const string NamespaceModeOptionName = "namespace-mode";
9+
public const string NamespaceOptionName = "namespace";
10+
public const string NameOnlyOptionName = "name-only";
911

10-
public static readonly Option<bool> Namespaces = new($"--{NamespacesOptionName}")
12+
public static readonly Option<bool> NamespaceMode = new($"--{NamespaceModeOptionName}")
1113
{
1214
Description = "If specified, returns a list of top-level service namespaces instead of individual tools.",
1315
Required = false
1416
};
17+
18+
public static readonly Option<string[]> Namespace = new($"--{NamespaceOptionName}")
19+
{
20+
Description = "Filter tools by namespace (e.g., 'storage', 'keyvault'). Can be specified multiple times to include multiple namespaces.",
21+
Required = false,
22+
AllowMultipleArgumentsPerToken = true
23+
};
24+
25+
public static readonly Option<bool> NameOnly = new($"--{NameOnlyOptionName}")
26+
{
27+
Description = "If specified, returns only tool names without descriptions or metadata.",
28+
Required = false
29+
};
1530
}

core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,15 @@ namespace Azure.Mcp.Core.Areas.Tools.Options;
55

66
public sealed class ToolsListOptions
77
{
8-
public bool Namespaces { get; set; } = false;
8+
public bool NamespaceMode { get; set; } = false;
9+
10+
/// <summary>
11+
/// If true, returns only tool names without descriptions or metadata.
12+
/// </summary>
13+
public bool NameOnly { get; set; } = false;
14+
15+
/// <summary>
16+
/// Optional namespaces to filter tools. If provided, only tools from these namespaces will be returned.
17+
/// </summary>
18+
public List<string> Namespaces { get; set; } = new();
919
}

core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using System.Text.Json.Serialization;
5+
using Azure.Mcp.Core.Areas.Tools.Commands;
56
using Azure.Mcp.Core.Commands;
67
using Azure.Mcp.Core.Models.Elicitation;
78

@@ -13,6 +14,7 @@ namespace Azure.Mcp.Core.Models;
1314
[JsonSerializable(typeof(ElicitationSchemaRoot))]
1415
[JsonSerializable(typeof(ElicitationSchemaProperty))]
1516
[JsonSerializable(typeof(ToolMetadata))]
17+
[JsonSerializable(typeof(ToolsListCommand.ToolNamesResult))]
1618
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
1719
public sealed partial class ModelsJsonContext : JsonSerializerContext
1820
{

0 commit comments

Comments
 (0)