diff --git a/Directory.Packages.props b/Directory.Packages.props index 176a5cb12d..37953169b0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,8 +62,7 @@ - + @@ -95,4 +94,4 @@ - + \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs index 145e492379..eb4ef99d2e 100644 --- a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine.Parsing; using System.Diagnostics.CodeAnalysis; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Models.Option; using Azure.Mcp.Core.Options; @@ -20,9 +22,7 @@ protected override void RegisterOptions(Command command) // This mirrors the prior behavior that preferred the explicit option but fell back to env var. command.Validators.Add(commandResult => { - var hasOption = commandResult.HasOptionResult(OptionDefinitions.Common.Subscription.Name); - var hasEnv = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID")); - if (!hasOption && !hasEnv) + if (!HasSubscriptionAvailable(commandResult)) { commandResult.AddError("Missing Required options: --subscription"); } @@ -36,7 +36,7 @@ protected override TOptions BindOptions(ParseResult parseResult) // Get subscription from command line option or fallback to environment variable var subscriptionValue = parseResult.GetValueOrDefault(OptionDefinitions.Common.Subscription.Name); - var envSubscription = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); + var envSubscription = EnvironmentHelpers.GetAzureSubscriptionId(); options.Subscription = (string.IsNullOrEmpty(subscriptionValue) || IsPlaceholder(subscriptionValue)) && !string.IsNullOrEmpty(envSubscription) @@ -46,6 +46,18 @@ protected override TOptions BindOptions(ParseResult parseResult) return options; } + /// + /// Checks if a subscription is available either from the command option or AZURE_SUBSCRIPTION_ID environment variable. + /// + /// The command result to check for the subscription option. + /// True if a subscription is available, false otherwise. + protected static bool HasSubscriptionAvailable(CommandResult commandResult) + { + var hasOption = commandResult.HasOptionResult(OptionDefinitions.Common.Subscription); + var hasEnv = !string.IsNullOrEmpty(EnvironmentHelpers.GetAzureSubscriptionId()); + return hasOption || hasEnv; + } + private static bool IsPlaceholder(string value) => value.Contains("subscription") || value.Contains("default"); } diff --git a/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs b/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs index 0af31764a5..05f10bc7c8 100644 --- a/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs +++ b/core/Azure.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs @@ -5,6 +5,8 @@ namespace Azure.Mcp.Core.Helpers { public static class EnvironmentHelpers { + private const string AzureSubscriptionIdEnvironmentVariable = "AZURE_SUBSCRIPTION_ID"; + public static bool GetEnvironmentVariableAsBool(string envVarName) { return Environment.GetEnvironmentVariable(envVarName) switch @@ -16,5 +18,24 @@ public static bool GetEnvironmentVariableAsBool(string envVarName) _ => false }; } + + /// + /// Gets the Azure subscription ID from the AZURE_SUBSCRIPTION_ID environment variable. + /// + /// The subscription ID if available, null otherwise. + public static string? GetAzureSubscriptionId() + { + return Environment.GetEnvironmentVariable(AzureSubscriptionIdEnvironmentVariable); + } + + /// + /// Sets the AZURE_SUBSCRIPTION_ID environment variable. + /// This method is primarily intended for testing scenarios. + /// + /// The subscription ID to set, or null to clear the variable. + public static void SetAzureSubscriptionId(string? subscriptionId) + { + Environment.SetEnvironmentVariable(AzureSubscriptionIdEnvironmentVariable, subscriptionId); + } } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs index d263160b97..95c48f24b8 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.CommandLine; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Options; using Azure.Mcp.Tools.Storage.Commands.Account; @@ -40,9 +41,8 @@ public SubscriptionCommandTests() public void Validate_WithEnvironmentVariableOnly_PassesValidation() { // Arrange - var originalValue = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", "env-subs"); - + var originalValue = EnvironmentHelpers.GetAzureSubscriptionId(); + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); try { var parseResult = _commandDefinition.Parse([]); @@ -53,7 +53,7 @@ public void Validate_WithEnvironmentVariableOnly_PassesValidation() finally { // Cleanup - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", originalValue); + EnvironmentHelpers.SetAzureSubscriptionId(originalValue); } } @@ -61,8 +61,8 @@ public void Validate_WithEnvironmentVariableOnly_PassesValidation() public async Task ExecuteAsync_WithEnvironmentVariableOnly_CallsServiceWithCorrectSubscription() { // Arrange - var originalValue = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", "env-subs"); + var originalValue = EnvironmentHelpers.GetAzureSubscriptionId(); + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); try { @@ -97,7 +97,7 @@ public async Task ExecuteAsync_WithEnvironmentVariableOnly_CallsServiceWithCorre finally { // Cleanup - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", originalValue); + EnvironmentHelpers.SetAzureSubscriptionId(originalValue); } } @@ -105,8 +105,8 @@ public async Task ExecuteAsync_WithEnvironmentVariableOnly_CallsServiceWithCorre public async Task ExecuteAsync_WithBothOptionAndEnvironmentVariable_PrefersOption() { // Arrange - var originalValue = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", "env-subs"); + var originalValue = EnvironmentHelpers.GetAzureSubscriptionId(); + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); try { @@ -146,7 +146,7 @@ public async Task ExecuteAsync_WithBothOptionAndEnvironmentVariable_PrefersOptio finally { // Cleanup - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", originalValue); + EnvironmentHelpers.SetAzureSubscriptionId(originalValue); } } } diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index ebc09224e9..550df944c0 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -478,6 +478,13 @@ azmcp deploy plan get --workspace-folder \ # List all Event Grid topics in a subscription or resource group azmcp eventgrid topic list --subscription \ [--resource-group ] + + +# List all Event Grid subscriptions in a subscription, resource group, or topic +azmcp eventgrid subscription list --subscription \ + [--resource-group ] \ + [--topic ] + [--location ] ``` ### Azure Function App Operations diff --git a/docs/e2eTestPrompts.md b/docs/e2eTestPrompts.md index e0eb067e08..1558e8e182 100644 --- a/docs/e2eTestPrompts.md +++ b/docs/e2eTestPrompts.md @@ -146,6 +146,13 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | azmcp_eventgrid_topic_list | Show me the Event Grid topics in my subscription | | azmcp_eventgrid_topic_list | List all Event Grid topics in subscription | | azmcp_eventgrid_topic_list | List all Event Grid topics in resource group in subscription | +| azmcp_eventgrid_subscription_list | Show me all Event Grid subscriptions for topic | +| azmcp_eventgrid_subscription_list | List Event Grid subscriptions for topic in subscription | +| azmcp_eventgrid_subscription_list | List Event Grid subscriptions for topic in resource group | +| azmcp_eventgrid_subscription_list | Show all Event Grid subscriptions in my subscription | +| azmcp_eventgrid_subscription_list | List all Event Grid subscriptions in subscription | +| azmcp_eventgrid_subscription_list | Show Event Grid subscriptions in resource group in subscription | +| azmcp_eventgrid_subscription_list | List Event Grid subscriptions for subscription in location | ## Azure Function App diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index e1de2943ab..a5f2a75a48 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -98,7 +98,11 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * "List all Event Grid topics in subscription 'my-subscription'" * "Show me the Event Grid topics in my subscription" -* "List all Event Grid topics in resource group 'my-resource-group' in my subscription" +* "List all Event Grid topics in resource group 'my-resourcegroup' in my subscription" +* "List Event Grid subscriptions for topic 'my-topic' in resource group 'my-resourcegroup'" +* "List Event Grid subscriptions for topic 'my-topic' in subscription 'my-subscription'" +* "List Event Grid Subscriptions in subscription 'my-subscription'" +* "List Event Grid subscriptions for topic 'my-topic' in location 'my-location'" ### ⚡ Azure Managed Lustre @@ -211,6 +215,8 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * List Event Grid topics in subscription or resource group * View topic configuration and status information * Access endpoint and key details for event publishing +* List Event Grid subscriptions with filtering by topic name, resource group, and location +* View subscription details including destination endpoints and retry policies ### 🧮 Azure Foundry diff --git a/tools/Azure.Mcp.Tools.Acr/tests/Azure.Mcp.Tools.Acr.UnitTests/Registry/RegistryListCommandTests.cs b/tools/Azure.Mcp.Tools.Acr/tests/Azure.Mcp.Tools.Acr.UnitTests/Registry/RegistryListCommandTests.cs index cc9ab6712b..7b50206205 100644 --- a/tools/Azure.Mcp.Tools.Acr/tests/Azure.Mcp.Tools.Acr.UnitTests/Registry/RegistryListCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Acr/tests/Azure.Mcp.Tools.Acr.UnitTests/Registry/RegistryListCommandTests.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Text.Json; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Options; using Azure.Mcp.Tools.Acr.Commands; @@ -52,7 +53,7 @@ public void Constructor_InitializesCommandCorrectly() public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) { // Ensure environment variable fallback does not interfere with validation tests - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", null); + EnvironmentHelpers.SetAzureSubscriptionId(null); // Arrange if (shouldSucceed) { diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs index 0a0a96b08d..301ad71ac6 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs @@ -2,13 +2,16 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; +using Azure.Mcp.Tools.EventGrid.Commands.Subscription; using Azure.Mcp.Tools.EventGrid.Commands.Topic; namespace Azure.Mcp.Tools.EventGrid.Commands; +[JsonSerializable(typeof(SubscriptionListCommand.SubscriptionListCommandResult))] [JsonSerializable(typeof(TopicListCommand.TopicListCommandResult))] +[JsonSerializable(typeof(EventGridSubscriptionInfo))] [JsonSerializable(typeof(EventGridTopicInfo))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] internal sealed partial class EventGridJsonContext : JsonSerializerContext { } diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Subscription/SubscriptionListCommand.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Subscription/SubscriptionListCommand.cs new file mode 100644 index 0000000000..f348a6109c --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Subscription/SubscriptionListCommand.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.EventGrid.Models; +using Azure.Mcp.Tools.EventGrid.Options; +using Azure.Mcp.Tools.EventGrid.Options.Subscription; +using Azure.Mcp.Tools.EventGrid.Services; + +namespace Azure.Mcp.Tools.EventGrid.Commands.Subscription; + +public sealed class SubscriptionListCommand(ILogger logger) : BaseEventGridCommand +{ + private const string CommandTitle = "List Event Grid Subscriptions"; + private readonly ILogger _logger = logger; + + public override string Name => "list"; + + public override string Description => + """ + List event subscriptions for topics with filtering and endpoint configuration. This tool shows all active + subscriptions including webhook endpoints, event filters, and delivery retry policies. Returns subscription + details as JSON array. Requires either --topic (bare topic name) OR --subscription. If only --topic is provided + the tool searches all accessible subscriptions for a topic with that name. Optional --resource-group/--location + may only be used alongside --subscription or --topic. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = true, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(OptionDefinitions.Common.ResourceGroup); + command.Options.Add(EventGridOptionDefinitions.TopicName); + command.Options.Add(EventGridOptionDefinitions.Location); + } + + protected override SubscriptionListOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + options.TopicName = parseResult.GetValueOrDefault(EventGridOptionDefinitions.TopicName.Name); + options.Location = parseResult.GetValueOrDefault(EventGridOptionDefinitions.Location.Name); + return options; + } + + public override ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null) + { + // Skip the base validation that requires subscription and implement custom validation + var result = new ValidationResult { IsValid = true }; + + var hasSubscription = HasSubscriptionAvailable(commandResult); + var hasTopicOption = commandResult.HasOptionResult(EventGridOptionDefinitions.TopicName); + var hasRg = commandResult.HasOptionResult(OptionDefinitions.Common.ResourceGroup); + var hasLocation = commandResult.HasOptionResult(EventGridOptionDefinitions.Location); + + // Either topic or subscription is mandatory + if (!hasSubscription && !hasTopicOption) + { + result.IsValid = false; + result.ErrorMessage = "Either --subscription or --topic is required."; + + if (commandResponse != null) + { + commandResponse.Status = 400; + commandResponse.Message = result.ErrorMessage; + } + } + // Location and resource-group can only be used with subscription or topic + else if ((hasRg || hasLocation) && !hasSubscription && !hasTopicOption) + { + result.IsValid = false; + result.ErrorMessage = "Either --subscription or --topic is required when using --resource-group or --location."; + + if (commandResponse != null) + { + commandResponse.Status = 400; + commandResponse.Message = result.ErrorMessage; + } + } + + return result; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + var hasSubscription = !string.IsNullOrWhiteSpace(options.Subscription); + var hasTopic = !string.IsNullOrWhiteSpace(options.TopicName); + + // Bare topic name without subscription triggers cross-subscription search + bool crossSubscriptionSearch = !hasSubscription && hasTopic; + + try + { + var eventGridService = context.GetService(); + + if (crossSubscriptionSearch) + { + // Iterate all subscriptions and aggregate + var subscriptionService = context.GetService(); + var allSubs = await subscriptionService.GetSubscriptions(null, options.RetryPolicy); + var aggregate = new List(); + foreach (var sub in allSubs) + { + try + { + var found = await eventGridService.GetSubscriptionsAsync( + sub.SubscriptionId, + options.ResourceGroup, + options.TopicName, // bare name + options.Location, + options.Tenant, + options.RetryPolicy); + if (found?.Count > 0) + { + aggregate.AddRange(found); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed searching topic '{Topic}' in subscription '{Sub}'. Continuing.", options.TopicName, sub.SubscriptionId); + continue; + } + } + context.Response.Results = ResponseResult.Create( + new SubscriptionListCommandResult(aggregate), + EventGridJsonContext.Default.SubscriptionListCommandResult); + } + else + { + var subscriptions = await eventGridService.GetSubscriptionsAsync( + options.Subscription!, + options.ResourceGroup, + options.TopicName, + options.Location, + options.Tenant, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create( + new SubscriptionListCommandResult(subscriptions ?? []), + EventGridJsonContext.Default.SubscriptionListCommandResult); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error listing Event Grid subscriptions. Subscription: {Subscription}, ResourceGroup: {ResourceGroup}, TopicName: {TopicName}, Location: {Location}, Options: {@Options}", + options.Subscription, options.ResourceGroup, options.TopicName, options.Location, options); + HandleException(context, ex); + } + + return context.Response; + } + + internal record SubscriptionListCommandResult(List Subscriptions); +} diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicListCommand.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicListCommand.cs index 0bbf7a0da2..5c31a26fa7 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicListCommand.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicListCommand.cs @@ -3,6 +3,7 @@ using Azure.Mcp.Core.Extensions; using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Tools.EventGrid.Models; using Azure.Mcp.Tools.EventGrid.Options.Topic; using Azure.Mcp.Tools.EventGrid.Services; @@ -37,7 +38,7 @@ Returns topic information as JSON array. Requires subscription. protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); + command.Options.Add(OptionDefinitions.Common.ResourceGroup); } protected override TopicListOptions BindOptions(ParseResult parseResult) @@ -62,9 +63,12 @@ public override async Task ExecuteAsync(CommandContext context, var topics = await eventGridService.GetTopicsAsync( options.Subscription!, options.ResourceGroup, + options.Tenant, options.RetryPolicy); - context.Response.Results = ResponseResult.Create(new(topics ?? []), EventGridJsonContext.Default.TopicListCommandResult); + context.Response.Results = ResponseResult.Create( + new TopicListCommandResult(topics ?? []), + EventGridJsonContext.Default.TopicListCommandResult); } catch (Exception ex) { diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs b/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs index 76281e760e..4e1e985cad 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Areas; +using Azure.Mcp.Tools.EventGrid.Commands.Subscription; using Azure.Mcp.Tools.EventGrid.Commands.Topic; using Azure.Mcp.Tools.EventGrid.Services; using Microsoft.Extensions.DependencyInjection; @@ -27,7 +28,14 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor var topics = new CommandGroup("topic", "Event Grid topic operations - Commands for managing Event Grid topics and their configurations."); eventGrid.AddSubGroup(topics); + // Subscriptions subgroup + var subscriptions = new CommandGroup("subscription", "Event Grid subscription operations - Commands for managing event subscriptions with filtering and endpoint configuration."); + eventGrid.AddSubGroup(subscriptions); + // Register Topic commands topics.AddCommand("list", new TopicListCommand(loggerFactory.CreateLogger())); + + // Register Subscription commands + subscriptions.AddCommand("list", new SubscriptionListCommand(loggerFactory.CreateLogger())); } } diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Models/EventGridSubscriptionInfo.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Models/EventGridSubscriptionInfo.cs new file mode 100644 index 0000000000..7881da1f45 --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Models/EventGridSubscriptionInfo.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.EventGrid.Models; + +public record EventGridSubscriptionInfo( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("endpointType")] string? EndpointType, + [property: JsonPropertyName("endpointUrl")] string? EndpointUrl, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState, + [property: JsonPropertyName("deadLetterDestination")] string? DeadLetterDestination, + [property: JsonPropertyName("filter")] string? Filter, + [property: JsonPropertyName("maxDeliveryAttempts")] int? MaxDeliveryAttempts, + [property: JsonPropertyName("eventTimeToLiveInMinutes")] int? EventTimeToLiveInMinutes, + [property: JsonPropertyName("createdDateTime")] string? CreatedDateTime, + [property: JsonPropertyName("updatedDateTime")] string? UpdatedDateTime +); diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Options/EventGridOptionDefinitions.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Options/EventGridOptionDefinitions.cs index a94d03a7a7..c51bc62339 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Options/EventGridOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Options/EventGridOptionDefinitions.cs @@ -5,4 +5,20 @@ namespace Azure.Mcp.Tools.EventGrid.Options; public static class EventGridOptionDefinitions { + public const string TopicNameParam = "topic"; + public const string LocationParam = "location"; + + public static readonly Option TopicName = new( + $"--{TopicNameParam}" + ) + { + Description = "The name of the Event Grid topic." + }; + + public static readonly Option Location = new( + $"--{LocationParam}" + ) + { + Description = "The Azure region to filter resources by (e.g., 'eastus', 'westus2')." + }; } diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Options/Subscription/SubscriptionListOptions.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Options/Subscription/SubscriptionListOptions.cs new file mode 100644 index 0000000000..6041fef566 --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Options/Subscription/SubscriptionListOptions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.EventGrid.Options.Subscription; + +public class SubscriptionListOptions : BaseEventGridOptions +{ + public string? TopicName { get; set; } + public string? Location { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Services/EventGridService.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Services/EventGridService.cs index ffbfccb602..9d83eebd89 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Services/EventGridService.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Services/EventGridService.cs @@ -1,21 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; +using Azure.Mcp.Core.Options; +using Azure.ResourceManager; using Azure.ResourceManager.EventGrid; +using Azure.ResourceManager.EventGrid.Models; +using Azure.ResourceManager.Resources; namespace Azure.Mcp.Tools.EventGrid.Services; -public class EventGridService(ISubscriptionService subscriptionService, ITenantService tenantService) +public class EventGridService(ISubscriptionService subscriptionService, ITenantService tenantService, ILogger logger) : BaseAzureService(tenantService), IEventGridService { private readonly ISubscriptionService _subscriptionService = subscriptionService ?? throw new ArgumentNullException(nameof(subscriptionService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); public async Task> GetTopicsAsync( string subscription, string? resourceGroup = null, + string? tenant = null, RetryPolicyOptions? retryPolicy = null) { - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); var topics = new List(); if (!string.IsNullOrEmpty(resourceGroup)) @@ -41,6 +48,222 @@ public async Task> GetTopicsAsync( return topics; } + public async Task> GetSubscriptionsAsync( + string subscription, + string? resourceGroup = null, + string? topicName = null, + string? location = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null) + { + var subscriptions = new List(); + + try + { + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + + // If specific topic is requested, get subscriptions for that topic only + if (!string.IsNullOrEmpty(topicName)) + { + await GetSubscriptionsForSpecificTopic(subscriptionResource, resourceGroup, topicName, location, subscriptions); + } + else + { + // Get subscriptions from all topics in the subscription or resource group + await GetSubscriptionsFromAllTopics(subscriptionResource, resourceGroup, location, subscriptions); + } + } + catch (Exception ex) + { + // Log the actual error instead of swallowing it + throw new InvalidOperationException($"Failed to retrieve EventGrid subscriptions: {ex.Message}", ex); + } + + return subscriptions; + } + + private async Task GetSubscriptionsForSpecificTopic( + SubscriptionResource subscriptionResource, + string? resourceGroup, + string topicName, + string? location, + List subscriptions) + { + try + { + // Find the specific custom topic first + var topic = await FindTopic(subscriptionResource, resourceGroup, topicName); + if (topic != null) + { + await AddSubscriptionsFromTopic(topic.Data.Location.ToString(), location, subscriptions, topic.GetTopicEventSubscriptions().GetAllAsync()); + return; // Found custom topic, no need to check system topics + } + + // If not found in custom topics, check system topics + var systemTopic = await FindSystemTopic(subscriptionResource, resourceGroup, topicName); + if (systemTopic != null) + { + await AddSubscriptionsFromSystemTopic(systemTopic.Data.Location.ToString(), location, subscriptions, systemTopic.GetSystemTopicEventSubscriptions().GetAllAsync()); + } + } + catch (Exception ex) + { + // Log and re-throw to preserve error information + throw new InvalidOperationException($"Failed to get subscriptions for topic '{topicName}': {ex.Message}", ex); + } + } + + private async Task GetSubscriptionsFromAllTopics( + SubscriptionResource subscriptionResource, + string? resourceGroup, + string? location, + List subscriptions) + { + try + { + if (!string.IsNullOrEmpty(resourceGroup)) + { + // Get topics from specific resource group and their subscriptions + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + + // Check custom topics + await foreach (var topic in resourceGroupResource.Value.GetEventGridTopics().GetAllAsync()) + { + try + { + await AddSubscriptionsFromTopic(topic.Data.Location.ToString(), location, subscriptions, topic.GetTopicEventSubscriptions().GetAllAsync()); + } + catch (Exception ex) + { + // Continue with other topics if one fails - individual topic access errors + // shouldn't block the entire operation since we're aggregating from multiple topics + _logger.LogWarning(ex, "Failed to get subscriptions for topic '{TopicName}'. Continuing with other topics.", topic.Data.Name); + continue; + } + } // Also check system topics in the resource group + await foreach (var systemTopic in resourceGroupResource.Value.GetSystemTopics().GetAllAsync()) + { + try + { + await AddSubscriptionsFromSystemTopic(systemTopic.Data.Location.ToString(), location, subscriptions, systemTopic.GetSystemTopicEventSubscriptions().GetAllAsync()); + } + catch (Exception ex) + { + // Continue with other system topics if one fails - individual system topic access errors + // shouldn't block the entire operation since we're aggregating from multiple topics + _logger.LogWarning(ex, "Failed to get subscriptions for system topic '{SystemTopicName}'. Continuing with other topics.", systemTopic.Data.Name); + continue; + } + } + } + else + { + // Get topics from all resource groups and their subscriptions + await foreach (var topic in subscriptionResource.GetEventGridTopicsAsync()) + { + try + { + await AddSubscriptionsFromTopic(topic.Data.Location.ToString(), location, subscriptions, topic.GetTopicEventSubscriptions().GetAllAsync()); + } + catch (Exception ex) + { + // Continue with other topics if one fails - individual topic access errors + // shouldn't block the entire operation since we're aggregating from multiple topics + _logger.LogWarning(ex, "Failed to get subscriptions for topic '{TopicName}'. Continuing with other topics.", topic.Data.Name); + continue; + } + } + + // Also check system topics across all resource groups + await foreach (var systemTopic in subscriptionResource.GetSystemTopicsAsync()) + { + try + { + await AddSubscriptionsFromSystemTopic(systemTopic.Data.Location.ToString(), location, subscriptions, systemTopic.GetSystemTopicEventSubscriptions().GetAllAsync()); + } + catch (Exception ex) + { + // Continue with other system topics if one fails - individual system topic access errors + // shouldn't block the entire operation since we're aggregating from multiple topics + _logger.LogWarning(ex, "Failed to get subscriptions for system topic '{SystemTopicName}'. Continuing with other topics.", systemTopic.Data.Name); + continue; + } + } + } + } + catch (Exception ex) + { + // Log and re-throw to preserve error information + throw new InvalidOperationException($"Failed to get subscriptions from all topics in resource group '{resourceGroup}': {ex.Message}", ex); + } + } + + private async Task FindTopic( + SubscriptionResource subscriptionResource, + string? resourceGroup, + string topicName) + { + if (!string.IsNullOrEmpty(resourceGroup)) + { + // Search in specific resource group + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + + await foreach (var topic in resourceGroupResource.Value.GetEventGridTopics().GetAllAsync()) + { + if (topic.Data.Name.Equals(topicName, StringComparison.OrdinalIgnoreCase)) + { + return topic; + } + } + } + else + { + // Search in all resource groups + await foreach (var topic in subscriptionResource.GetEventGridTopicsAsync()) + { + if (topic.Data.Name.Equals(topicName, StringComparison.OrdinalIgnoreCase)) + { + return topic; + } + } + } + + return null; + } + + private async Task FindSystemTopic( + SubscriptionResource subscriptionResource, + string? resourceGroup, + string topicName) + { + if (!string.IsNullOrEmpty(resourceGroup)) + { + // Search in specific resource group + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + + await foreach (var systemTopic in resourceGroupResource.Value.GetSystemTopics().GetAllAsync()) + { + if (systemTopic.Data.Name.Equals(topicName, StringComparison.OrdinalIgnoreCase)) + { + return systemTopic; + } + } + } + else + { + // Search in all resource groups + await foreach (var systemTopic in subscriptionResource.GetSystemTopicsAsync()) + { + if (systemTopic.Data.Name.Equals(topicName, StringComparison.OrdinalIgnoreCase)) + { + return systemTopic; + } + } + } + + return null; + } + private static EventGridTopicInfo CreateTopicInfo(EventGridTopicData topicData) { return new EventGridTopicInfo( @@ -51,4 +274,95 @@ private static EventGridTopicInfo CreateTopicInfo(EventGridTopicData topicData) PublicNetworkAccess: topicData.PublicNetworkAccess?.ToString(), InputSchema: topicData.InputSchema?.ToString()); } + + private static EventGridSubscriptionInfo CreateSubscriptionInfo(EventGridSubscriptionData subscriptionData) + { + string? endpointType = null; + string? endpointUrl = null; + + // Extract endpoint information based on destination type + if (subscriptionData.Destination != null) + { + // Extract both endpoint type and URL using type-safe pattern matching + (endpointType, endpointUrl) = subscriptionData.Destination switch + { + WebHookEventSubscriptionDestination webhook => ("WebHook", webhook.Endpoint?.ToString()), + AzureFunctionEventSubscriptionDestination azureFunction => ("AzureFunction", azureFunction.ResourceId?.ToString()), + EventHubEventSubscriptionDestination eventHub => ("EventHub", eventHub.ResourceId?.ToString()), + HybridConnectionEventSubscriptionDestination hybridConnection => ("HybridConnection", hybridConnection.ResourceId?.ToString()), + NamespaceTopicEventSubscriptionDestination namespaceTopic => ("NamespaceTopic", namespaceTopic.ResourceId?.ToString()), + PartnerEventSubscriptionDestination partner => ("Partner", partner.ResourceId), + ServiceBusQueueEventSubscriptionDestination serviceBusQueue => ("ServiceBusQueue", serviceBusQueue.ResourceId?.ToString()), + ServiceBusTopicEventSubscriptionDestination serviceBusTopic => ("ServiceBusTopic", serviceBusTopic.ResourceId?.ToString()), + StorageQueueEventSubscriptionDestination storageQueue => ("StorageQueue", storageQueue.ResourceId?.ToString()), + MonitorAlertEventSubscriptionDestination => ("MonitorAlert", null), // No endpoint property + _ => (subscriptionData.Destination.GetType().Name, null) // Unknown or future destination types - fallback to full type name + }; + } + + // Extract filter information + string? filterInfo = null; + if (subscriptionData.Filter != null) + { + var filterDetails = new List(); + + if (subscriptionData.Filter.SubjectBeginsWith != null) + filterDetails.Add($"SubjectBeginsWith: {subscriptionData.Filter.SubjectBeginsWith}"); + + if (subscriptionData.Filter.SubjectEndsWith != null) + filterDetails.Add($"SubjectEndsWith: {subscriptionData.Filter.SubjectEndsWith}"); + + if (subscriptionData.Filter.IncludedEventTypes?.Any() == true) + filterDetails.Add($"EventTypes: {string.Join(", ", subscriptionData.Filter.IncludedEventTypes)}"); + + if (subscriptionData.Filter.IsSubjectCaseSensitive.HasValue) + filterDetails.Add($"CaseSensitive: {subscriptionData.Filter.IsSubjectCaseSensitive}"); + + filterInfo = filterDetails.Any() ? string.Join("; ", filterDetails) : null; + } + + return new EventGridSubscriptionInfo( + Name: subscriptionData.Name, + Type: subscriptionData.ResourceType.ToString(), + EndpointType: endpointType, + EndpointUrl: endpointUrl, + ProvisioningState: subscriptionData.ProvisioningState?.ToString(), + DeadLetterDestination: subscriptionData.DeadLetterDestination?.ToString(), + Filter: filterInfo, + MaxDeliveryAttempts: subscriptionData.RetryPolicy?.MaxDeliveryAttempts, + EventTimeToLiveInMinutes: subscriptionData.RetryPolicy?.EventTimeToLiveInMinutes, + CreatedDateTime: subscriptionData.SystemData?.CreatedOn?.ToString("yyyy-MM-ddTHH:mm:ssZ"), + UpdatedDateTime: subscriptionData.SystemData?.LastModifiedOn?.ToString("yyyy-MM-ddTHH:mm:ssZ") + ); + } + + private async Task AddSubscriptionsFromTopic( + string topicLocation, + string? locationFilter, + List subscriptions, + IAsyncEnumerable subscriptionCollection) + { + if (string.IsNullOrEmpty(locationFilter) || string.Equals(topicLocation, locationFilter, StringComparison.OrdinalIgnoreCase)) + { + await foreach (var subscription in subscriptionCollection) + { + subscriptions.Add(CreateSubscriptionInfo(subscription.Data)); + } + } + } + + private async Task AddSubscriptionsFromSystemTopic( + string topicLocation, + string? locationFilter, + List subscriptions, + IAsyncEnumerable subscriptionCollection) + { + if (string.IsNullOrEmpty(locationFilter) || string.Equals(topicLocation, locationFilter, StringComparison.OrdinalIgnoreCase)) + { + await foreach (var subscription in subscriptionCollection) + { + subscriptions.Add(CreateSubscriptionInfo(subscription.Data)); + } + } + } } diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs index 3fe6b2bc63..f956d66a05 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Tools.EventGrid.Models; + namespace Azure.Mcp.Tools.EventGrid.Services; public interface IEventGridService @@ -8,5 +10,14 @@ public interface IEventGridService Task> GetTopicsAsync( string subscription, string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null); + + Task> GetSubscriptionsAsync( + string subscription, + string? resourceGroup = null, + string? topicName = null, + string? location = null, + string? tenant = null, RetryPolicyOptions? retryPolicy = null); } diff --git a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.LiveTests/EventGridCommandTests.cs b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.LiveTests/EventGridCommandTests.cs index 20151c95f8..99249b9289 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.LiveTests/EventGridCommandTests.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.LiveTests/EventGridCommandTests.cs @@ -43,4 +43,35 @@ public async Task Should_list_eventgrid_topics_by_subscription_and_resource_grou Assert.Equal(JsonValueKind.Array, topics.ValueKind); // Note: topics array might be empty if no Event Grid topics exist in the resource group } + + [Fact] + public async Task Should_list_eventgrid_subscriptions_by_subscription() + { + var result = await CallToolAsync( + "azmcp_eventgrid_subscription_list", + new() + { + { "subscription", Settings.SubscriptionId } + }); + + var subscriptions = result.AssertProperty("subscriptions"); + Assert.Equal(JsonValueKind.Array, subscriptions.ValueKind); + // Note: subscriptions array might be empty if no Event Grid subscriptions exist in the subscription + } + + [Fact] + public async Task Should_list_eventgrid_subscriptions_by_subscription_and_resource_group() + { + var result = await CallToolAsync( + "azmcp_eventgrid_subscription_list", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName } + }); + + var subscriptions = result.AssertProperty("subscriptions"); + Assert.Equal(JsonValueKind.Array, subscriptions.ValueKind); + // Note: subscriptions array might be empty if no Event Grid subscriptions exist in the resource group + } } diff --git a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Subscription/SubscriptionListCommandTests.cs b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Subscription/SubscriptionListCommandTests.cs new file mode 100644 index 0000000000..240d575327 --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Subscription/SubscriptionListCommandTests.cs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.EventGrid.Commands.Subscription; +using Azure.Mcp.Tools.EventGrid.Services; +using Azure.ResourceManager.Models; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Resources.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.EventGrid.UnitTests.Subscription; + +[Trait("Area", "EventGrid")] +public class SubscriptionListCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IEventGridService _eventGridService; + private readonly Azure.Mcp.Core.Services.Azure.Subscription.ISubscriptionService _subscriptionService; + private readonly ILogger _logger; + private readonly SubscriptionListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public SubscriptionListCommandTests() + { + _eventGridService = Substitute.For(); + _subscriptionService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection() + .AddSingleton(_eventGridService) + .AddSingleton(_subscriptionService); + _serviceProvider = collection.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("list", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Fact] + public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions() + { + // Arrange + var subscription = "sub123"; + var expectedSubscriptions = new List + { + new("subscription1", "Microsoft.EventGrid/eventSubscriptions", "WebHook", "https://example.com/webhook1", "Succeeded", null, null, 30, 1440, "2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z"), + new("subscription2", "Microsoft.EventGrid/eventSubscriptions", "StorageQueue", "https://storage.queue.core.windows.net/myqueue", "Succeeded", null, null, 10, 720, "2023-01-03T00:00:00Z", "2023-01-04T00:00:00Z") + }; + + _eventGridService.GetSubscriptionsAsync(Arg.Is(subscription), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(expectedSubscriptions)); + + var args = _commandDefinition.Parse(["--subscription", subscription]); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.NotNull(result!.Subscriptions); + Assert.Equal(expectedSubscriptions.Count, result.Subscriptions!.Count); + Assert.Equal(expectedSubscriptions.Select(s => s.Name), result.Subscriptions.Select(s => s.Name)); + } + + [Fact] + public async Task ExecuteAsync_WithTopicNameFilter_FiltersCorrectly() + { + // Arrange + var subscription = "sub123"; + var resourceGroup = "test-rg"; + var topicName = "test-topic"; + var expectedSubscriptions = new List + { + new("filtered-subscription", "Microsoft.EventGrid/eventSubscriptions", "WebHook", "https://example.com/webhook", "Succeeded", null, null, 30, 1440, "2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z") + }; + + _eventGridService.GetSubscriptionsAsync(Arg.Is(subscription), Arg.Is(resourceGroup), Arg.Is(topicName), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(expectedSubscriptions)); + + var args = _commandDefinition.Parse(["--subscription", subscription, "--resource-group", resourceGroup, "--topic", topicName]); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.NotNull(result!.Subscriptions); + Assert.Single(result.Subscriptions); + Assert.Equal("filtered-subscription", result.Subscriptions.First().Name); + } + + [Fact] + public async Task ExecuteAsync_ReturnsNull_WhenNoSubscriptions() + { + // Arrange + var subscription = "sub123"; + + _eventGridService.GetSubscriptionsAsync(Arg.Is(subscription), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new List())); + + var args = _commandDefinition.Parse(["--subscription", subscription]); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var result = JsonSerializer.Deserialize(json, options); + Assert.NotNull(result); + Assert.NotNull(result.Subscriptions); + Assert.Empty(result.Subscriptions); + } + + [Fact] + public async Task ExecuteAsync_WithLocationFilter_FiltersCorrectly() + { + // Arrange + var subscription = "sub123"; + var location = "eastus"; + var expectedSubscriptions = new List + { + new("location-filtered-subscription", "Microsoft.EventGrid/eventSubscriptions", "WebHook", "https://example.com/webhook", "Succeeded", null, null, 30, 1440, "2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z") + }; + + _eventGridService.GetSubscriptionsAsync(Arg.Is(subscription), Arg.Any(), Arg.Any(), Arg.Is(location), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(expectedSubscriptions)); + + var args = _commandDefinition.Parse(["--subscription", subscription, "--location", location]); + + // Act + var response = await _command.ExecuteAsync(_context, args); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.NotNull(result!.Subscriptions); + Assert.Single(result.Subscriptions); + Assert.Equal("location-filtered-subscription", result.Subscriptions.First().Name); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + // Arrange + _eventGridService.GetSubscriptionsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException>(new Exception("Test error"))); + + var parseResult = _commandDefinition.Parse(["--subscription", "sub"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(500, response.Status); + Assert.Contains("Test error", response.Message); + Assert.Contains("troubleshooting", response.Message); + } + + + + + [Theory] + [InlineData("--subscription sub", true)] + [InlineData("--subscription sub --topic my-topic", true)] + [InlineData("--subscription sub --resource-group rg", true)] + [InlineData("--topic my-topic", true)] // Cross-subscription search - valid with topic alone + [InlineData("", false)] + [InlineData("--location eastus", false)] + [InlineData("--resource-group rg", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + _eventGridService.GetSubscriptionsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("subscription1", "Microsoft.EventGrid/eventSubscriptions", "WebHook", "https://example.com/webhook1", "Succeeded", null, null, 30, 1440, "2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z") + }); + + // Set up subscription service for cross-subscription search scenario + _subscriptionService.GetSubscriptions(Arg.Any(), Arg.Any()) + .Returns(new List()); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(shouldSucceed ? 200 : 400, response.Status); + if (shouldSucceed) + { + Assert.NotNull(response.Results); + } + else + { + Assert.Contains("required", response.Message.ToLower()); + } + } + + + + private class SubscriptionListResult + { + [JsonPropertyName("subscriptions")] + public List? Subscriptions { get; set; } + } +} diff --git a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Topic/TopicListCommandTests.cs b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Topic/TopicListCommandTests.cs index 1a8505678c..47bd2ebaa0 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Topic/TopicListCommandTests.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.UnitTests/Topic/TopicListCommandTests.cs @@ -50,8 +50,8 @@ public async Task ExecuteAsync_NoParameters_ReturnsTopics() new("topic2", "westus", "https://topic2.westus.eventgrid.azure.net/api/events", "Succeeded", "Enabled", "EventGridSchema") }; - _eventGridService.GetTopicsAsync(Arg.Is(subscriptionId), Arg.Any(), Arg.Any()) - .Returns(expectedTopics); + _eventGridService.GetTopicsAsync(Arg.Is(subscriptionId), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(expectedTopics)); var args = _commandDefinition.Parse(["--subscription", subscriptionId]); @@ -77,8 +77,8 @@ public async Task ExecuteAsync_ReturnsEmpty_WhenNoTopics() // Arrange var subscriptionId = "sub123"; - _eventGridService.GetTopicsAsync(Arg.Is(subscriptionId), Arg.Any(), Arg.Any()) - .Returns([]); + _eventGridService.GetTopicsAsync(Arg.Is(subscriptionId), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new List())); var args = _commandDefinition.Parse(["--subscription", subscriptionId]); @@ -91,8 +91,8 @@ public async Task ExecuteAsync_ReturnsEmpty_WhenNoTopics() var json = JsonSerializer.Serialize(response.Results); var result = JsonSerializer.Deserialize(json, EventGridJsonContext.Default.TopicListCommandResult); - Assert.NotNull(result); + Assert.NotNull(result!.Topics); Assert.Empty(result.Topics); } @@ -103,7 +103,7 @@ public async Task ExecuteAsync_HandlesException() var expectedError = "Test error"; var subscriptionId = "sub123"; - _eventGridService.GetTopicsAsync(Arg.Is(subscriptionId), null, Arg.Any()) + _eventGridService.GetTopicsAsync(Arg.Is(subscriptionId), null, Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception(expectedError)); var args = _commandDefinition.Parse(["--subscription", subscriptionId]); @@ -133,8 +133,8 @@ public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldS new("topic1", "eastus", "https://topic1.eastus.eventgrid.azure.net/api/events", "Succeeded", "Enabled", "EventGridSchema"), new("topic2", "westus", "https://topic2.westus.eventgrid.azure.net/api/events", "Succeeded", "Enabled", "EventGridSchema") }; - _eventGridService.GetTopicsAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(expectedTopics); + _eventGridService.GetTopicsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(expectedTopics)); } var parseResult = _commandDefinition.Parse(args.Split(' ', StringSplitOptions.RemoveEmptyEntries)); diff --git a/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.UnitTests/Account/AccountGetCommandTests.cs b/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.UnitTests/Account/AccountGetCommandTests.cs index 0c7d43de19..070defce44 100644 --- a/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.UnitTests/Account/AccountGetCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Storage/tests/Azure.Mcp.Tools.Storage.UnitTests/Account/AccountGetCommandTests.cs @@ -3,6 +3,8 @@ using System.CommandLine; using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Options; using Azure.Mcp.Tools.Storage.Commands.Account; @@ -144,14 +146,14 @@ public void Constructor_InitializesCommandCorrectly() public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) { // Arrange - var originalSubscriptionEnv = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); + var originalSubscriptionEnv = EnvironmentHelpers.GetAzureSubscriptionId(); try { // Clear environment variable for failing test cases to ensure proper validation if (!shouldSucceed && !args.Contains("--subscription")) { - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", null); + EnvironmentHelpers.SetAzureSubscriptionId(null); } if (shouldSucceed) @@ -185,7 +187,7 @@ public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldS finally { // Restore original environment variable - Environment.SetEnvironmentVariable("AZURE_SUBSCRIPTION_ID", originalSubscriptionEnv); + EnvironmentHelpers.SetAzureSubscriptionId(originalSubscriptionEnv); } }