From a8bc055fc2ba351dffbe699ccdc9789e0349ba1c Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Tue, 2 Sep 2025 23:34:57 -0700 Subject: [PATCH 01/15] Add SQL Server firewall rule create and delete commands and unit tests --- Directory.Packages.props | 1 + .../src/Azure.Mcp.Tools.Sql.csproj | 1 + .../FirewallRule/FirewallRuleCreateCommand.cs | 112 +++++ .../FirewallRule/FirewallRuleDeleteCommand.cs | 99 +++++ .../src/Commands/SqlJsonContext.cs | 2 + .../FirewallRule/FirewallRuleCreateOptions.cs | 19 + .../FirewallRule/FirewallRuleDeleteOptions.cs | 13 + .../src/Options/SqlOptionDefinitions.cs | 27 ++ .../src/Services/ISqlService.cs | 42 ++ .../src/Services/SqlService.cs | 130 +++++- tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs | 2 + .../SqlCommandTests.cs | 133 ++++++ ...p.Tools.Sql.UnitTests.sln.DotSettings.user | 38 ++ .../FirewallRuleCreateCommandTests.cs | 394 ++++++++++++++++++ 14 files changed, 1010 insertions(+), 3 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleCreateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs create mode 100644 tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleCreateOptions.cs create mode 100644 tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleDeleteOptions.cs create mode 100644 tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user create mode 100644 tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 054998c98..e15ac58bb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,6 +36,7 @@ + diff --git a/tools/Azure.Mcp.Tools.Sql/src/Azure.Mcp.Tools.Sql.csproj b/tools/Azure.Mcp.Tools.Sql/src/Azure.Mcp.Tools.Sql.csproj index 374869120..574ec4ba0 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Azure.Mcp.Tools.Sql.csproj +++ b/tools/Azure.Mcp.Tools.Sql/src/Azure.Mcp.Tools.Sql.csproj @@ -12,6 +12,7 @@ + diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleCreateCommand.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleCreateCommand.cs new file mode 100644 index 000000000..8faebc4c0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleCreateCommand.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Tools.Sql.Models; +using Azure.Mcp.Tools.Sql.Options; +using Azure.Mcp.Tools.Sql.Options.FirewallRule; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.Sql.Commands.FirewallRule; + +public sealed class FirewallRuleCreateCommand(ILogger logger) + : BaseSqlCommand(logger) +{ + private const string CommandTitle = "Create SQL Server Firewall Rule"; + + private readonly Option _firewallRuleNameOption = SqlOptionDefinitions.FirewallRuleNameOption; + private readonly Option _startIpAddressOption = SqlOptionDefinitions.StartIpAddressOption; + private readonly Option _endIpAddressOption = SqlOptionDefinitions.EndIpAddressOption; + + public override string Name => "create"; + + public override string Description => + """ + Creates a firewall rule for a SQL server. Firewall rules control which IP addresses + are allowed to connect to the SQL server. You can specify either a single IP address + (by setting start and end IP to the same value) or a range of IP addresses. Returns + the created firewall rule with its properties. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = false }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_firewallRuleNameOption); + command.AddOption(_startIpAddressOption); + command.AddOption(_endIpAddressOption); + } + + protected override FirewallRuleCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.FirewallRuleName = parseResult.GetValueForOption(_firewallRuleNameOption); + options.StartIpAddress = parseResult.GetValueForOption(_startIpAddressOption); + options.EndIpAddress = parseResult.GetValueForOption(_endIpAddressOption); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var sqlService = context.GetService(); + + var firewallRule = await sqlService.CreateFirewallRuleAsync( + options.Server!, + options.ResourceGroup!, + options.Subscription!, + options.FirewallRuleName!, + options.StartIpAddress!, + options.EndIpAddress!, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create( + new FirewallRuleCreateResult(firewallRule), + SqlJsonContext.Default.FirewallRuleCreateResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating SQL server firewall rule. Server: {Server}, ResourceGroup: {ResourceGroup}, Rule: {Rule}, Options: {@Options}", + options.Server, options.ResourceGroup, options.FirewallRuleName, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + Azure.RequestFailedException reqEx when reqEx.Status == 404 => + "SQL server not found. Verify the server name, resource group, and that you have access.", + Azure.RequestFailedException reqEx when reqEx.Status == 403 => + $"Authorization failed creating the firewall rule. Verify you have appropriate permissions. Details: {reqEx.Message}", + Azure.RequestFailedException reqEx when reqEx.Status == 409 => + "A firewall rule with this name already exists. Choose a different name or update the existing rule.", + Azure.RequestFailedException reqEx => reqEx.Message, + ArgumentException argEx => $"Invalid IP address format: {argEx.Message}", + _ => base.GetErrorMessage(ex) + }; + + protected override int GetStatusCode(Exception ex) => ex switch + { + Azure.RequestFailedException reqEx => reqEx.Status, + ArgumentException => 400, + _ => base.GetStatusCode(ex) + }; + + internal record FirewallRuleCreateResult(SqlServerFirewallRule FirewallRule); +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs new file mode 100644 index 000000000..963b1701e --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Tools.Sql.Options; +using Azure.Mcp.Tools.Sql.Options.FirewallRule; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.Sql.Commands.FirewallRule; + +public sealed class FirewallRuleDeleteCommand(ILogger logger) + : BaseSqlCommand(logger) +{ + private const string CommandTitle = "Delete SQL Server Firewall Rule"; + + private readonly Option _firewallRuleNameOption = SqlOptionDefinitions.FirewallRuleNameOption; + + public override string Name => "delete"; + + public override string Description => + """ + Deletes a firewall rule from a SQL server. This operation removes the specified + firewall rule, potentially restricting access for the IP addresses that were + previously allowed by this rule. The operation is idempotent - if the rule + doesn't exist, no error is returned. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() { Destructive = true, ReadOnly = false }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_firewallRuleNameOption); + } + + protected override FirewallRuleDeleteOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.FirewallRuleName = parseResult.GetValueForOption(_firewallRuleNameOption); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + var options = BindOptions(parseResult); + + try + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var sqlService = context.GetService(); + + var deleted = await sqlService.DeleteFirewallRuleAsync( + options.Server!, + options.ResourceGroup!, + options.Subscription!, + options.FirewallRuleName!, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create( + new FirewallRuleDeleteResult(deleted, options.FirewallRuleName!), + SqlJsonContext.Default.FirewallRuleDeleteResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting SQL server firewall rule. Server: {Server}, ResourceGroup: {ResourceGroup}, Rule: {Rule}, Options: {@Options}", + options.Server, options.ResourceGroup, options.FirewallRuleName, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + Azure.RequestFailedException reqEx when reqEx.Status == 404 => + "SQL server or firewall rule not found. Verify the server name, rule name, resource group, and that you have access.", + Azure.RequestFailedException reqEx when reqEx.Status == 403 => + $"Authorization failed deleting the firewall rule. Verify you have appropriate permissions. Details: {reqEx.Message}", + Azure.RequestFailedException reqEx => reqEx.Message, + _ => base.GetErrorMessage(ex) + }; + + protected override int GetStatusCode(Exception ex) => ex switch + { + Azure.RequestFailedException reqEx => reqEx.Status, + _ => base.GetStatusCode(ex) + }; + + internal record FirewallRuleDeleteResult(bool Deleted, string RuleName); +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs index c9044fc3b..690910e5c 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs @@ -15,6 +15,8 @@ namespace Azure.Mcp.Tools.Sql.Commands; [JsonSerializable(typeof(DatabaseListCommand.DatabaseListResult))] [JsonSerializable(typeof(EntraAdminListCommand.EntraAdminListResult))] [JsonSerializable(typeof(FirewallRuleListCommand.FirewallRuleListResult))] +[JsonSerializable(typeof(FirewallRuleCreateCommand.FirewallRuleCreateResult))] +[JsonSerializable(typeof(FirewallRuleDeleteCommand.FirewallRuleDeleteResult))] [JsonSerializable(typeof(ElasticPoolListCommand.ElasticPoolListResult))] [JsonSerializable(typeof(SqlDatabase))] [JsonSerializable(typeof(SqlServerEntraAdministrator))] diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleCreateOptions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleCreateOptions.cs new file mode 100644 index 000000000..2ebbbd7ba --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleCreateOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Tools.Sql.Options; + +namespace Azure.Mcp.Tools.Sql.Options.FirewallRule; + +public class FirewallRuleCreateOptions : BaseSqlOptions +{ + [JsonPropertyName(SqlOptionDefinitions.FirewallRuleName)] + public string? FirewallRuleName { get; set; } + + [JsonPropertyName(SqlOptionDefinitions.StartIpAddress)] + public string? StartIpAddress { get; set; } + + [JsonPropertyName(SqlOptionDefinitions.EndIpAddress)] + public string? EndIpAddress { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleDeleteOptions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleDeleteOptions.cs new file mode 100644 index 000000000..72279a5dc --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/FirewallRule/FirewallRuleDeleteOptions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Tools.Sql.Options; + +namespace Azure.Mcp.Tools.Sql.Options.FirewallRule; + +public class FirewallRuleDeleteOptions : BaseSqlOptions +{ + [JsonPropertyName(SqlOptionDefinitions.FirewallRuleName)] + public string? FirewallRuleName { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs index 4b184876b..7099defdb 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs @@ -7,6 +7,9 @@ public static class SqlOptionDefinitions { public const string ServerName = "server"; public const string DatabaseName = "database"; + public const string FirewallRuleName = "name"; + public const string StartIpAddress = "start-ip-address"; + public const string EndIpAddress = "end-ip-address"; public static readonly Option Server = new( $"--{ServerName}", @@ -23,4 +26,28 @@ public static class SqlOptionDefinitions { IsRequired = true }; + + public static readonly Option FirewallRuleNameOption = new( + $"--{FirewallRuleName}", + "The name of the firewall rule." + ) + { + IsRequired = true + }; + + public static readonly Option StartIpAddressOption = new( + $"--{StartIpAddress}", + "The start IP address of the firewall rule range." + ) + { + IsRequired = true + }; + + public static readonly Option EndIpAddressOption = new( + $"--{EndIpAddress}", + "The end IP address of the firewall rule range." + ) + { + IsRequired = true + }; } diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs index 766abf709..3475bc0f6 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs @@ -90,4 +90,46 @@ Task> ListFirewallRulesAsync( string subscription, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken = default); + + /// + /// Creates a firewall rule for a SQL server. + /// + /// The name of the SQL server + /// The name of the resource group + /// The subscription ID or name + /// The name of the firewall rule + /// The start IP address of the firewall rule range + /// The end IP address of the firewall rule range + /// Optional retry policy options + /// Cancellation token + /// The created SQL server firewall rule + Task CreateFirewallRuleAsync( + string serverName, + string resourceGroup, + string subscription, + string firewallRuleName, + string startIpAddress, + string endIpAddress, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default); + + /// + /// Deletes a firewall rule from a SQL server. + /// + /// The name of the SQL server + /// The name of the resource group + /// The subscription ID or name + /// The name of the firewall rule to delete + /// Optional retry policy options + /// Cancellation token + /// True if the firewall rule was successfully deleted + Task DeleteFirewallRuleAsync( + string serverName, + string resourceGroup, + string subscription, + string firewallRuleName, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default); + + } diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs index 2fb587f64..ed9751523 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -8,6 +8,8 @@ using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.Sql.Models; using Azure.Mcp.Tools.Sql.Services.Models; +using Azure.ResourceManager.Sql; +using Azure.ResourceManager.Sql.Models; using Microsoft.Extensions.Logging; namespace Azure.Mcp.Tools.Sql.Services; @@ -212,9 +214,131 @@ public async Task> ListFirewallRulesAsync( } } + /// + /// Creates a firewall rule for an Azure SQL Server. + /// Firewall rules control which IP addresses are allowed to connect to the SQL server. + /// + /// The name of the SQL server to create firewall rule for + /// The name of the resource group containing the server + /// The subscription ID or name + /// The name of the firewall rule to create + /// The start IP address of the firewall rule range + /// The end IP address of the firewall rule range + /// Optional retry policy configuration for resilient operations + /// Token to observe for cancellation requests + /// The created firewall rule + /// Thrown when required parameters are null or empty + public async Task CreateFirewallRuleAsync( + string serverName, + string resourceGroup, + string subscription, + string firewallRuleName, + string startIpAddress, + string endIpAddress, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters(serverName, resourceGroup, subscription, firewallRuleName, startIpAddress, endIpAddress); + + try + { + // Use ARM client directly for create operations + var armClient = await CreateArmClientAsync(null, retryPolicy); + var subscriptionResource = armClient.GetSubscriptionResource(Azure.ResourceManager.Resources.SubscriptionResource.CreateResourceIdentifier(subscription)); + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + var sqlServerResource = await resourceGroupResource.Value.GetSqlServers().GetAsync(serverName); + + var firewallRuleData = new Azure.ResourceManager.Sql.SqlFirewallRuleData() + { + StartIPAddress = startIpAddress, + EndIPAddress = endIpAddress + }; + + var operation = await sqlServerResource.Value.GetSqlFirewallRules().CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + firewallRuleName, + firewallRuleData, + cancellationToken); + + var firewallRule = operation.Value; + + return new SqlServerFirewallRule( + Name: firewallRule.Data.Name, + Id: firewallRule.Data.Id.ToString(), + Type: firewallRule.Data.ResourceType?.ToString() ?? "Unknown", + StartIpAddress: firewallRule.Data.StartIPAddress, + EndIpAddress: firewallRule.Data.EndIPAddress + ); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating SQL server firewall rule. Server: {Server}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}, Rule: {Rule}", + serverName, resourceGroup, subscription, firewallRuleName); + throw; + } + } + + /// + /// Deletes a firewall rule from an Azure SQL Server. + /// + /// The name of the SQL server + /// The name of the resource group containing the server + /// The subscription ID or name + /// The name of the firewall rule to delete + /// Optional retry policy configuration for resilient operations + /// Token to observe for cancellation requests + /// True if the firewall rule was successfully deleted + /// Thrown when required parameters are null or empty + public async Task DeleteFirewallRuleAsync( + string serverName, + string resourceGroup, + string subscription, + string firewallRuleName, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters(serverName, resourceGroup, subscription, firewallRuleName); + + try + { + // Use ARM client directly for delete operations + var armClient = await CreateArmClientAsync(null, retryPolicy); + var subscriptionResource = armClient.GetSubscriptionResource(Azure.ResourceManager.Resources.SubscriptionResource.CreateResourceIdentifier(subscription)); + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + var sqlServerResource = await resourceGroupResource.Value.GetSqlServers().GetAsync(serverName); + + var firewallRuleResource = await sqlServerResource.Value.GetSqlFirewallRules().GetAsync(firewallRuleName); + + await firewallRuleResource.Value.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); + + _logger.LogInformation( + "Successfully deleted SQL server firewall rule. Server: {Server}, ResourceGroup: {ResourceGroup}, Rule: {Rule}", + serverName, resourceGroup, firewallRuleName); + + return true; + } + catch (Azure.RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogWarning( + "Firewall rule not found during delete operation. Server: {Server}, ResourceGroup: {ResourceGroup}, Rule: {Rule}", + serverName, resourceGroup, firewallRuleName); + + // Return false to indicate the rule was not found (idempotent delete) + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting SQL server firewall rule. Server: {Server}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}, Rule: {Rule}", + serverName, resourceGroup, subscription, firewallRuleName); + throw; + } + } + private static SqlDatabase ConvertToSqlDatabaseModel(JsonElement item) { - SqlDatabaseData? sqlDatabase = SqlDatabaseData.FromJson(item); + Azure.Mcp.Tools.Sql.Services.Models.SqlDatabaseData? sqlDatabase = Azure.Mcp.Tools.Sql.Services.Models.SqlDatabaseData.FromJson(item); if (sqlDatabase == null) throw new InvalidOperationException("Failed to parse SQL database data"); @@ -282,7 +406,7 @@ private static SqlElasticPool ConvertToSqlElasticPoolModel(JsonElement item) State: elasticPool.Properties?.State, CreationDate: elasticPool.Properties?.CreatedOn, MaxSizeBytes: elasticPool.Properties?.MaxSizeBytes, - PerDatabaseSettings: elasticPool.Properties?.PerDatabaseSettings != null ? new ElasticPoolPerDatabaseSettings( + PerDatabaseSettings: elasticPool.Properties?.PerDatabaseSettings != null ? new Azure.Mcp.Tools.Sql.Models.ElasticPoolPerDatabaseSettings( MinCapacity: elasticPool.Properties.PerDatabaseSettings.MinCapacity, MaxCapacity: elasticPool.Properties.PerDatabaseSettings.MaxCapacity ) : null, @@ -297,7 +421,7 @@ private static SqlElasticPool ConvertToSqlElasticPoolModel(JsonElement item) private static SqlServerFirewallRule ConvertToSqlFirewallRuleModel(JsonElement item) { - SqlFirewallRuleData? firewallRule = SqlFirewallRuleData.FromJson(item); + Azure.Mcp.Tools.Sql.Services.Models.SqlFirewallRuleData? firewallRule = Azure.Mcp.Tools.Sql.Services.Models.SqlFirewallRuleData.FromJson(item); if (firewallRule == null) throw new InvalidOperationException("Failed to parse SQL firewall rule data"); diff --git a/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs b/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs index 16a8216eb..8fdfa0d47 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs @@ -48,5 +48,7 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor server.AddSubGroup(firewallRule); firewallRule.AddCommand("list", new FirewallRuleListCommand(loggerFactory.CreateLogger())); + firewallRule.AddCommand("create", new FirewallRuleCreateCommand(loggerFactory.CreateLogger())); + firewallRule.AddCommand("delete", new FirewallRuleDeleteCommand(loggerFactory.CreateLogger())); } } diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs index 482ae3c9a..fb1ae7d31 100644 --- a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs @@ -277,4 +277,137 @@ public async Task Should_ListElasticPools_Successfully() var poolType = firstPool.GetProperty("type").GetString(); Assert.Equal("Microsoft.Sql/servers/elasticPools", poolType, ignoreCase: true); } + + [Fact] + public async Task Should_CreateFirewallRule_Successfully() + { + // Use the deployed test SQL server + var serverName = Settings.ResourceBaseName; + var ruleName = $"test-rule-{DateTime.UtcNow:yyyyMMddHHmmss}"; + var startIp = "192.168.1.100"; + var endIp = "192.168.1.200"; + + var result = await CallToolAsync( + "azmcp_sql_server_firewall-rule_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "server", serverName }, + { "name", ruleName }, + { "start-ip-address", startIp }, + { "end-ip-address", endIp } + }); + + // Should successfully create the firewall rule + var firewallRule = result.AssertProperty("firewallRule"); + Assert.Equal(JsonValueKind.Object, firewallRule.ValueKind); + + // Verify firewall rule properties + var name = firewallRule.GetProperty("name").GetString(); + Assert.Equal(ruleName, name); + + var ruleType = firewallRule.GetProperty("type").GetString(); + Assert.Equal("Microsoft.Sql/servers/firewallRules", ruleType, ignoreCase: true); + + var ruleStartIp = firewallRule.GetProperty("startIpAddress").GetString(); + Assert.Equal(startIp, ruleStartIp); + + var ruleEndIp = firewallRule.GetProperty("endIpAddress").GetString(); + Assert.Equal(endIp, ruleEndIp); + + var id = firewallRule.GetProperty("id").GetString(); + Assert.NotNull(id); + Assert.Contains(serverName, id); + Assert.Contains(ruleName, id); + } + + [Fact] + public async Task Should_DeleteFirewallRule_Successfully() + { + // Use the deployed test SQL server + var serverName = Settings.ResourceBaseName; + var ruleName = $"test-delete-rule-{DateTime.UtcNow:yyyyMMddHHmmss}"; + var startIp = "192.168.2.100"; + var endIp = "192.168.2.200"; + + // First create a firewall rule to delete + await CallToolAsync( + "azmcp_sql_server_firewall-rule_create", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "server", serverName }, + { "name", ruleName }, + { "start-ip-address", startIp }, + { "end-ip-address", endIp } + }); + + // Now delete the firewall rule + var result = await CallToolAsync( + "azmcp_sql_server_firewall-rule_delete", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "server", serverName }, + { "name", ruleName } + }); + + // Should successfully delete the firewall rule + var deleted = result.AssertProperty("deleted").GetBoolean(); + Assert.True(deleted); + + var deletedRuleName = result.AssertProperty("ruleName").GetString(); + Assert.Equal(ruleName, deletedRuleName); + } + + [Fact] + public async Task Should_DeleteNonExistentFirewallRule_ReturnsFalse() + { + // Use the deployed test SQL server + var serverName = Settings.ResourceBaseName; + var nonExistentRuleName = $"non-existent-rule-{DateTime.UtcNow:yyyyMMddHHmmss}"; + + var result = await CallToolAsync( + "azmcp_sql_server_firewall-rule_delete", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "server", serverName }, + { "name", nonExistentRuleName } + }); + + // Should return false when trying to delete non-existent rule (idempotent) + var deleted = result.AssertProperty("deleted").GetBoolean(); + Assert.False(deleted); + + var deletedRuleName = result.AssertProperty("ruleName").GetString(); + Assert.Equal(nonExistentRuleName, deletedRuleName); + } + + [Theory] + [InlineData("--invalid-param")] + [InlineData("--subscription invalidSub")] + [InlineData("--subscription sub --resource-group rg")] // Missing server, name, and IP addresses + [InlineData("--subscription sub --resource-group rg --server server --name rule1")] // Missing IP addresses + public async Task Should_Return400_WithInvalidFirewallRuleCreateInput(string args) + { + try + { + var result = await CallToolAsync("azmcp_sql_server_firewall-rule_create", + new Dictionary { { "args", args } }); + + // If we get here, the command didn't fail as expected + Assert.Fail("Expected command to fail with invalid input, but it succeeded"); + } + catch (Exception ex) + { + // Expected behavior - the command should fail with invalid input + Assert.NotNull(ex.Message); + Assert.NotEmpty(ex.Message); + } + } } diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user new file mode 100644 index 000000000..e80a451f5 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user @@ -0,0 +1,38 @@ + + <SessionState ContinuousTestingMode="0" Name="DatabaseListCommandTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::ACFA8C54-9889-EC7A-3C62-DE5077E15DAC::net9.0::Azure.Mcp.Tools.Sql.UnitTests.Database.DatabaseListCommandTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="Session #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <And> + <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> + <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> + </And> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="Session #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <And> + <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> + <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> + </And> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="FirewallRuleCreateCommandTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::ACFA8C54-9889-EC7A-3C62-DE5077E15DAC::net9.0::Azure.Mcp.Tools.Sql.UnitTests.FirewallRule.FirewallRuleCreateCommandTests</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="Session #4" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <And> + <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> + <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> + </And> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <And> + <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> + <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> + </And> +</SessionState> + <SessionState ContinuousTestingMode="0" Name="All tests from &lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> +</SessionState> \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs new file mode 100644 index 000000000..66a524a5d --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.Sql.Commands.FirewallRule; +using Azure.Mcp.Tools.Sql.Models; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Sql.UnitTests.FirewallRule; + +public class FirewallRuleCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ISqlService _service; + private readonly ILogger _logger; + private readonly FirewallRuleCreateCommand _command; + private readonly CommandContext _context; + private readonly Parser _parser; + + public FirewallRuleCreateCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_service); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _parser = new(_command.GetCommand()); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("create", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + Assert.Contains("Creates a firewall rule", command.Description); + } + + [Theory] + [InlineData("--subscription sub --resource-group rg --server server --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", true)] + [InlineData("--subscription sub --resource-group rg --server server --name rule1 --start-ip-address 192.168.1.1", false)] // Missing end IP + [InlineData("--subscription sub --resource-group rg --server server --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing rule name + [InlineData("--subscription sub --resource-group rg --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing server + [InlineData("--subscription sub --server server --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing resource group + [InlineData("--resource-group rg --server server --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing subscription + [InlineData("", false)] // Missing all required parameters + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var expectedFirewallRule = new SqlServerFirewallRule( + "rule1", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Sql/servers/server/firewallRules/rule1", + "Microsoft.Sql/servers/firewallRules", + "192.168.1.1", + "192.168.1.255"); + + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedFirewallRule); + } + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(shouldSucceed ? 200 : 400, response.Status); + if (shouldSucceed) + { + Assert.Equal("Success", response.Message); + } + else + { + Assert.Contains("required", response.Message.ToLower()); + } + } + + [Fact] + public async Task ExecuteAsync_CreatesFirewallRuleSuccessfully() + { + // Arrange + var expectedFirewallRule = new SqlServerFirewallRule( + "TestRule", + "/subscriptions/testsub/resourceGroups/testrg/providers/Microsoft.Sql/servers/testserver/firewallRules/TestRule", + "Microsoft.Sql/servers/firewallRules", + "192.168.1.1", + "192.168.1.255"); + + _service.CreateFirewallRuleAsync( + "testserver", + "testrg", + "testsub", + "TestRule", + "192.168.1.1", + "192.168.1.255", + Arg.Any(), + Arg.Any()) + .Returns(expectedFirewallRule); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + // Arrange + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new Exception("Test error"))); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + + // 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); + } + + [Fact] + public async Task ExecuteAsync_Handles404Error() + { + // Arrange + var requestException = new Azure.RequestFailedException(404, "Server not found"); + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(404, response.Status); + Assert.Contains("SQL server not found", response.Message); + } + + [Fact] + public async Task ExecuteAsync_Handles403Error() + { + // Arrange + var requestException = new Azure.RequestFailedException(403, "Access denied"); + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(403, response.Status); + Assert.Contains("Authorization failed", response.Message); + } + + [Fact] + public async Task ExecuteAsync_Handles409Error() + { + // Arrange + var requestException = new Azure.RequestFailedException(409, "Conflict - rule already exists"); + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(409, response.Status); + Assert.Contains("firewall rule with this name already exists", response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesArgumentException() + { + // Arrange + var argumentException = new ArgumentException("Invalid IP address format"); + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(argumentException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address invalid --end-ip-address invalid"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(400, response.Status); + Assert.Contains("Invalid IP address format", response.Message); + } + + [Fact] + public async Task ExecuteAsync_CallsServiceWithCorrectParameters() + { + // Arrange + const string serverName = "testserver"; + const string resourceGroup = "testrg"; + const string subscription = "testsub"; + const string ruleName = "TestRule"; + const string startIp = "192.168.1.1"; + const string endIp = "192.168.1.255"; + + var expectedFirewallRule = new SqlServerFirewallRule( + ruleName, + $"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Sql/servers/{serverName}/firewallRules/{ruleName}", + "Microsoft.Sql/servers/firewallRules", + startIp, + endIp); + + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedFirewallRule); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse($"--subscription {subscription} --resource-group {resourceGroup} --server {serverName} --name {ruleName} --start-ip-address {startIp} --end-ip-address {endIp}"); + + // Act + await _command.ExecuteAsync(context, parseResult); + + // Assert + await _service.Received(1).CreateFirewallRuleAsync( + serverName, + resourceGroup, + subscription, + ruleName, + startIp, + endIp, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithRetryPolicyOptions() + { + // Arrange + var expectedFirewallRule = new SqlServerFirewallRule( + "TestRule", + "/subscriptions/testsub/resourceGroups/testrg/providers/Microsoft.Sql/servers/testserver/firewallRules/TestRule", + "Microsoft.Sql/servers/firewallRules", + "192.168.1.1", + "192.168.1.255"); + + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedFirewallRule); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255 --retry-max-retries 3"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + + // Verify the service was called with retry policy + await _service.Received(1).CreateFirewallRuleAsync( + "testserver", + "testrg", + "testsub", + "TestRule", + "192.168.1.1", + "192.168.1.255", + Arg.Is(r => r != null && r.MaxRetries == 3), + Arg.Any()); + } + + [Theory] + [InlineData("10.0.0.1", "10.0.0.1")] // Single IP + [InlineData("192.168.1.1", "192.168.1.255")] // IP range + [InlineData("0.0.0.0", "255.255.255.255")] // Full range + public async Task ExecuteAsync_HandlesVariousIPFormats(string startIp, string endIp) + { + // Arrange + var expectedFirewallRule = new SqlServerFirewallRule( + "TestRule", + "/subscriptions/testsub/resourceGroups/testrg/providers/Microsoft.Sql/servers/testserver/firewallRules/TestRule", + "Microsoft.Sql/servers/firewallRules", + startIp, + endIp); + + _service.CreateFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedFirewallRule); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse($"--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address {startIp} --end-ip-address {endIp}"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + } +} From 62942253fdf982f9bce4041709f92f184b7a04d0 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 00:16:12 -0700 Subject: [PATCH 02/15] fix format issue --- tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs | 2 +- tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs index 3475bc0f6..bbe6dc80a 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs @@ -131,5 +131,5 @@ Task DeleteFirewallRuleAsync( RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken = default); - + } diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs index ed9751523..24d6d2370 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -261,7 +261,7 @@ public async Task CreateFirewallRuleAsync( cancellationToken); var firewallRule = operation.Value; - + return new SqlServerFirewallRule( Name: firewallRule.Data.Name, Id: firewallRule.Data.Id.ToString(), @@ -309,9 +309,9 @@ public async Task DeleteFirewallRuleAsync( var sqlServerResource = await resourceGroupResource.Value.GetSqlServers().GetAsync(serverName); var firewallRuleResource = await sqlServerResource.Value.GetSqlFirewallRules().GetAsync(firewallRuleName); - + await firewallRuleResource.Value.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); - + _logger.LogInformation( "Successfully deleted SQL server firewall rule. Server: {Server}, ResourceGroup: {ResourceGroup}, Rule: {Rule}", serverName, resourceGroup, firewallRuleName); @@ -323,7 +323,7 @@ public async Task DeleteFirewallRuleAsync( _logger.LogWarning( "Firewall rule not found during delete operation. Server: {Server}, ResourceGroup: {ResourceGroup}, Rule: {Rule}", serverName, resourceGroup, firewallRuleName); - + // Return false to indicate the rule was not found (idempotent delete) return false; } From b591b1bccc24584dfdbbdd35c9215d2365a41ec1 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 00:32:28 -0700 Subject: [PATCH 03/15] Delete Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user --- ...p.Tools.Sql.UnitTests.sln.DotSettings.user | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user deleted file mode 100644 index e80a451f5..000000000 --- a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Azure.Mcp.Tools.Sql.UnitTests.sln.DotSettings.user +++ /dev/null @@ -1,38 +0,0 @@ - - <SessionState ContinuousTestingMode="0" Name="DatabaseListCommandTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::ACFA8C54-9889-EC7A-3C62-DE5077E15DAC::net9.0::Azure.Mcp.Tools.Sql.UnitTests.Database.DatabaseListCommandTests</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="Session #3" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <And> - <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> - <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> - </And> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="Session #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <And> - <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> - <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> - </And> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="FirewallRuleCreateCommandTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <TestAncestor> - <TestId>xUnit::ACFA8C54-9889-EC7A-3C62-DE5077E15DAC::net9.0::Azure.Mcp.Tools.Sql.UnitTests.FirewallRule.FirewallRuleCreateCommandTests</TestId> - </TestAncestor> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="Session #4" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <And> - <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> - <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> - </And> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="Session" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <And> - <Namespace>Azure.Mcp.Tools.Sql.UnitTests</Namespace> - <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> - </And> -</SessionState> - <SessionState ContinuousTestingMode="0" Name="All tests from &lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="C:\src\mcp\tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.UnitTests" Presentation="&lt;Azure.Mcp.Tools.Sql.UnitTests&gt;" /> -</SessionState> \ No newline at end of file From 88af6471b66725f78fe57666f1b7623ea779867e Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 09:00:29 -0700 Subject: [PATCH 04/15] Move SqlSetup to AOT compatibility exception section. --- servers/Azure.Mcp.Server/src/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 001325181..0ab09258e 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -86,7 +86,6 @@ private static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.ResourceHealth.ResourceHealthSetup(), new Azure.Mcp.Tools.Search.SearchSetup(), new Azure.Mcp.Tools.ServiceBus.ServiceBusSetup(), - new Azure.Mcp.Tools.Sql.SqlSetup(), new Azure.Mcp.Tools.Storage.StorageSetup(), new Azure.Mcp.Tools.VirtualDesktop.VirtualDesktopSetup(), new Azure.Mcp.Tools.Workbooks.WorkbooksSetup(), @@ -98,6 +97,7 @@ private static IAreaSetup[] RegisterAreas() // https://github.com/Azure/azure-mcp/blob/main/docs/aot-compatibility.md new Azure.Mcp.Tools.BicepSchema.BicepSchemaSetup(), new Azure.Mcp.Tools.AzureManagedLustre.AzureManagedLustreSetup(), + new Azure.Mcp.Tools.Sql.SqlSetup(), #endif ]; } From 30a12699a6bc6774fb8da3fa89994795634af0f2 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 13:56:45 -0700 Subject: [PATCH 05/15] fix AOT issue --- Directory.Packages.props | 2 +- servers/Azure.Mcp.Server/src/Program.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e15ac58bb..64109cf2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,7 +36,7 @@ - + diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 0ab09258e..001325181 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -86,6 +86,7 @@ private static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.ResourceHealth.ResourceHealthSetup(), new Azure.Mcp.Tools.Search.SearchSetup(), new Azure.Mcp.Tools.ServiceBus.ServiceBusSetup(), + new Azure.Mcp.Tools.Sql.SqlSetup(), new Azure.Mcp.Tools.Storage.StorageSetup(), new Azure.Mcp.Tools.VirtualDesktop.VirtualDesktopSetup(), new Azure.Mcp.Tools.Workbooks.WorkbooksSetup(), @@ -97,7 +98,6 @@ private static IAreaSetup[] RegisterAreas() // https://github.com/Azure/azure-mcp/blob/main/docs/aot-compatibility.md new Azure.Mcp.Tools.BicepSchema.BicepSchemaSetup(), new Azure.Mcp.Tools.AzureManagedLustre.AzureManagedLustreSetup(), - new Azure.Mcp.Tools.Sql.SqlSetup(), #endif ]; } From b7a55f4951ff652b72be51a89b54cb5ef6e3aae8 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 15:21:48 -0700 Subject: [PATCH 06/15] add docs and update firewall-rule-name in the parameter list --- docs/azmcp-commands.md | 16 ++++++++++- docs/e2eTestPrompts.md | 6 ++++ servers/Azure.Mcp.Server/README.md | 4 +++ .../src/Options/SqlOptionDefinitions.cs | 2 +- .../SqlCommandTests.cs | 12 ++++---- .../FirewallRuleCreateCommandTests.cs | 28 +++++++++---------- 6 files changed, 46 insertions(+), 22 deletions(-) diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index af65397a8..b17deaa0d 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -898,8 +898,22 @@ azmcp sql db show --subscription \ --server \ --database +# Create a firewall rule for a SQL server +azmcp sql server firewall-rule create --subscription \ + --resource-group \ + --server \ + --firewall-rule-name \ + --start-ip-address \ + --end-ip-address + +# Delete a firewall rule from a SQL server +azmcp sql server firewall-rule delete --subscription \ + --resource-group \ + --server \ + --firewall-rule-name + # Gets a list of firewall rules for a SQL server -azmcp sql firewall-rule list --subscription \ +azmcp sql server firewall-rule list --subscription \ --resource-group \ --server ``` diff --git a/docs/e2eTestPrompts.md b/docs/e2eTestPrompts.md index 6b09277b1..4d75e9f65 100644 --- a/docs/e2eTestPrompts.md +++ b/docs/e2eTestPrompts.md @@ -360,6 +360,12 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | azmcp_sql_server_firewall-rule_list | List all firewall rules for SQL server | | azmcp_sql_server_firewall-rule_list | Show me the firewall rules for SQL server | | azmcp_sql_server_firewall-rule_list | What firewall rules are configured for my SQL server ? | +| azmcp_sql_server_firewall-rule_create | Create a firewall rule for my Azure SQL server | +| azmcp_sql_server_firewall-rule_create | Add a firewall rule to allow access from IP range to for SQL server | +| azmcp_sql_server_firewall-rule_create | Create a new firewall rule named for SQL server | +| azmcp_sql_server_firewall-rule_delete | Delete a firewall rule from my Azure SQL server | +| azmcp_sql_server_firewall-rule_delete | Remove the firewall rule from SQL server | +| azmcp_sql_server_firewall-rule_delete | Delete firewall rule for SQL server | ## Azure Storage diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 850b7261a..1ae4d0f53 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -84,6 +84,8 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * "Show me details about my Azure SQL database 'mydb'" * "List all databases in my Azure SQL server 'myserver'" * "List all firewall rules for my Azure SQL server 'myserver'" +* "Create a firewall rule for my Azure SQL server 'myserver'" +* "Delete a firewall rule from my Azure SQL server 'myserver'" * "List all elastic pools in my Azure SQL server 'myserver'" * "List Active Directory administrators for my Azure SQL server 'myserver'" @@ -276,6 +278,8 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * Show database details and properties * List the details and properties of all databases * List SQL server firewall rules +* Create SQL server firewall rules +* Delete SQL server firewall rules ### 🗄️ Azure SQL Elastic Pool diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs index 7099defdb..55984e5d2 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs @@ -7,7 +7,7 @@ public static class SqlOptionDefinitions { public const string ServerName = "server"; public const string DatabaseName = "database"; - public const string FirewallRuleName = "name"; + public const string FirewallRuleName = "firewall-rule-name"; public const string StartIpAddress = "start-ip-address"; public const string EndIpAddress = "end-ip-address"; diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs index fb1ae7d31..6051dd5c4 100644 --- a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs @@ -294,7 +294,7 @@ public async Task Should_CreateFirewallRule_Successfully() { "subscription", Settings.SubscriptionId }, { "resource-group", Settings.ResourceGroupName }, { "server", serverName }, - { "name", ruleName }, + { "firewall-rule-name", ruleName }, { "start-ip-address", startIp }, { "end-ip-address", endIp } }); @@ -304,7 +304,7 @@ public async Task Should_CreateFirewallRule_Successfully() Assert.Equal(JsonValueKind.Object, firewallRule.ValueKind); // Verify firewall rule properties - var name = firewallRule.GetProperty("name").GetString(); + var name = firewallRule.GetProperty("firewall-rule-name").GetString(); Assert.Equal(ruleName, name); var ruleType = firewallRule.GetProperty("type").GetString(); @@ -339,7 +339,7 @@ await CallToolAsync( { "subscription", Settings.SubscriptionId }, { "resource-group", Settings.ResourceGroupName }, { "server", serverName }, - { "name", ruleName }, + { "firewall-rule-name", ruleName }, { "start-ip-address", startIp }, { "end-ip-address", endIp } }); @@ -352,7 +352,7 @@ await CallToolAsync( { "subscription", Settings.SubscriptionId }, { "resource-group", Settings.ResourceGroupName }, { "server", serverName }, - { "name", ruleName } + { "firewall-rule-name", ruleName } }); // Should successfully delete the firewall rule @@ -377,7 +377,7 @@ public async Task Should_DeleteNonExistentFirewallRule_ReturnsFalse() { "subscription", Settings.SubscriptionId }, { "resource-group", Settings.ResourceGroupName }, { "server", serverName }, - { "name", nonExistentRuleName } + { "firewall-rule-name", nonExistentRuleName } }); // Should return false when trying to delete non-existent rule (idempotent) @@ -392,7 +392,7 @@ public async Task Should_DeleteNonExistentFirewallRule_ReturnsFalse() [InlineData("--invalid-param")] [InlineData("--subscription invalidSub")] [InlineData("--subscription sub --resource-group rg")] // Missing server, name, and IP addresses - [InlineData("--subscription sub --resource-group rg --server server --name rule1")] // Missing IP addresses + [InlineData("--subscription sub --resource-group rg --server server --firewall-rule-name rule1")] // Missing IP addresses public async Task Should_Return400_WithInvalidFirewallRuleCreateInput(string args) { try diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs index 66a524a5d..cb19ba52a 100644 --- a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleCreateCommandTests.cs @@ -47,12 +47,12 @@ public void Constructor_InitializesCommandCorrectly() } [Theory] - [InlineData("--subscription sub --resource-group rg --server server --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", true)] - [InlineData("--subscription sub --resource-group rg --server server --name rule1 --start-ip-address 192.168.1.1", false)] // Missing end IP + [InlineData("--subscription sub --resource-group rg --server server --firewall-rule-name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", true)] + [InlineData("--subscription sub --resource-group rg --server server --firewall-rule-name rule1 --start-ip-address 192.168.1.1", false)] // Missing end IP [InlineData("--subscription sub --resource-group rg --server server --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing rule name - [InlineData("--subscription sub --resource-group rg --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing server - [InlineData("--subscription sub --server server --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing resource group - [InlineData("--resource-group rg --server server --name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing subscription + [InlineData("--subscription sub --resource-group rg --firewall-rule-name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing server + [InlineData("--subscription sub --server server --firewall-rule-name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing resource group + [InlineData("--resource-group rg --server server --firewall-rule-name rule1 --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255", false)] // Missing subscription [InlineData("", false)] // Missing all required parameters public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) { @@ -119,7 +119,7 @@ public async Task ExecuteAsync_CreatesFirewallRuleSuccessfully() .Returns(expectedFirewallRule); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -146,7 +146,7 @@ public async Task ExecuteAsync_HandlesServiceErrors() .Returns(Task.FromException(new Exception("Test error"))); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -174,7 +174,7 @@ public async Task ExecuteAsync_Handles404Error() .Returns(Task.FromException(requestException)); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -201,7 +201,7 @@ public async Task ExecuteAsync_Handles403Error() .Returns(Task.FromException(requestException)); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -228,7 +228,7 @@ public async Task ExecuteAsync_Handles409Error() .Returns(Task.FromException(requestException)); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -255,7 +255,7 @@ public async Task ExecuteAsync_HandlesArgumentException() .Returns(Task.FromException(argumentException)); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address invalid --end-ip-address invalid"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address invalid --end-ip-address invalid"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -295,7 +295,7 @@ public async Task ExecuteAsync_CallsServiceWithCorrectParameters() .Returns(expectedFirewallRule); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse($"--subscription {subscription} --resource-group {resourceGroup} --server {serverName} --name {ruleName} --start-ip-address {startIp} --end-ip-address {endIp}"); + var parseResult = _parser.Parse($"--subscription {subscription} --resource-group {resourceGroup} --server {serverName} --firewall-rule-name {ruleName} --start-ip-address {startIp} --end-ip-address {endIp}"); // Act await _command.ExecuteAsync(context, parseResult); @@ -335,7 +335,7 @@ public async Task ExecuteAsync_WithRetryPolicyOptions() .Returns(expectedFirewallRule); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255 --retry-max-retries 3"); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address 192.168.1.1 --end-ip-address 192.168.1.255 --retry-max-retries 3"); // Act var response = await _command.ExecuteAsync(context, parseResult); @@ -382,7 +382,7 @@ public async Task ExecuteAsync_HandlesVariousIPFormats(string startIp, string en .Returns(expectedFirewallRule); var context = new CommandContext(_serviceProvider); - var parseResult = _parser.Parse($"--subscription testsub --resource-group testrg --server testserver --name TestRule --start-ip-address {startIp} --end-ip-address {endIp}"); + var parseResult = _parser.Parse($"--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --start-ip-address {startIp} --end-ip-address {endIp}"); // Act var response = await _command.ExecuteAsync(context, parseResult); From 093b675bef626bcfe2685ff479c34f3c74fbfa2f Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 15:25:09 -0700 Subject: [PATCH 07/15] Update CHANGELOG.md --- servers/Azure.Mcp.Server/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 8c404cdd7..7a81c2c95 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -5,6 +5,7 @@ The Azure MCP Server updates automatically by default whenever a new release com ## 0.5.12 (Unreleased) ### Features Added +- add `azmcp sql server firewall-rule create` and `azmcp sql server firewall-rule delete` commands. [#121](https://github.com/microsoft/mcp/pull/121) ### Breaking Changes From 2c5d5a676a7e4378d961247189841b1f2e0c0190 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 15:56:16 -0700 Subject: [PATCH 08/15] fix failed live test --- .../tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs index 6051dd5c4..e7017a5aa 100644 --- a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.LiveTests/SqlCommandTests.cs @@ -304,7 +304,7 @@ public async Task Should_CreateFirewallRule_Successfully() Assert.Equal(JsonValueKind.Object, firewallRule.ValueKind); // Verify firewall rule properties - var name = firewallRule.GetProperty("firewall-rule-name").GetString(); + var name = firewallRule.GetProperty("name").GetString(); Assert.Equal(ruleName, name); var ruleType = firewallRule.GetProperty("type").GetString(); From cfb7ae0401a173dcc7000d500e7ec4032fa80924 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:06:14 -0700 Subject: [PATCH 09/15] add delete unit test --- .../FirewallRule/FirewallRuleDeleteCommandTests.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs new file mode 100644 index 000000000..e69de29bb From 5104623901ffbff5e84d29284344193c94edaec2 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:13:44 -0700 Subject: [PATCH 10/15] add delete firewall rule unit test --- .../FirewallRule/FirewallRuleDeleteCommand.cs | 2 + .../FirewallRuleDeleteCommandTests.cs | 374 ++++++++++++++++++ 2 files changed, 376 insertions(+) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs index 963b1701e..d9f8fc004 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/FirewallRule/FirewallRuleDeleteCommand.cs @@ -86,12 +86,14 @@ public override async Task ExecuteAsync(CommandContext context, Azure.RequestFailedException reqEx when reqEx.Status == 403 => $"Authorization failed deleting the firewall rule. Verify you have appropriate permissions. Details: {reqEx.Message}", Azure.RequestFailedException reqEx => reqEx.Message, + ArgumentException argEx => argEx.Message, _ => base.GetErrorMessage(ex) }; protected override int GetStatusCode(Exception ex) => ex switch { Azure.RequestFailedException reqEx => reqEx.Status, + ArgumentException => 400, _ => base.GetStatusCode(ex) }; diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs index e69de29bb..de5448159 100644 --- a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/FirewallRule/FirewallRuleDeleteCommandTests.cs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.Sql.Commands.FirewallRule; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Sql.UnitTests.FirewallRule; + +public class FirewallRuleDeleteCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ISqlService _service; + private readonly ILogger _logger; + private readonly FirewallRuleDeleteCommand _command; + private readonly CommandContext _context; + private readonly Parser _parser; + + public FirewallRuleDeleteCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_service); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _parser = new(_command.GetCommand()); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("delete", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + Assert.Contains("Deletes a firewall rule", command.Description); + } + + [Fact] + public void Command_HasCorrectMetadata() + { + Assert.True(_command.Metadata.Destructive); + Assert.False(_command.Metadata.ReadOnly); + } + + [Theory] + [InlineData("--subscription sub --resource-group rg --server server --firewall-rule-name rule1", true)] + [InlineData("--subscription sub --resource-group rg --server server", false)] // Missing rule name + [InlineData("--subscription sub --resource-group rg --firewall-rule-name rule1", false)] // Missing server + [InlineData("--subscription sub --server server --firewall-rule-name rule1", false)] // Missing resource group + [InlineData("--resource-group rg --server server --firewall-rule-name rule1", false)] // Missing subscription + [InlineData("", false)] // Missing all required parameters + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + } + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(shouldSucceed ? 200 : 400, response.Status); + if (shouldSucceed) + { + Assert.Equal("Success", response.Message); + } + else + { + Assert.Contains("required", response.Message.ToLower()); + } + } + + [Fact] + public async Task ExecuteAsync_DeletesFirewallRuleSuccessfully() + { + // Arrange + _service.DeleteFirewallRuleAsync( + "testserver", + "testrg", + "testsub", + "TestRule", + Arg.Any(), + Arg.Any()) + .Returns(true); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesIdempotentDelete_WhenRuleDoesNotExist() + { + // Arrange - Rule doesn't exist, but delete operation should still succeed (idempotent) + _service.DeleteFirewallRuleAsync( + "testserver", + "testrg", + "testsub", + "NonExistentRule", + Arg.Any(), + Arg.Any()) + .Returns(false); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name NonExistentRule"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Equal("Success", response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + // Arrange + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new Exception("Test error"))); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule"); + + // 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); + } + + [Fact] + public async Task ExecuteAsync_Handles404Error() + { + // Arrange + var requestException = new Azure.RequestFailedException(404, "Server not found"); + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(404, response.Status); + Assert.Contains("SQL server or firewall rule not found", response.Message); + } + + [Fact] + public async Task ExecuteAsync_Handles403Error() + { + // Arrange + var requestException = new Azure.RequestFailedException(403, "Access denied"); + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(403, response.Status); + Assert.Contains("Authorization failed", response.Message); + } + + [Fact] + public async Task ExecuteAsync_CallsServiceWithCorrectParameters() + { + // Arrange + const string serverName = "testserver"; + const string resourceGroup = "testrg"; + const string subscription = "testsub"; + const string ruleName = "TestRule"; + + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse($"--subscription {subscription} --resource-group {resourceGroup} --server {serverName} --firewall-rule-name {ruleName}"); + + // Act + await _command.ExecuteAsync(context, parseResult); + + // Assert + await _service.Received(1).DeleteFirewallRuleAsync( + serverName, + resourceGroup, + subscription, + ruleName, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithRetryPolicyOptions() + { + // Arrange + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name TestRule --retry-max-retries 3"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + + // Verify the service was called with retry policy + await _service.Received(1).DeleteFirewallRuleAsync( + "testserver", + "testrg", + "testsub", + "TestRule", + Arg.Is(r => r != null && r.MaxRetries == 3), + Arg.Any()); + } + + [Theory] + [InlineData("TestRule")] + [InlineData("MyFirewallRule")] + [InlineData("Rule-With-Hyphens")] + [InlineData("Rule_With_Underscores")] + [InlineData("Rule123")] + public async Task ExecuteAsync_HandlesVariousRuleNames(string ruleName) + { + // Arrange + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse($"--subscription testsub --resource-group testrg --server testserver --firewall-rule-name {ruleName}"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + + // Verify the service was called with the correct rule name + await _service.Received(1).DeleteFirewallRuleAsync( + "testserver", + "testrg", + "testsub", + ruleName, + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesArgumentException() + { + // Arrange + var argumentException = new ArgumentException("Invalid firewall rule name"); + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(argumentException)); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse("--subscription testsub --resource-group testrg --server testserver --firewall-rule-name InvalidRule"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(400, response.Status); + Assert.Contains("Invalid firewall rule name", response.Message); + } + + [Fact] + public async Task ExecuteAsync_VerifiesResultContainsExpectedData() + { + // Arrange + const string ruleName = "TestRule"; + _service.DeleteFirewallRuleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + + var context = new CommandContext(_serviceProvider); + var parseResult = _parser.Parse($"--subscription testsub --resource-group testrg --server testserver --firewall-rule-name {ruleName}"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + } +} From 76af369a06ffd6338f35c156879f571fddba43bb Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:46:12 -0700 Subject: [PATCH 11/15] remove blank line Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs index bbe6dc80a..f838d90a8 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs @@ -130,6 +130,4 @@ Task DeleteFirewallRuleAsync( string firewallRuleName, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken = default); - - } From 8e25827114eb4304f0d6a38681d73b3c67fd5301 Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:46:38 -0700 Subject: [PATCH 12/15] remove explict reference Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs index 24d6d2370..fbbd2d5c2 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -421,7 +421,7 @@ private static SqlElasticPool ConvertToSqlElasticPoolModel(JsonElement item) private static SqlServerFirewallRule ConvertToSqlFirewallRuleModel(JsonElement item) { - Azure.Mcp.Tools.Sql.Services.Models.SqlFirewallRuleData? firewallRule = Azure.Mcp.Tools.Sql.Services.Models.SqlFirewallRuleData.FromJson(item); + SqlFirewallRuleData? firewallRule = SqlFirewallRuleData.FromJson(item); if (firewallRule == null) throw new InvalidOperationException("Failed to parse SQL firewall rule data"); From 1504bd85152c86b4579f28acccdd3d58b6f6553f Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:46:52 -0700 Subject: [PATCH 13/15] remove explict reference Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs index fbbd2d5c2..a099f91c3 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -406,7 +406,7 @@ private static SqlElasticPool ConvertToSqlElasticPoolModel(JsonElement item) State: elasticPool.Properties?.State, CreationDate: elasticPool.Properties?.CreatedOn, MaxSizeBytes: elasticPool.Properties?.MaxSizeBytes, - PerDatabaseSettings: elasticPool.Properties?.PerDatabaseSettings != null ? new Azure.Mcp.Tools.Sql.Models.ElasticPoolPerDatabaseSettings( + PerDatabaseSettings: elasticPool.Properties?.PerDatabaseSettings != null ? new ElasticPoolPerDatabaseSettings( MinCapacity: elasticPool.Properties.PerDatabaseSettings.MinCapacity, MaxCapacity: elasticPool.Properties.PerDatabaseSettings.MaxCapacity ) : null, From 5a7012c3c9b710ccc303ba428bf8b67148638b6f Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:49:57 -0700 Subject: [PATCH 14/15] Revert "remove explict reference" This reverts commit 1504bd85152c86b4579f28acccdd3d58b6f6553f. --- tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs index a099f91c3..fbbd2d5c2 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -406,7 +406,7 @@ private static SqlElasticPool ConvertToSqlElasticPoolModel(JsonElement item) State: elasticPool.Properties?.State, CreationDate: elasticPool.Properties?.CreatedOn, MaxSizeBytes: elasticPool.Properties?.MaxSizeBytes, - PerDatabaseSettings: elasticPool.Properties?.PerDatabaseSettings != null ? new ElasticPoolPerDatabaseSettings( + PerDatabaseSettings: elasticPool.Properties?.PerDatabaseSettings != null ? new Azure.Mcp.Tools.Sql.Models.ElasticPoolPerDatabaseSettings( MinCapacity: elasticPool.Properties.PerDatabaseSettings.MinCapacity, MaxCapacity: elasticPool.Properties.PerDatabaseSettings.MaxCapacity ) : null, From b7a8ea5f229306eac48f6c7cd3a44849b336beea Mon Sep 17 00:00:00 2001 From: Ji Wang Date: Wed, 3 Sep 2025 16:50:26 -0700 Subject: [PATCH 15/15] Revert "remove explict reference" This reverts commit 8e25827114eb4304f0d6a38681d73b3c67fd5301. --- tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs index fbbd2d5c2..24d6d2370 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -421,7 +421,7 @@ private static SqlElasticPool ConvertToSqlElasticPoolModel(JsonElement item) private static SqlServerFirewallRule ConvertToSqlFirewallRuleModel(JsonElement item) { - SqlFirewallRuleData? firewallRule = SqlFirewallRuleData.FromJson(item); + Azure.Mcp.Tools.Sql.Services.Models.SqlFirewallRuleData? firewallRule = Azure.Mcp.Tools.Sql.Services.Models.SqlFirewallRuleData.FromJson(item); if (firewallRule == null) throw new InvalidOperationException("Failed to parse SQL firewall rule data");