Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
namespace Azure.Mcp.Core.Areas.Group.Commands;

[JsonSerializable(typeof(GroupListCommand.Result))]
[JsonSerializable(typeof(GroupResourceListCommand.GroupResourceListCommandResult))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class GroupJsonContext : JsonSerializerContext
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Areas.Group.Options;
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Extensions;
using Azure.Mcp.Core.Models.Option;
using Azure.Mcp.Core.Models.Resource;
using Azure.Mcp.Core.Services.Azure.ResourceGroup;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Models.Command;
using Microsoft.Mcp.Core.Models.Option;

namespace Azure.Mcp.Core.Areas.Group.Commands;

public sealed class GroupResourceListCommand(ILogger<GroupResourceListCommand> logger) : SubscriptionCommand<ResourceListOptions>()
{
private const string CommandTitle = "List Resources in Resource Group";
private readonly ILogger<GroupResourceListCommand> _logger = logger;

public override string Id => "b1c2d3e4-f5a6-7890-abcd-ef1234567890";

public override string Name => "list";

public override string Description =>
$"""
List all resources in a resource group. This command retrieves all resources available
in the specified {OptionDefinitions.Common.ResourceGroupName} within the given
{OptionDefinitions.Common.SubscriptionName}. Results include resource names, IDs, types,
and locations. The command returns a JSON object with a `resources` array containing these entries.
""";

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(OptionDefinitions.Common.ResourceGroup.AsRequired());
}

protected override ResourceListOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.ResourceGroup ??= parseResult.GetValueOrDefault<string>(OptionDefinitions.Common.ResourceGroup.Name);
return options;
}

public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}

var options = BindOptions(parseResult);

try
{
var resourceGroupService = context.GetService<IResourceGroupService>();
var resources = await resourceGroupService.GetGenericResources(
options.Subscription!,
options.ResourceGroup!,
options.Tenant,
options.RetryPolicy,
cancellationToken);

context.Response.Results = resources?.Count > 0 ?
ResponseResult.Create(new GroupResourceListCommandResult(resources), GroupJsonContext.Default.GroupResourceListCommandResult) :
null;
}
catch (Exception ex)
{
_logger.LogError(ex, "An exception occurred listing resources in resource group.");
HandleException(context, ex);
}

return context.Response;
}

internal record class GroupResourceListCommandResult(List<GenericResourceInfo> Resources);
}
10 changes: 9 additions & 1 deletion core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,24 @@ public sealed class GroupSetup : IAreaSetup
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<GroupListCommand>();
services.AddSingleton<GroupResourceListCommand>();
}

public CommandGroup RegisterCommands(IServiceProvider serviceProvider)
{
var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups in your subscriptions.", Title);
var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups and their resources in your subscriptions.", Title);

// Register Group commands
var listCommand = serviceProvider.GetRequiredService<GroupListCommand>();
group.AddCommand(listCommand.Name, listCommand);

// Register Resource sub-group
var resource = new CommandGroup("resource", "Resource operations - Commands for listing resources within a resource group.");
group.AddSubGroup(resource);

var resourceListCommand = serviceProvider.GetRequiredService<GroupResourceListCommand>();
resource.AddCommand(resourceListCommand.Name, resourceListCommand);

return group;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.Mcp.Core.Areas.Group.Options;

public class ResourceListOptions : BaseGroupOptions;
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Models.Resource;
using Azure.Mcp.Core.Models.ResourceGroup;
using Azure.Mcp.Core.Options;
using Azure.ResourceManager.Resources;
Expand All @@ -12,4 +13,5 @@ public interface IResourceGroupService
Task<List<ResourceGroupInfo>> GetResourceGroups(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
Task<ResourceGroupInfo?> GetResourceGroup(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
Task<ResourceGroupResource?> GetResourceGroupResource(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
Task<List<GenericResourceInfo>> GetGenericResources(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Models.Resource;
using Azure.Mcp.Core.Models.ResourceGroup;
using Azure.Mcp.Core.Options;
using Azure.Mcp.Core.Services.Azure.Subscription;
Expand Down Expand Up @@ -111,4 +112,29 @@ public async Task<List<ResourceGroupInfo>> GetResourceGroups(string subscription
throw new Exception($"Error retrieving resource group {resourceGroupName}: {ex.Message}", ex);
}
}

public async Task<List<GenericResourceInfo>> GetGenericResources(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default)
{
ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroupName), resourceGroupName));

try
{
var resourceGroupResource = await GetResourceGroupResource(subscription, resourceGroupName, tenant, retryPolicy, cancellationToken)
?? throw new Exception($"Resource group '{resourceGroupName}' not found");

var resources = await resourceGroupResource.GetGenericResourcesAsync(cancellationToken: cancellationToken)
.Select(r => new GenericResourceInfo(
r.Data.Name,
r.Data.Id.ToString(),
r.Data.ResourceType.ToString(),
r.Data.Location.ToString()))
.ToListAsync(cancellationToken: cancellationToken);

return resources;
}
catch (Exception ex) when (ex is not ArgumentException)
{
throw new Exception($"Error retrieving resources in resource group {resourceGroupName}: {ex.Message}", ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ public async Task NamespaceProxyMode_WithSpecificNamespaces_LoadsNamespaceSpecif
// Should not include documentation tool when explicit namespaces are specified
Assert.DoesNotContain("documentation", toolNames, StringComparer.OrdinalIgnoreCase);

// Should contain exactly 4 tools: 2 specified namespaces + 2 utility tools (group_list, subscription_list)
Assert.Equal(4, toolNames.Count);
// Should contain exactly 5 tools: 2 specified namespaces + 3 utility tools (group_list, group_resource_list, subscription_list)
Assert.Equal(5, toolNames.Count);

// Verify tools are from storage, keyvault namespaces, or utility tools
Assert.All(toolNames, toolName =>
Expand Down Expand Up @@ -355,7 +355,7 @@ public async Task NamespaceProxyMode_WithDocumentationNamespace_LoadsOnlyDocumen
var toolNames = listResult.Select(t => t.Name).ToList();

// Should contain the documentation tool (displayed by its title) plus utility tools
Assert.Equal(3, listResult.Count());
Assert.Equal(4, listResult.Count());
Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase);
Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase));
Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase));
Expand Down Expand Up @@ -418,7 +418,7 @@ public async Task DefaultMode_WithNamespaceFilter_LoadsFilteredTools()

// Assert
Assert.NotEmpty(listResult);
Assert.Equal(4, listResult.Count()); // 2 specified namespaces + 2 utility tools
Assert.Equal(5, listResult.Count()); // 2 specified namespaces + 3 utility tools

var toolNames = listResult.Select(t => t.Name).ToList();

Expand Down Expand Up @@ -541,8 +541,8 @@ public async Task InvalidNamespace_LoadsGracefully()
// Should not crash, but may have fewer tools
Assert.NotNull(listResult);

// Invalid namespaces should result in only utility tools (group_list, subscription_list)
Assert.Equal(2, listResult.Count());
// Invalid namespaces should result in only utility tools (group_list, group_resource_list, subscription_list)
Assert.Equal(3, listResult.Count());
var toolNames = listResult.Select(t => t.Name).ToList();
Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase));
Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase));
Expand Down
Loading