diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs index ac82c2477d..675440ec11 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs @@ -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 { diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupResourceListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupResourceListCommand.cs new file mode 100644 index 0000000000..34ea556dc3 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupResourceListCommand.cs @@ -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 logger) : SubscriptionCommand() +{ + private const string CommandTitle = "List Resources in Resource Group"; + private readonly ILogger _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(OptionDefinitions.Common.ResourceGroup.Name); + return options; + } + + public override async Task 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(); + 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 Resources); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs index e2a9d5c155..546ffd4d27 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs @@ -19,16 +19,24 @@ public sealed class GroupSetup : IAreaSetup public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); } 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(); 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(); + resource.AddCommand(resourceListCommand.Name, resourceListCommand); + return group; } } diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Options/ResourceListOptions.cs b/core/Azure.Mcp.Core/src/Areas/Group/Options/ResourceListOptions.cs new file mode 100644 index 0000000000..ec9833cfa4 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Group/Options/ResourceListOptions.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Group.Options; + +public class ResourceListOptions : BaseGroupOptions; diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs index 33e87781f3..92dbfc017e 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs @@ -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; @@ -12,4 +13,5 @@ public interface IResourceGroupService Task> GetResourceGroups(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetResourceGroup(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetResourceGroupResource(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task> GetGenericResources(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs index 34fb21ff14..33ad717d20 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs @@ -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; @@ -111,4 +112,29 @@ public async Task> GetResourceGroups(string subscription throw new Exception($"Error retrieving resource group {resourceGroupName}: {ex.Message}", ex); } } + + public async Task> 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); + } + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerStartCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerStartCommandTests.cs index 2d903a63bc..e420c356f7 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerStartCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerStartCommandTests.cs @@ -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 => @@ -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)); @@ -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(); @@ -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)); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupResourceListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupResourceListCommandTests.cs new file mode 100644 index 0000000000..499badd561 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupResourceListCommandTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Group.Commands; +using Azure.Mcp.Core.Models.Resource; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure.ResourceGroup; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using ModelContextProtocol.Server; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Group.UnitTests; + +public class GroupResourceListCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly McpServer _mcpServer; + private readonly ILogger _logger; + private readonly IResourceGroupService _resourceGroupService; + private readonly GroupResourceListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public GroupResourceListCommandTests() + { + _mcpServer = Substitute.For(); + _resourceGroupService = Substitute.For(); + _logger = Substitute.For>(); + var collection = new ServiceCollection() + .AddSingleton(_mcpServer) + .AddSingleton(_resourceGroupService); + + _serviceProvider = collection.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_WithValidParameters_ReturnsResources() + { + // Arrange + var subscriptionId = "test-subs-id"; + var resourceGroup = "test-rg"; + var expectedResources = new List + { + new("storageAccount1", "/subscriptions/test-subs-id/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storageAccount1", "Microsoft.Storage/storageAccounts", "East US"), + new("vm1", "/subscriptions/test-subs-id/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/vm1", "Microsoft.Compute/virtualMachines", "West US") + }; + + _resourceGroupService + .GetGenericResources( + Arg.Is(x => x == subscriptionId), + Arg.Is(x => x == resourceGroup), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedResources); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId} --resource-group {resourceGroup}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.NotNull(result.Results); + + var jsonDoc = JsonDocument.Parse(JsonSerializer.Serialize(result.Results)); + var resourcesArray = jsonDoc.RootElement.GetProperty("resources"); + + Assert.Equal(2, resourcesArray.GetArrayLength()); + + var first = resourcesArray[0]; + var second = resourcesArray[1]; + + Assert.Equal("storageAccount1", first.GetProperty("name").GetString()); + Assert.Equal("Microsoft.Storage/storageAccounts", first.GetProperty("type").GetString()); + Assert.Equal("East US", first.GetProperty("location").GetString()); + + Assert.Equal("vm1", second.GetProperty("name").GetString()); + Assert.Equal("Microsoft.Compute/virtualMachines", second.GetProperty("type").GetString()); + Assert.Equal("West US", second.GetProperty("location").GetString()); + + await _resourceGroupService.Received(1).GetGenericResources( + Arg.Is(x => x == subscriptionId), + Arg.Is(x => x == resourceGroup), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithTenant_PassesTenantToService() + { + // Arrange + var subscriptionId = "test-subs-id"; + var resourceGroup = "test-rg"; + var tenantId = "test-tenant-id"; + var expectedResources = new List + { + new("resource1", "/subscriptions/test-subs-id/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/resource1", "Microsoft.Storage/storageAccounts", "East US") + }; + + _resourceGroupService + .GetGenericResources( + Arg.Is(x => x == subscriptionId), + Arg.Is(x => x == resourceGroup), + Arg.Is(x => x == tenantId), + Arg.Any(), + Arg.Any()) + .Returns(expectedResources); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId} --resource-group {resourceGroup} --tenant {tenantId}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + await _resourceGroupService.Received(1).GetGenericResources( + Arg.Is(x => x == subscriptionId), + Arg.Is(x => x == resourceGroup), + Arg.Is(x => x == tenantId), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_EmptyResourceList_ReturnsNullResults() + { + // Arrange + var subscriptionId = "test-subs-id"; + var resourceGroup = "test-rg"; + _resourceGroupService + .GetGenericResources(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId} --resource-group {resourceGroup}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.OK, result.Status); + Assert.Null(result.Results); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorInResponse() + { + // Arrange + var subscriptionId = "test-subs-id"; + var resourceGroup = "test-rg"; + var expectedError = "Test error message"; + _resourceGroupService + .GetGenericResources(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException>(new Exception(expectedError))); + + var args = _commandDefinition.Parse($"--subscription {subscriptionId} --resource-group {resourceGroup}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(HttpStatusCode.InternalServerError, result.Status); + Assert.Contains(expectedError, result.Message); + } + + [Fact] + public async Task ExecuteAsync_MissingResourceGroup_ReturnsValidationError() + { + // Arrange + var subscriptionId = "test-subs-id"; + var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); + + // Act + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(HttpStatusCode.OK, result.Status); + } +} diff --git a/core/Microsoft.Mcp.Core/src/Models/Resource/GenericResourceInfo.cs b/core/Microsoft.Mcp.Core/src/Models/Resource/GenericResourceInfo.cs new file mode 100644 index 0000000000..1bd065ed9a --- /dev/null +++ b/core/Microsoft.Mcp.Core/src/Models/Resource/GenericResourceInfo.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Models.Resource; + +public class GenericResourceInfo(string name, string id, string type, string location) +{ + public string Name { get; set; } = name; + public string Id { get; set; } = id; + public string Type { get; set; } = type; + public string Location { get; set; } = location; +} diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 82c7f6a270..4c7e6b446d 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -2652,6 +2652,10 @@ azmcp redis list --subscription # List resource groups in a subscription # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp group list --subscription + +# List all resources in a resource group +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp group resource list --subscription --resource-group ``` ### Azure Resource Health Operations diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 3c49ac7bbc..c73731ed66 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -671,6 +671,9 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | group_list | List all resource groups in my subscription | | group_list | Show me my resource groups | | group_list | Show me the resource groups in my subscription | +| group_resource_list | List all resources in my resource group | +| group_resource_list | Show me what resources are in the resource group myRG | +| group_resource_list | What resources exist in resource group myRG? | ## Azure Resource Health diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 8f3221b933..6a8f6963f9 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2,7 +2,7 @@ "consolidated_tools": [ { "name": "get_azure_subscriptions_and_resource_groups", - "description": "Get information about Azure subscriptions and resource groups that the user has access to.", + "description": "Get information about Azure subscriptions, resource groups, and resources within resource groups that the user has access to.", "toolMetadata": { "destructive": { "value": false, @@ -31,6 +31,7 @@ }, "mappedToolList": [ "group_list", + "group_resource_list", "subscription_list" ] },