-
Notifications
You must be signed in to change notification settings - Fork 205
Add --namespaces
switch to azmcp tools list
and update ToolDescriptionEvaluator to work with namespaces/consolidated modes
#496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
773f7d9
69103a3
d2a676f
ce3868c
62bfb5c
fab5a04
da06e1d
8ea32cd
6c2d31a
8425d4a
44ad7f4
85ed09d
c564fec
bf2fd73
21e95f7
28998e1
a4a17ab
27b9e5b
15e2232
2211b55
109c8cb
5ee4d30
77d48b7
3375f15
c0d8acc
477fae7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
// Copyright (c) Microsoft Corporation. | ||
// Licensed under the MIT License. | ||
|
||
using System.CommandLine; | ||
using Azure.Mcp.Core.Commands; | ||
using Azure.Mcp.Core.Models.Option; | ||
using Microsoft.Extensions.Logging; | ||
|
@@ -11,6 +12,10 @@ namespace Azure.Mcp.Core.Areas.Tools.Commands; | |
public sealed class ToolsListCommand(ILogger<ToolsListCommand> logger) : BaseCommand<EmptyOptions> | ||
{ | ||
private const string CommandTitle = "List Available Tools"; | ||
private static readonly Option<bool> NamespacesOption = new("--namespaces") | ||
{ | ||
Description = "If specified, returns a list of top-level service namespaces instead of individual commands.", | ||
}; | ||
|
||
public override string Name => "list"; | ||
|
||
|
@@ -33,23 +38,124 @@ arguments. Use this to explore the CLI's functionality or to build interactive c | |
Secret = false | ||
}; | ||
|
||
protected override void RegisterOptions(Command command) | ||
{ | ||
command.Options.Add(NamespacesOption); | ||
} | ||
|
||
protected override EmptyOptions BindOptions(ParseResult parseResult) => new(); | ||
|
||
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult) | ||
{ | ||
try | ||
{ | ||
var factory = context.GetService<CommandFactory>(); | ||
|
||
// If the --namespaces flag set, return distinct top-level namespaces (area group names beneath root 'azmcp') | ||
var namespacesOnly = parseResult.CommandResult.HasOptionResult(NamespacesOption); | ||
if (namespacesOnly) | ||
{ | ||
// Exclude internal or special namespaces. 'extension' is flattened as top-level leaf commands. | ||
var ignored = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "server", "tools" }; | ||
var rootGroup = factory.RootGroup; // azmcp | ||
|
||
// Build lookup for namespace descriptions from the existing registered groups | ||
var namespaceDescriptionMap = rootGroup.SubGroup.ToDictionary(commandGroup => commandGroup.Name, g => g.Description, StringComparer.OrdinalIgnoreCase); | ||
|
||
// Single pass over all visible commands to bucket by namespace | ||
var namespaceBuckets = new Dictionary<string, List<CommandInfo>>(StringComparer.OrdinalIgnoreCase); | ||
var extensionLeafCommands = new List<CommandInfo>(); | ||
|
||
foreach (var kvp in CommandFactory.GetVisibleCommands(factory.AllCommands)) | ||
{ | ||
var key = kvp.Key; // Tokenized e.g. azmcp_storage_account_get | ||
var firstSeparatorIndex = key.IndexOf(CommandFactory.Separator); // Expect at least root + namespace + verb | ||
|
||
if (firstSeparatorIndex < 0) | ||
continue; // Malformed, skip | ||
|
||
var secondSeparatorIndex = key.IndexOf(CommandFactory.Separator, firstSeparatorIndex + 1); | ||
|
||
if (secondSeparatorIndex < 0) | ||
continue; // Not enough tokens | ||
Comment on lines
+71
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel that so much of this logic could be completely removed if we changed how There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should get all the matching namespaces. Please simplify the logic here. |
||
|
||
var namespaceToken = key.Substring(firstSeparatorIndex + 1, secondSeparatorIndex - firstSeparatorIndex - 1); | ||
|
||
if (ignored.Contains(namespaceToken)) | ||
{ | ||
// Skip internal groups entirely | ||
continue; | ||
} | ||
|
||
var cmdInfo = CreateCommand(key, kvp.Value); | ||
|
||
if (namespaceToken.Equals("extension", StringComparison.OrdinalIgnoreCase)) | ||
{ | ||
// Flatten: treat each extension command as top-level leaf | ||
extensionLeafCommands.Add(cmdInfo); | ||
|
||
continue; | ||
} | ||
|
||
if (!namespaceBuckets.TryGetValue(namespaceToken, out var list)) | ||
{ | ||
list = new List<CommandInfo>(); | ||
namespaceBuckets[namespaceToken] = list; | ||
} | ||
|
||
list.Add(cmdInfo); | ||
} | ||
|
||
// Build namespace CommandInfo objects | ||
var namespaceCommands = namespaceBuckets | ||
.Select(kvp => | ||
{ | ||
var namespaceName = kvp.Key; | ||
var subcommands = kvp.Value | ||
.OrderBy(ci => ci.Command, StringComparer.OrdinalIgnoreCase) | ||
.ToList(); | ||
namespaceDescriptionMap.TryGetValue(namespaceName, out var desc); | ||
return new CommandInfo | ||
{ | ||
Name = namespaceName, | ||
Description = desc ?? string.Empty, | ||
Command = $"azmcp {namespaceName}", | ||
Subcommands = subcommands, | ||
Options = null, | ||
SubcommandsCount = subcommands.Count | ||
}; | ||
}) | ||
.OrderBy(ci => ci.Name, StringComparer.OrdinalIgnoreCase) | ||
.ToList(); | ||
|
||
// Order extension leaf commands | ||
extensionLeafCommands = extensionLeafCommands | ||
.OrderBy(ci => ci.Command, StringComparer.OrdinalIgnoreCase) | ||
.ToList(); | ||
|
||
// Combine and sort: namespaces first, then extension leaves by Name | ||
namespaceCommands.AddRange(extensionLeafCommands); | ||
namespaceCommands = namespaceCommands | ||
.OrderByDescending(ci => ci.SubcommandsCount > 0) | ||
.ThenBy(ci => ci.Name, StringComparer.OrdinalIgnoreCase) | ||
.ToList(); | ||
|
||
context.Response.Results = ResponseResult.Create(namespaceCommands, ModelsJsonContext.Default.ListCommandInfo); | ||
context.Response.ResultsCount = namespaceCommands.Count; | ||
return context.Response; | ||
} | ||
|
||
var tools = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands) | ||
.Select(kvp => CreateCommand(kvp.Key, kvp.Value)) | ||
.ToList()); | ||
|
||
context.Response.Results = ResponseResult.Create(tools, ModelsJsonContext.Default.ListCommandInfo); | ||
context.Response.ResultsCount = tools.Count; | ||
return context.Response; | ||
} | ||
catch (Exception ex) | ||
{ | ||
logger.LogError(ex, "An exception occurred processing tool."); | ||
logger.LogError(ex, "An exception occurred while processing tool listing."); | ||
HandleException(context, ex); | ||
|
||
return context.Response; | ||
|
@@ -73,6 +179,8 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm | |
Description = commandDetails.Description ?? string.Empty, | ||
Command = tokenizedName.Replace(CommandFactory.Separator, ' '), | ||
Options = optionInfos, | ||
// Leaf commands have no subcommands. | ||
SubcommandsCount = 0, | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -869,6 +869,9 @@ azmcp bestpractices get --resource <resource> --action <action> | |||||
```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 | ||||||
``` | ||||||
|
||||||
### Azure Monitor Operations | ||||||
|
@@ -1397,6 +1400,21 @@ All responses follow a consistent JSON format: | |||||
} | ||||||
``` | ||||||
|
||||||
### Tool & Namespace Result Objects | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
To follow writing guidelines. |
||||||
|
||||||
When invoking `azmcp tools list` (with or without `--namespaces`), each returned object now includes a `count` field: | ||||||
|
||||||
| Field | Description | | ||||||
|-------|-------------| | ||||||
| `name` | Command or namespace name | | ||||||
| `description` | Human-readable description | | ||||||
| `command` | Fully qualified CLI invocation path | | ||||||
| `subcommands` | (Namespaces only) Array of leaf command objects | | ||||||
| `option` | (Leaf commands only) Array of options supported by the command | | ||||||
| `count` | Namespaces: number of subcommands; Leaf commands: always 0 (options not counted) | | ||||||
|
||||||
This quantitative field enables quick sizing of a namespace without traversing nested arrays. Leaf command complexity should be inferred from its option list, not the `count` field. | ||||||
|
||||||
## Error Handling | ||||||
|
||||||
The CLI returns structured JSON responses for errors, including: | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even though this is a core tool, I don't think we should break design patterns that are expected to be followed by Azure tools.