diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 51a145c1f1..049c319d09 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1057,6 +1057,7 @@ Example prompts that generate Azure CLI commands: ### 📣 Azure Event Grid +* "Create an Event Grid topic named 'my-topic' in resource group 'my-resourcegroup' in eastus" * "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-resourcegroup' in my subscription" diff --git a/servers/Azure.Mcp.Server/changelog-entries/1779838409065.yaml b/servers/Azure.Mcp.Server/changelog-entries/1779838409065.yaml new file mode 100644 index 0000000000..488adc46af --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1779838409065.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added `eventgrid topic create` command to create Azure Event Grid topics in a specified resource group and location" diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 8d10807f3f..d6a1c09222 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -2300,6 +2300,13 @@ azmcp deviceregistry namespace list --subscription \ ### Azure Event Grid Operations ```bash +# Create an Azure Event Grid topic in a specified resource group and location +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp eventgrid topic create --subscription \ + --resource-group \ + --topic \ + --location + # List all Event Grid topics in a subscription or resource group # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp eventgrid topic list --subscription \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index f1b8a62cbc..f812677f27 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -438,6 +438,9 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | eventgrid_events_publish | Publish an event to Event Grid topic using with the following data | | eventgrid_events_publish | Publish event to my Event Grid topic with the following events | | eventgrid_events_publish | Send an event to Event Grid topic in resource group with | +| eventgrid_topic_create | Create an Event Grid topic named in resource group in eastus | +| eventgrid_topic_create | Create a new Event Grid topic called in subscription and resource group | +| eventgrid_topic_create | Set up an Event Grid topic in resource group in location westus2 | | eventgrid_topic_list | List all Event Grid topics in my subscription | | eventgrid_topic_list | Show me the Event Grid topics in my subscription | | eventgrid_topic_list | List all Event Grid topics in subscription | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 967dbc23ee..1ef38ca679 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2083,6 +2083,39 @@ "eventgrid_events_publish" ] }, + { + "name": "create_azure_eventgrid_topic", + "description": "Create Azure Event Grid topics in a specified resource group and location for event-driven messaging architectures.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventgrid_topic_create" + ] + }, { "name": "get_azure_file_shares", "description": "Get and list Azure File Shares. Retrieve details of specific file shares or list all file shares in a subscription or resource group.", diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs index 3de95a0c0c..3397f28bfd 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/EventGridJsonContext.cs @@ -19,6 +19,7 @@ namespace Azure.Mcp.Tools.EventGrid.Commands; [JsonSerializable(typeof(CloudEvent))] // For CloudEvent schema input deserialization [JsonSerializable(typeof(EventGridEventInput))] // For EventGrid schema input deserialization [JsonSerializable(typeof(CustomEvent))] // For custom event schema input deserialization +[JsonSerializable(typeof(TopicCreateCommand.TopicCreateCommandResult))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] internal sealed partial class EventGridJsonContext : JsonSerializerContext { diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicCreateCommand.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicCreateCommand.cs new file mode 100644 index 0000000000..ab9ba3bf92 --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Commands/Topic/TopicCreateCommand.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.EventGrid.Options; +using Azure.Mcp.Tools.EventGrid.Options.Topic; +using Azure.Mcp.Tools.EventGrid.Services; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.EventGrid.Commands.Topic; + +[CommandMetadata( + Id = "f8a3b2c1-4d5e-6f7a-8b9c-0d1e2f3a4b5c", + Name = "create", + Title = "Create Event Grid Topic", + Description = "Create an Azure Event Grid topic in a specified resource group and location. Returns the created topic's endpoint, provisioning state, and configuration details.", + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class TopicCreateCommand(ILogger logger, IEventGridService eventGridService) : BaseEventGridCommand +{ + private readonly ILogger _logger = logger; + private readonly IEventGridService _eventGridService = eventGridService; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(EventGridOptionDefinitions.TopicName.AsRequired()); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsRequired()); + command.Options.Add(EventGridOptionDefinitions.Location.AsRequired()); + } + + protected override TopicCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Topic = parseResult.GetValueOrDefault(EventGridOptionDefinitions.TopicName); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup); + options.Location = parseResult.GetValueOrDefault(EventGridOptionDefinitions.Location); + 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 topic = await _eventGridService.CreateTopicAsync( + options.Topic!, + options.ResourceGroup!, + options.Location!, + options.Subscription!, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(new TopicCreateCommandResult(topic), EventGridJsonContext.Default.TopicCreateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating Event Grid topic. Topic: {Topic}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}.", + options.Topic, options.ResourceGroup, options.Subscription); + HandleException(context, ex); + } + + return context.Response; + } + + internal record TopicCreateCommandResult(EventGridTopicInfo? Topic); +} diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs b/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs index f0e630f28a..88749b2b0b 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/EventGridSetup.cs @@ -23,6 +23,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -47,6 +48,7 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) // Register Topic commands topics.AddCommand(serviceProvider); + topics.AddCommand(serviceProvider); // Register Subscription commands subscriptions.AddCommand(serviceProvider); diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Options/Topic/TopicCreateOptions.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Options/Topic/TopicCreateOptions.cs new file mode 100644 index 0000000000..6f48990939 --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Options/Topic/TopicCreateOptions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.EventGrid.Options.Topic; + +public class TopicCreateOptions : BaseEventGridOptions +{ + public string? Topic { 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 2fb8f80828..67ce9d11a9 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Services/EventGridService.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Services/EventGridService.cs @@ -533,4 +533,31 @@ private static async Task AddSubscriptionsFromSystemTopic( } } + public async Task CreateTopicAsync( + string topic, + string resourceGroup, + string location, + string subscription, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken); + + var topicData = new EventGridTopicData(new Azure.Core.AzureLocation(location)) + { + InputSchema = EventGridInputSchema.EventGridSchema, + PublicNetworkAccess = EventGridPublicNetworkAccess.Enabled + }; + + var operation = await resourceGroupResource.Value.GetEventGridTopics().CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + topic, + topicData, + cancellationToken); + + return CreateTopicInfo(operation.Value.Data); + } + } diff --git a/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs b/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs index d31259ed36..4f4c346e46 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/src/Services/IEventGridService.cs @@ -32,4 +32,13 @@ Task PublishEventAsync( string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + + Task CreateTopicAsync( + string topic, + string resourceGroup, + string location, + string subscription, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); } diff --git a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/EventGridCommandTests.cs b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/EventGridCommandTests.cs index 800aea83d0..7b159ffd14 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/EventGridCommandTests.cs +++ b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/EventGridCommandTests.cs @@ -441,4 +441,27 @@ public async Task Should_publish_with_default_schema_when_not_specified() Assert.Equal("Success", status); Assert.Equal(1, publishedEventCount); } + + [Fact] + public async Task Should_create_eventgrid_topic() + { + var topicName = RegisterOrRetrieveVariable("create_topic_name", $"topic-{Guid.NewGuid():N}"[..24]); + + var result = await CallToolAsync( + "eventgrid_topic_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "location", "eastus" }, + { "topic", topicName }, + { "tenant", Settings.TenantId } + }); + + var topic = result.AssertProperty("topic"); + Assert.Equal(JsonValueKind.Object, topic.ValueKind); + topic.AssertProperty("name"); + topic.AssertProperty("endpoint"); + Assert.Equal("Succeeded", topic.GetProperty("provisioningState").GetString()); + } } diff --git a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/Topic/TopicCreateCommandTests.cs b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/Topic/TopicCreateCommandTests.cs new file mode 100644 index 0000000000..70bb8abce1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/Topic/TopicCreateCommandTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.EventGrid.Commands; +using Azure.Mcp.Tools.EventGrid.Commands.Topic; +using Azure.Mcp.Tools.EventGrid.Services; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.EventGrid.Tests.Topic; + +public class TopicCreateCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("create", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Fact] + public async Task ExecuteAsync_ValidParameters_ReturnsCreatedTopic() + { + var expectedTopic = new Models.EventGridTopicInfo( + "test-topic", "eastus", + "https://test-topic.eastus.eventgrid.azure.net/api/events", + "Succeeded", "Enabled", "EventGridSchema"); + + Service.CreateTopicAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(expectedTopic)); + + var response = await ExecuteCommandAsync( + "--topic", "test-topic", + "--resource-group", "test-rg", + "--location", "eastus", + "--subscription", "sub123"); + + var result = ValidateAndDeserializeResponse(response, EventGridJsonContext.Default.TopicCreateCommandResult); + Assert.NotNull(result.Topic); + Assert.Equal("test-topic", result.Topic.Name); + Assert.Equal("eastus", result.Topic.Location); + } + + [Theory] + [InlineData("--topic test --resource-group rg --location eastus --subscription sub", true)] + [InlineData("--topic test --resource-group rg --subscription sub", false)] // missing location + [InlineData("--topic test --location eastus --subscription sub", false)] // missing resource-group + [InlineData("--resource-group rg --location eastus --subscription sub", false)] // missing topic + [InlineData("", false)] // missing args + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.CreateTopicAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult( + new Models.EventGridTopicInfo("test", "eastus", null, "Succeeded", "Enabled", "EventGridSchema"))); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.CreateTopicAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync( + "--topic", "test-topic", + "--resource-group", "test-rg", + "--location", "eastus", + "--subscription", "sub123"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + } +} diff --git a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/assets.json b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/assets.json index b85b6eb16f..c6bc2f3062 100644 --- a/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.EventGrid/tests/Azure.Mcp.Tools.EventGrid.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.EventGrid.Tests", - "Tag": "Azure.Mcp.Tools.EventGrid.Tests_f3b2b0c29f" + "Tag": "Azure.Mcp.Tools.EventGrid.Tests_c4ec5088cd" }