diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 76187a465a..dc6eb0fa5f 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -948,6 +948,26 @@ azmcp sql elastic-pool list --subscription \ ### Azure SQL Server Operations ```bash +# Create a new SQL server +azmcp sql server create --subscription \ + --resource-group \ + --server \ + --location \ + --admin-user \ + --admin-password \ + [--version ] \ + [--public-network-access ] + +# Delete a SQL server +azmcp sql server delete --subscription \ + --resource-group \ + --server + +# Show details of a specific SQL server +azmcp sql server show --subscription \ + --resource-group \ + --server + # List Microsoft Entra ID administrators for a SQL server azmcp sql server entra-admin list --subscription \ --resource-group \ diff --git a/docs/e2eTestPrompts.md b/docs/e2eTestPrompts.md index 788aeed897..b5ab12d4bf 100644 --- a/docs/e2eTestPrompts.md +++ b/docs/e2eTestPrompts.md @@ -387,6 +387,12 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| +| azmcp_sql_server_create | Create a new Azure SQL server named in resource group | +| azmcp_sql_server_create | Create an Azure SQL server with name in location with admin user | +| azmcp_sql_server_create | Set up a new SQL server called in my resource group | +| azmcp_sql_server_delete | Delete the Azure SQL server from resource group | +| azmcp_sql_server_delete | Remove the SQL server from my subscription | +| azmcp_sql_server_delete | Delete SQL server permanently | | azmcp_sql_server_entra-admin_list | List Microsoft Entra ID administrators for SQL server | | azmcp_sql_server_entra-admin_list | Show me the Entra ID administrators configured for SQL server | | azmcp_sql_server_entra-admin_list | What Microsoft Entra ID administrators are set up for my SQL server ? | @@ -399,6 +405,9 @@ 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_show | Show me the details of Azure SQL server in resource group | +| azmcp_sql_server_show | Get the configuration details for SQL server | +| azmcp_sql_server_show | Display the properties of SQL server | ## Azure Storage diff --git a/servers/Azure.Mcp.Server/CHANGELOG.md b/servers/Azure.Mcp.Server/CHANGELOG.md index 83a485a737..50d2506d10 100644 --- a/servers/Azure.Mcp.Server/CHANGELOG.md +++ b/servers/Azure.Mcp.Server/CHANGELOG.md @@ -7,6 +7,7 @@ The Azure MCP Server updates automatically by default whenever a new release com ### Features Added - Added elicitation support. An elicitation request is sent if the tool annotation secret hint is true. [[#404](https://github.com/microsoft/mcp/pull/404)] +- Added `azmcp sql server create`, `azmcp sql server delete`, `azmcp sql server show` to support SQL server create, delete, and show commands. [[#312](https://github.com/microsoft/mcp/pull/312)] - Added the following Azure Managed Lustre commands: [[#100](https://github.com/microsoft/mcp/issues/100)] - `azmcp_azuremanagedlustre_filesystem_get_sku_info`: Get information about Azure Managed Lustre SKU. diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index a94caa77b2..a6756d3bbe 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -124,6 +124,9 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * "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'" +* "Create a new Azure SQL server in my resource group" +* "Show me details about my Azure SQL server 'myserver'" +* "Delete my Azure SQL server 'myserver'" ### 💾 Azure Storage @@ -323,6 +326,9 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some ### 🗄️ Azure SQL Server * List Microsoft Entra ID administrators for SQL servers +* Create new SQL servers +* Show details and properties of SQL servers +* Delete SQL servers ### 💾 Azure Storage diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerCreateCommand.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerCreateCommand.cs new file mode 100644 index 0000000000..5d9afa9536 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerCreateCommand.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Tools.Sql.Models; +using Azure.Mcp.Tools.Sql.Options; +using Azure.Mcp.Tools.Sql.Options.Server; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.Sql.Commands.Server; + +public sealed class ServerCreateCommand(ILogger logger) + : BaseSqlCommand(logger) +{ + private const string CommandTitle = "Create SQL Server"; + + private readonly Option _administratorLoginOption = SqlOptionDefinitions.AdministratorLoginOption; + private readonly Option _administratorPasswordOption = SqlOptionDefinitions.AdministratorPasswordOption; + private readonly Option _locationOption = SqlOptionDefinitions.LocationOption; + private readonly Option _versionOption = SqlOptionDefinitions.VersionOption; + private readonly Option _publicNetworkAccessOption = SqlOptionDefinitions.PublicNetworkAccessOption; + + public override string Name => "create"; + + public override string Description => + """ + Creates a new Azure SQL server in the specified resource group and location. + The server will be created with the specified administrator credentials and + optional configuration settings. Returns the created server with its properties + including the fully qualified domain name. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = false, + OpenWorld = true, + ReadOnly = false, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(_administratorLoginOption); + command.Options.Add(_administratorPasswordOption); + command.Options.Add(_locationOption); + command.Options.Add(_versionOption); + command.Options.Add(_publicNetworkAccessOption); + } + + protected override ServerCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.AdministratorLogin = parseResult.GetValueOrDefault(_administratorLoginOption); + options.AdministratorPassword = parseResult.GetValueOrDefault(_administratorPasswordOption); + options.Location = parseResult.GetValueOrDefault(_locationOption); + options.Version = parseResult.GetValueOrDefault(_versionOption); + options.PublicNetworkAccess = parseResult.GetValueOrDefault(_publicNetworkAccessOption); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var sqlService = context.GetService(); + + var server = await sqlService.CreateServerAsync( + options.Server!, + options.ResourceGroup!, + options.Subscription!, + options.Location!, + options.AdministratorLogin!, + options.AdministratorPassword!, + options.Version, + options.PublicNetworkAccess, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create( + new ServerCreateResult(server), + SqlJsonContext.Default.ServerCreateResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating SQL server. Server: {Server}, ResourceGroup: {ResourceGroup}, Location: {Location}, Options: {@Options}", + options.Server, options.ResourceGroup, options.Location, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + Azure.RequestFailedException reqEx when reqEx.Status == 409 => + "A SQL server with this name already exists. Choose a different server name.", + Azure.RequestFailedException reqEx when reqEx.Status == 403 => + $"Authorization failed creating the SQL server. Verify you have appropriate permissions. Details: {reqEx.Message}", + Azure.RequestFailedException reqEx when reqEx.Status == 400 => + $"Invalid request parameters for SQL server creation: {reqEx.Message}", + Azure.RequestFailedException reqEx => reqEx.Message, + ArgumentException argEx => $"Invalid parameter: {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 ServerCreateResult(SqlServer Server); +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerDeleteCommand.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerDeleteCommand.cs new file mode 100644 index 0000000000..13b16905b3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerDeleteCommand.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Tools.Sql.Options; +using Azure.Mcp.Tools.Sql.Options.Server; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.Sql.Commands.Server; + +public sealed class ServerDeleteCommand(ILogger logger) + : BaseSqlCommand(logger) +{ + private const string CommandTitle = "Delete SQL Server"; + + private readonly Option _forceOption = SqlOptionDefinitions.ForceOption; + + public override string Name => "delete"; + + public override string Description => + """ + Deletes an Azure SQL server and all of its databases from the specified resource group. + This operation is irreversible and will permanently remove the server and all its data. + Use the --force flag to skip confirmation prompts. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = true, + Idempotent = true, + OpenWorld = true, + ReadOnly = false, + LocalRequired = false, + Secret = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(_forceOption); + } + + protected override ServerDeleteOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Force = parseResult.GetValueOrDefault(_forceOption); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + // Show warning about destructive operation unless force is specified + if (!options.Force) + { + context.Response.Status = 200; + context.Response.Message = + $"WARNING: This operation will permanently delete the SQL server '{options.Server}' " + + $"and ALL its databases in resource group '{options.ResourceGroup}'. " + + $"This action cannot be undone. Use --force to confirm deletion."; + return context.Response; + } + + var sqlService = context.GetService(); + + var deleted = await sqlService.DeleteServerAsync( + options.Server!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy); + + if (deleted) + { + context.Response.Results = ResponseResult.Create( + new ServerDeleteResult($"SQL server '{options.Server}' was successfully deleted.", true), + SqlJsonContext.Default.ServerDeleteResult); + } + else + { + context.Response.Status = 404; + context.Response.Message = $"SQL server '{options.Server}' not found in resource group '{options.ResourceGroup}'."; + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting SQL server. Server: {Server}, ResourceGroup: {ResourceGroup}, Options: {@Options}", + options.Server, options.ResourceGroup, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + Azure.RequestFailedException reqEx when reqEx.Status == 404 => + $"The given SQL server not found. It may have already been deleted.", + Azure.RequestFailedException reqEx when reqEx.Status == 403 => + $"Authorization failed deleting the SQL server. Verify you have appropriate permissions. Details: {reqEx.Message}", + Azure.RequestFailedException reqEx when reqEx.Status == 409 => + $"Cannot delete SQL server due to a conflict. It may be in use or have dependent resources. Details: {reqEx.Message}", + Azure.RequestFailedException reqEx => reqEx.Message, + ArgumentException argEx => $"Invalid parameter: {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 ServerDeleteResult(string Message, bool Success); +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerShowCommand.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerShowCommand.cs new file mode 100644 index 0000000000..bef59ad5ea --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/Server/ServerShowCommand.cs @@ -0,0 +1,95 @@ +// 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.Server; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tools.Sql.Commands.Server; + +public sealed class ServerShowCommand(ILogger logger) + : BaseSqlCommand(logger) +{ + private const string CommandTitle = "Show SQL Server"; + + public override string Name => "show"; + + public override string Description => + """ + Retrieves detailed information about an Azure SQL server including its configuration, + status, and properties such as the fully qualified domain name, version, + administrator login, and network access settings. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = true, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var sqlService = context.GetService(); + + var server = await sqlService.GetServerAsync( + options.Server!, + options.ResourceGroup!, + options.Subscription!, + options.RetryPolicy); + + context.Response.Results = ResponseResult.Create( + new ServerShowResult(server), + SqlJsonContext.Default.ServerShowResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error retrieving SQL server. Server: {Server}, ResourceGroup: {ResourceGroup}, Options: {@Options}", + options.Server, options.ResourceGroup, options); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + KeyNotFoundException => + "SQL server not found in the specified resource group. Verify the server name and resource group.", + Azure.RequestFailedException reqEx when reqEx.Status == 404 => + "SQL server not found in the specified resource group. Verify the server name and resource group.", + Azure.RequestFailedException reqEx when reqEx.Status == 403 => + $"Authorization failed retrieving the SQL server. Verify you have appropriate permissions. Details: {reqEx.Message}", + Azure.RequestFailedException reqEx => reqEx.Message, + ArgumentException argEx => $"Invalid parameter: {argEx.Message}", + _ => base.GetErrorMessage(ex) + }; + + protected override int GetStatusCode(Exception ex) => ex switch + { + KeyNotFoundException => 404, + Azure.RequestFailedException reqEx => reqEx.Status, + ArgumentException => 400, + _ => base.GetStatusCode(ex) + }; + + internal record ServerShowResult(SqlServer Server); +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs b/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs index 690910e5cf..f203de55ea 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Commands/SqlJsonContext.cs @@ -6,6 +6,7 @@ using Azure.Mcp.Tools.Sql.Commands.ElasticPool; using Azure.Mcp.Tools.Sql.Commands.EntraAdmin; using Azure.Mcp.Tools.Sql.Commands.FirewallRule; +using Azure.Mcp.Tools.Sql.Commands.Server; using Azure.Mcp.Tools.Sql.Models; using Azure.Mcp.Tools.Sql.Services.Models; @@ -17,8 +18,12 @@ namespace Azure.Mcp.Tools.Sql.Commands; [JsonSerializable(typeof(FirewallRuleListCommand.FirewallRuleListResult))] [JsonSerializable(typeof(FirewallRuleCreateCommand.FirewallRuleCreateResult))] [JsonSerializable(typeof(FirewallRuleDeleteCommand.FirewallRuleDeleteResult))] +[JsonSerializable(typeof(ServerCreateCommand.ServerCreateResult))] +[JsonSerializable(typeof(ServerDeleteCommand.ServerDeleteResult))] +[JsonSerializable(typeof(ServerShowCommand.ServerShowResult))] [JsonSerializable(typeof(ElasticPoolListCommand.ElasticPoolListResult))] [JsonSerializable(typeof(SqlDatabase))] +[JsonSerializable(typeof(SqlServer))] [JsonSerializable(typeof(SqlServerEntraAdministrator))] [JsonSerializable(typeof(SqlServerFirewallRule))] [JsonSerializable(typeof(SqlElasticPool))] diff --git a/tools/Azure.Mcp.Tools.Sql/src/Models/SqlServer.cs b/tools/Azure.Mcp.Tools.Sql/src/Models/SqlServer.cs new file mode 100644 index 0000000000..4ee426cc26 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Models/SqlServer.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Sql.Models; + +public record SqlServer( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("fullyQualifiedDomainName")] string? FullyQualifiedDomainName, + [property: JsonPropertyName("location")] string Location, + [property: JsonPropertyName("resourceGroup")] string ResourceGroup, + [property: JsonPropertyName("subscription")] string Subscription, + [property: JsonPropertyName("administratorLogin")] string? AdministratorLogin, + [property: JsonPropertyName("version")] string? Version, + [property: JsonPropertyName("state")] string? State, + [property: JsonPropertyName("publicNetworkAccess")] string? PublicNetworkAccess, + [property: JsonPropertyName("tags")] Dictionary? Tags +); diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerCreateOptions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerCreateOptions.cs new file mode 100644 index 0000000000..575232223e --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerCreateOptions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.Sql.Options.Server; + +public class ServerCreateOptions : BaseSqlOptions +{ + [JsonPropertyName(SqlOptionDefinitions.AdministratorLogin)] + public string? AdministratorLogin { get; set; } + + [JsonPropertyName(SqlOptionDefinitions.AdministratorPassword)] + public string? AdministratorPassword { get; set; } + + [JsonPropertyName(SqlOptionDefinitions.Location)] + public string? Location { get; set; } + + [JsonPropertyName(SqlOptionDefinitions.Version)] + public string? Version { get; set; } + + [JsonPropertyName(SqlOptionDefinitions.PublicNetworkAccess)] + public string? PublicNetworkAccess { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerDeleteOptions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerDeleteOptions.cs new file mode 100644 index 0000000000..4ff9e94f2b --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerDeleteOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Sql.Options.Server; + +/// +/// Options for the SQL server delete command. +/// +public class ServerDeleteOptions : BaseSqlOptions +{ + /// + /// Gets or sets whether to force delete the server without confirmation. + /// + public bool Force { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerShowOptions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerShowOptions.cs new file mode 100644 index 0000000000..cc75ed111a --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/Server/ServerShowOptions.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Sql.Options.Server; + +public class ServerShowOptions : BaseSqlOptions +{ +} diff --git a/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs b/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs index d5b651292d..5ec76cf76c 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Options/SqlOptionDefinitions.cs @@ -10,6 +10,12 @@ public static class SqlOptionDefinitions public const string FirewallRuleName = "firewall-rule-name"; public const string StartIpAddress = "start-ip-address"; public const string EndIpAddress = "end-ip-address"; + public const string AdministratorLogin = "administrator-login"; + public const string AdministratorPassword = "administrator-password"; + public const string Location = "location"; + public const string Version = "version"; + public const string PublicNetworkAccess = "public-network-access"; + public const string Force = "force"; public static readonly Option Server = new( $"--{ServerName}" @@ -50,4 +56,52 @@ public static class SqlOptionDefinitions Description = "The end IP address of the firewall rule range.", Required = true }; + + public static readonly Option AdministratorLoginOption = new( + $"--{AdministratorLogin}" + ) + { + Description = "The administrator login name for the SQL server.", + Required = true + }; + + public static readonly Option AdministratorPasswordOption = new( + $"--{AdministratorPassword}" + ) + { + Description = "The administrator password for the SQL server.", + Required = true + }; + + public static readonly Option LocationOption = new( + $"--{Location}" + ) + { + Description = "The Azure region location where the SQL server will be created.", + Required = true + }; + + public static readonly Option VersionOption = new( + $"--{Version}" + ) + { + Description = "The version of SQL Server to create (e.g., '12.0').", + Required = false + }; + + public static readonly Option PublicNetworkAccessOption = new( + $"--{PublicNetworkAccess}" + ) + { + Description = "Whether public network access is enabled for the SQL server ('Enabled' or 'Disabled').", + Required = false + }; + + public static readonly Option ForceOption = new( + $"--{Force}" + ) + { + Description = "Force delete the server without confirmation prompts.", + Required = false + }; } diff --git a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs index f838d90a88..fecbd95027 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/ISqlService.cs @@ -130,4 +130,63 @@ Task DeleteFirewallRuleAsync( string firewallRuleName, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken = default); + + /// + /// Creates a new SQL server. + /// + /// The name of the SQL server + /// The name of the resource group + /// The subscription ID or name + /// The Azure region location where the SQL server will be created + /// The administrator login name for the SQL server + /// The administrator password for the SQL server + /// The version of SQL Server to create (optional, defaults to latest) + /// Whether public network access is enabled (optional) + /// Optional retry policy options + /// Cancellation token + /// The created SQL server information + Task CreateServerAsync( + string serverName, + string resourceGroup, + string subscription, + string location, + string administratorLogin, + string administratorPassword, + string? version, + string? publicNetworkAccess, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default); + + /// + /// Gets a SQL server. + /// + /// The name of the SQL server + /// The name of the resource group + /// The subscription ID or name + /// Optional retry policy options + /// Cancellation token + /// The SQL server information + /// Thrown when the server is not found + Task GetServerAsync( + string serverName, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default); + + /// + /// Deletes a SQL server. + /// + /// The name of the SQL server + /// The name of the resource group + /// The subscription ID or name + /// Optional retry policy options + /// Cancellation token + /// True if the server was successfully deleted + Task DeleteServerAsync( + string serverName, + string resourceGroup, + string subscription, + 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 bc67cdb183..237f1b6c90 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/Services/SqlService.cs @@ -9,6 +9,7 @@ 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; @@ -335,6 +336,186 @@ public async Task DeleteFirewallRuleAsync( } } + /// + /// Creates a new Azure SQL Server. + /// + /// The name of the SQL server to create + /// The name of the resource group + /// The subscription ID or name + /// The Azure region location where the SQL server will be created + /// The administrator login name for the SQL server + /// The administrator password for the SQL server + /// The version of SQL Server to create (optional, defaults to latest) + /// Whether public network access is enabled (optional) + /// Optional retry policy configuration for resilient operations + /// Token to observe for cancellation requests + /// The created SQL server + /// Thrown when required parameters are null or empty + public async Task CreateServerAsync( + string serverName, + string resourceGroup, + string subscription, + string location, + string administratorLogin, + string administratorPassword, + string? version, + string? publicNetworkAccess, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters(serverName, resourceGroup, subscription, location, administratorLogin, administratorPassword); + + 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 serverData = new SqlServerData(location) + { + AdministratorLogin = administratorLogin, + AdministratorLoginPassword = administratorPassword, + Version = version ?? "12.0", // Default to SQL Server 2014 (12.0) + }; + + // Set PublicNetworkAccess if specified + if (!string.IsNullOrEmpty(publicNetworkAccess)) + { + // Set the public network access value - defaults to "Enabled" if not "Disabled" + serverData.PublicNetworkAccess = publicNetworkAccess.Equals("Enabled", StringComparison.OrdinalIgnoreCase) + ? ServerNetworkAccessFlag.Enabled + : ServerNetworkAccessFlag.Disabled; + } + + var operation = await resourceGroupResource.Value.GetSqlServers().CreateOrUpdateAsync( + Azure.WaitUntil.Completed, + serverName, + serverData, + cancellationToken); + + var server = operation.Value; + var tags = server.Data.Tags?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(); + + return new SqlServer( + Name: server.Data.Name, + FullyQualifiedDomainName: server.Data.FullyQualifiedDomainName, + Location: server.Data.Location.ToString(), + ResourceGroup: resourceGroup, + Subscription: subscription, + AdministratorLogin: server.Data.AdministratorLogin, + Version: server.Data.Version, + State: server.Data.State?.ToString(), + PublicNetworkAccess: server.Data.PublicNetworkAccess?.ToString(), + Tags: tags + ); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating SQL server. Server: {Server}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}, Location: {Location}", + serverName, resourceGroup, subscription, location); + throw; + } + } + + /// + /// Retrieves a specific SQL server from Azure. + /// + /// The name of the SQL server + /// The name of the resource group containing the server + /// The subscription ID or name + /// Optional retry policy configuration for resilient operations + /// Token to observe for cancellation requests + /// The SQL server if found, otherwise throws KeyNotFoundException + /// Thrown when the specified server is not found + /// Thrown when required parameters are null or empty + public async Task GetServerAsync( + string serverName, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters(serverName, resourceGroup, subscription); + + try + { + // Use ARM client directly for get operations + var armClient = await CreateArmClientAsync(null, retryPolicy); + var subscriptionResource = armClient.GetSubscriptionResource(Azure.ResourceManager.Resources.SubscriptionResource.CreateResourceIdentifier(subscription)); + var resourceGroupResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup); + + var serverResource = await resourceGroupResource.Value.GetSqlServers().GetAsync(serverName); + var server = serverResource.Value; + var tags = server.Data.Tags?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(); + + return new SqlServer( + Name: server.Data.Name, + FullyQualifiedDomainName: server.Data.FullyQualifiedDomainName, + Location: server.Data.Location.ToString(), + ResourceGroup: resourceGroup, + Subscription: subscription, + AdministratorLogin: server.Data.AdministratorLogin, + Version: server.Data.Version, + State: server.Data.State?.ToString(), + PublicNetworkAccess: server.Data.PublicNetworkAccess?.ToString(), + Tags: tags + ); + } + catch (Azure.RequestFailedException reqEx) when (reqEx.Status == 404) + { + throw new KeyNotFoundException($"SQL server '{serverName}' not found in resource group '{resourceGroup}' for subscription '{subscription}'."); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error getting SQL server. Server: {Server}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + serverName, resourceGroup, subscription); + throw; + } + } + + public async Task DeleteServerAsync( + string serverName, + string resourceGroup, + string subscription, + RetryPolicyOptions? retryPolicy, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters(serverName, resourceGroup, subscription); + + 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 serverResource = await resourceGroupResource.Value.GetSqlServers().GetAsync(serverName); + + var operation = await serverResource.Value.DeleteAsync( + Azure.WaitUntil.Completed, + cancellationToken); + + return true; + } + catch (Azure.RequestFailedException reqEx) when (reqEx.Status == 404) + { + _logger.LogWarning( + "SQL server not found during delete operation. Server: {Server}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + serverName, resourceGroup, subscription); + return false; // Server doesn't exist + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting SQL server. Server: {Server}, ResourceGroup: {ResourceGroup}, Subscription: {Subscription}", + serverName, resourceGroup, subscription); + throw; + } + } + private static SqlDatabase ConvertToSqlDatabaseModel(JsonElement item) { Models.SqlDatabaseData? sqlDatabase = Azure.Mcp.Tools.Sql.Services.Models.SqlDatabaseData.FromJson(item); diff --git a/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs b/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs index 8fdfa0d472..3627970afd 100644 --- a/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs +++ b/tools/Azure.Mcp.Tools.Sql/src/SqlSetup.cs @@ -7,6 +7,7 @@ using Azure.Mcp.Tools.Sql.Commands.ElasticPool; using Azure.Mcp.Tools.Sql.Commands.EntraAdmin; using Azure.Mcp.Tools.Sql.Commands.FirewallRule; +using Azure.Mcp.Tools.Sql.Commands.Server; using Azure.Mcp.Tools.Sql.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -36,6 +37,10 @@ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactor var server = new CommandGroup("server", "SQL server operations"); sql.AddSubGroup(server); + server.AddCommand("create", new ServerCreateCommand(loggerFactory.CreateLogger())); + server.AddCommand("delete", new ServerDeleteCommand(loggerFactory.CreateLogger())); + server.AddCommand("show", new ServerShowCommand(loggerFactory.CreateLogger())); + var elasticPool = new CommandGroup("elastic-pool", "SQL elastic pool operations"); sql.AddSubGroup(elasticPool); elasticPool.AddCommand("list", new ElasticPoolListCommand(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 395732d18b..ea07222f97 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 @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net; using System.Text.Json; using Azure.Mcp.Tests; using Azure.Mcp.Tests.Client; @@ -409,4 +410,71 @@ public async Task Should_Return400_WithInvalidFirewallRuleCreateInput(string arg Assert.NotEmpty(ex.Message); } } + + [Theory] + [InlineData("--invalid-param")] + [InlineData("--subscription invalidSub")] + [InlineData("--subscription sub --resource-group rg")] // Missing server, location, admin credentials + [InlineData("--subscription sub --resource-group rg --server server1")] // Missing location and admin credentials + public async Task Should_Return400_WithInvalidSqlServerCreateInput(string args) + { + try + { + var result = await CallToolAsync("azmcp_sql_server_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); + } + } + + [Theory] + [InlineData("--invalid-param")] + [InlineData("--subscription invalidSub")] + [InlineData("--subscription sub --resource-group rg")] // Missing server + public async Task Should_Return400_WithInvalidSqlServerShowInput(string args) + { + try + { + var result = await CallToolAsync("azmcp_sql_server_show", + 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); + } + } + + [Theory] + [InlineData("--invalid-param")] + [InlineData("--subscription invalidSub")] + [InlineData("--subscription sub --resource-group rg")] // Missing server + public async Task Should_Return400_WithInvalidSqlServerDeleteInput(string args) + { + try + { + var result = await CallToolAsync("azmcp_sql_server_delete", + 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/Server/ServerCreateCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerCreateCommandTests.cs new file mode 100644 index 0000000000..9558fd2fc6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerCreateCommandTests.cs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.Sql.Commands.Server; +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.Server; + +public class ServerCreateCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ISqlService _service; + private readonly ILogger _logger; + private readonly ServerCreateCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public ServerCreateCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_service); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _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 new Azure SQL server", command.Description); + } + + [Theory] + [InlineData("--subscription sub --resource-group rg --server testserver --location eastus --administrator-login admin --administrator-password Password123!", true)] + [InlineData("--subscription sub --resource-group rg --server testserver --location eastus --administrator-login admin", false)] // Missing password + [InlineData("--subscription sub --resource-group rg --server testserver --location eastus --administrator-password Password123!", false)] // Missing login + [InlineData("--subscription sub --resource-group rg --server testserver --administrator-login admin --administrator-password Password123!", false)] // Missing location + [InlineData("--subscription sub --resource-group rg --location eastus --administrator-login admin --administrator-password Password123!", false)] // Missing server + [InlineData("--subscription sub --server testserver --location eastus --administrator-login admin --administrator-password Password123!", false)] // Missing resource group + [InlineData("--resource-group rg --server testserver --location eastus --administrator-login admin --administrator-password Password123!", false)] // Missing subscription + [InlineData("", false)] // Missing all required parameters + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed) + { + var expectedServer = new SqlServer( + Name: "testserver", + FullyQualifiedDomainName: "testserver.database.windows.net", + Location: "East US", + ResourceGroup: "rg", + Subscription: "sub", + AdministratorLogin: "admin", + Version: "12.0", + State: "Ready", + PublicNetworkAccess: "Enabled", + Tags: new Dictionary() + ); + + _service.CreateServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedServer); + } + + var context = new CommandContext(_serviceProvider); + var parseResult = _commandDefinition.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_CreatesServerSuccessfully() + { + // Arrange + var expectedServer = new SqlServer( + Name: "testserver", + FullyQualifiedDomainName: "testserver.database.windows.net", + Location: "East US", + ResourceGroup: "rg", + Subscription: "sub", + AdministratorLogin: "admin", + Version: "12.0", + State: "Ready", + PublicNetworkAccess: "Enabled", + Tags: new Dictionary() + ); + + _service.CreateServerAsync( + "testserver", + "rg", + "sub", + "eastus", + "admin", + "Password123!", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(expectedServer); + + var context = new CommandContext(_serviceProvider); + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --location eastus --administrator-login admin --administrator-password Password123!"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.Equal("Success", response.Message); + Assert.NotNull(response.Results); + + await _service.Received(1).CreateServerAsync( + "testserver", + "rg", + "sub", + "eastus", + "admin", + "Password123!", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WithOptionalParameters_PassesAllParameters() + { + // Arrange + var expectedServer = new SqlServer( + Name: "testserver", + FullyQualifiedDomainName: "testserver.database.windows.net", + Location: "East US", + ResourceGroup: "rg", + Subscription: "sub", + AdministratorLogin: "admin", + Version: "12.0", + State: "Ready", + PublicNetworkAccess: "Disabled", + Tags: new Dictionary() + ); + + _service.CreateServerAsync( + "testserver", + "rg", + "sub", + "eastus", + "admin", + "Password123!", + "12.0", + "Disabled", + Arg.Any(), + Arg.Any()) + .Returns(expectedServer); + + var context = new CommandContext(_serviceProvider); + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --location eastus --administrator-login admin --administrator-password Password123! --version 12.0 --public-network-access Disabled"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.Equal("Success", response.Message); + + await _service.Received(1).CreateServerAsync( + "testserver", + "rg", + "sub", + "eastus", + "admin", + "Password123!", + "12.0", + "Disabled", + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WhenServiceThrowsException_ReturnsErrorResponse() + { + // Arrange + _service.CreateServerAsync( + Arg.Any(), + Arg.Any(), + 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 = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --location eastus --administrator-login admin --administrator-password Password123!"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.NotEqual(200, response.Status); + Assert.Contains("error", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenServerAlreadyExists_Returns409StatusCode() + { + // Arrange + var requestException = new Azure.RequestFailedException(409, "Conflict: Server already exists"); + + _service.CreateServerAsync( + Arg.Any(), + Arg.Any(), + 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 = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --location eastus --administrator-login admin --administrator-password Password123!"); + + // Act + var response = await _command.ExecuteAsync(context, parseResult); + + // Assert + Assert.Equal(409, response.Status); + Assert.Contains("server with this name already exists", response.Message.ToLower()); + } +} diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerDeleteCommandTests.cs new file mode 100644 index 0000000000..8aff653174 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerDeleteCommandTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.Sql.Commands.Server; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Sql.UnitTests.Server; + +public class ServerDeleteCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ISqlService _service; + private readonly ILogger _logger; + private readonly ServerDeleteCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public ServerDeleteCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_service); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _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 an Azure SQL server", command.Description); + } + + [Theory] + [InlineData("--subscription sub --resource-group rg --server testserver --force", true)] + [InlineData("--subscription sub --resource-group rg --server testserver", true)] // Should show warning without force + [InlineData("--subscription sub --resource-group rg --force", false)] // Missing server + [InlineData("--subscription sub --server testserver --force", false)] // Missing resource group + [InlineData("--resource-group rg --server testserver --force", false)] // Missing subscription + [InlineData("", false)] // Missing all required parameters + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + // Arrange + if (shouldSucceed && args.Contains("--force")) + { + _service.DeleteServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + if (shouldSucceed) + { + Assert.Equal(200, response.Status); + } + else + { + Assert.NotEqual(200, response.Status); + } + } + + [Fact] + public async Task ExecuteAsync_WhenForceNotSpecified_ReturnsWarning() + { + // Arrange + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.Contains("WARNING", response.Message); + Assert.Contains("permanently delete", response.Message); + Assert.Contains("--force", response.Message); + } + + [Fact] + public async Task ExecuteAsync_WhenServerDeletedSuccessfully_ReturnsSuccess() + { + // Arrange + _service.DeleteServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --force"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + await _service.Received(1).DeleteServerAsync("testserver", "rg", "sub", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WhenServerNotFound_Returns404() + { + // Arrange + _service.DeleteServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --force"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(404, response.Status); + Assert.Contains("not found", response.Message); + } + + [Fact] + public async Task ExecuteAsync_WhenServiceThrowsException_ReturnsErrorResponse() + { + // Arrange + _service.DeleteServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new Exception("Test error"))); + + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --force"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.NotEqual(200, response.Status); + Assert.Contains("error", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenServerNotFoundFromAzure_Returns404StatusCode() + { + // Arrange + var requestException = new Azure.RequestFailedException(404, "Not Found: Server not found"); + + _service.DeleteServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --force"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(404, response.Status); + Assert.Contains("not found", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenUnauthorized_Returns403StatusCode() + { + // Arrange + var requestException = new Azure.RequestFailedException(403, "Forbidden: Insufficient permissions"); + + _service.DeleteServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestException)); + + var parseResult = _commandDefinition.Parse("--subscription sub --resource-group rg --server testserver --force"); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(403, response.Status); + Assert.Contains("authorization failed", response.Message.ToLower()); + } +} diff --git a/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerShowCommandTests.cs b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerShowCommandTests.cs new file mode 100644 index 0000000000..11d7fbe6ae --- /dev/null +++ b/tools/Azure.Mcp.Tools.Sql/tests/Azure.Mcp.Tools.Sql.UnitTests/Server/ServerShowCommandTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Tools.Sql.Commands.Server; +using Azure.Mcp.Tools.Sql.Models; +using Azure.Mcp.Tools.Sql.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.Sql.UnitTests.Server; + +public class ServerShowCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ISqlService _service; + private readonly ILogger _logger; + private readonly ServerShowCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public ServerShowCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + collection.AddSingleton(_service); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("show", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + Assert.Contains("SQL server", command.Description); + } + + [Theory] + [InlineData("--subscription sub --resource-group rg --server testserver", true)] + [InlineData("--subscription sub --resource-group rg", false)] // Missing server + [InlineData("--subscription sub --server testserver", false)] // Missing resource-group + [InlineData("--resource-group rg --server testserver", false)] // Missing subscription + public async Task ExecuteAsync_ValidationScenarios_ReturnsExpectedResults(string args, bool shouldSucceed) + { + var parseResult = _commandDefinition.Parse(args); + var response = await _command.ExecuteAsync(_context, parseResult); + + if (shouldSucceed) + { + Assert.Equal(200, response.Status); + } + else + { + Assert.Equal(400, response.Status); + Assert.Contains("required", response.Message.ToLower()); + } + } + + [Fact] + public async Task ExecuteAsync_WithValidOptions_ReturnsServer() + { + // Arrange + var expectedServer = new SqlServer( + Name: "test-server", + FullyQualifiedDomainName: "test-server.database.windows.net", + Location: "East US", + ResourceGroup: "test-rg", + Subscription: "test-sub", + AdministratorLogin: "sql-admin", + Version: "12.0", + State: "Ready", + PublicNetworkAccess: "Enabled", + Tags: new Dictionary { { "environment", "test" } } + ); + + _service.GetServerAsync( + "test-server", + "test-rg", + "test-sub", + Arg.Any(), + Arg.Any()) + .Returns(expectedServer); + + // Act + var parseResult = _commandDefinition.Parse("--subscription test-sub --resource-group test-rg --server test-server"); + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + } + + [Fact] + public async Task ExecuteAsync_WhenServerNotFound_ReturnsNotFound() + { + // Arrange + _service.GetServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new KeyNotFoundException("SQL server not found"))); + + // Act + var parseResult = _commandDefinition.Parse("--subscription test-sub --resource-group test-rg --server nonexistent-server"); + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(404, response.Status); + Assert.Contains("not found", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenAzureRequestFails404_ReturnsNotFound() + { + // Arrange + var requestFailedException = new Azure.RequestFailedException(404, "Resource not found"); + _service.GetServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestFailedException)); + + // Act + var parseResult = _commandDefinition.Parse("--subscription test-sub --resource-group test-rg --server missing-server"); + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(404, response.Status); + Assert.Contains("not found", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenAzureRequestFails403_ReturnsAuthorizationError() + { + // Arrange + var requestFailedException = new Azure.RequestFailedException(403, "Authorization failed"); + _service.GetServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(requestFailedException)); + + // Act + var parseResult = _commandDefinition.Parse("--subscription test-sub --resource-group test-rg --server test-server"); + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(403, response.Status); + Assert.Contains("authorization failed", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenArgumentException_ReturnsBadRequest() + { + // Arrange + _service.GetServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new ArgumentException("Invalid server name"))); + + // Act + var parseResult = _commandDefinition.Parse("--subscription test-sub --resource-group test-rg --server invalid"); + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(400, response.Status); + Assert.Contains("invalid parameter", response.Message.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_WhenUnexpectedException_ReturnsInternalServerError() + { + // Arrange + _service.GetServerAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Unexpected error"))); + + // Act + var parseResult = _commandDefinition.Parse("--subscription test-sub --resource-group test-rg --server test-server"); + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(500, response.Status); + Assert.Contains("Unexpected error", response.Message); + } + + [Fact] + public void Metadata_IndicatesReadOnlyOperation() + { + // Act & Assert + Assert.False(_command.Metadata.Destructive); + Assert.True(_command.Metadata.ReadOnly); + } +}