diff --git a/servers/Azure.Mcp.Server/changelog-entries/1772478811657.yaml b/servers/Azure.Mcp.Server/changelog-entries/1772478811657.yaml new file mode 100644 index 0000000000..7417aec9f2 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1772478811657.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Add tools for web app diagnostics" \ No newline at end of file diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 335f61a62e..ffb748d3bf 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -686,6 +686,53 @@ azmcp appservice webapp deployment get --subscription "my-subscription" \ --deployment-id "deployment-id" ``` +#### Web App Diagnostics + +```bash +# List detectors for an App Service Web App +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp appservice webapp diagnostic list --subscription \ + --resource-group \ + --app + +# Examples: +# List diagnostic detectors for an App Service Web App +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp appservice webapp diagnostic list --subscription "my-subscription" \ + --resource-group "my-resource-group" \ + --app "my-web-app" +``` + +```bash +# Diagnose an App Service Web App with detector +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp appservice webapp diagnostic diagnose --subscription \ + --resource-group \ + --app \ + --detector-name \ + [--start-time ] \ + [--end-time ] \ + [--interval ] + +# Examples: +# Diagnose the Web App with detector +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp appservice webapp diagnostic diagnose --subscription "my-subscription" \ + --resource-group "my-resource-group" \ + --app "my-web-app" \ + --detector-name "detector" + +# Diagnose the Web App with detector between start and end time with interval +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp appservice webapp diagnostic diagnose --subscription "my-subscription" \ + --resource-group "my-resource-group" \ + --app "my-web-app" \ + --detector-name "detector" + --start-time "2026-01-01T00:00:00Z" \ + --end-time "2026-01-01T23:59:59Z" \ + --interval "PT1H" +``` + ### Azure CLI Operations #### Generate diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 58ab023c94..d454fe4a12 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -105,6 +105,9 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | appservice_database_add | Connect database to my app service using connection string in resource group | | appservice_database_add | Set up database for app service with connection string under resource group | | appservice_database_add | Configure database for app service with the connection string in resource group | +| appservice_webapp_diagnostic_diagnose | Diagnose web app in with detector | +| appservice_webapp_diagnostic_diagnose | Diagnose web app in with detector between and with interval | +| appservice_webapp_diagnostic_list | List the diagnostic detectors for web app in | | appservice_webapp_get | List the web apps in my subscription | | appservice_webapp_get | Show me the web apps in my resource group | | appservice_webapp_get | Get the details for web app in | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index c2435e5715..5825805d79 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -3196,7 +3196,7 @@ }, { "name": "get_azure_appservice_details", - "description": "Get details about Azure App Service resources including Web Apps and web app deployments.", + "description": "Get details about Azure App Service resources including Web Apps, web app deployments, web app diagnostic detectors, and web app diagnostic diagnoses.", "toolMetadata": { "destructive": { "value": false, @@ -3225,7 +3225,9 @@ }, "mappedToolList": [ "appservice_webapp_get", - "appservice_webapp_deployment_get" + "appservice_webapp_deployment_get", + "appservice_webapp_diagnostic_diagnose", + "appservice_webapp_diagnostic_list" ] }, { diff --git a/tools/Azure.Mcp.Tools.AppService/src/AppServiceSetup.cs b/tools/Azure.Mcp.Tools.AppService/src/AppServiceSetup.cs index e8ca43cddb..cebd958b57 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/AppServiceSetup.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/AppServiceSetup.cs @@ -4,6 +4,7 @@ using Azure.Mcp.Tools.AppService.Commands.Database; using Azure.Mcp.Tools.AppService.Commands.Webapp; using Azure.Mcp.Tools.AppService.Commands.Webapp.Deployment; +using Azure.Mcp.Tools.AppService.Commands.Webapp.Diagnostic; using Azure.Mcp.Tools.AppService.Commands.Webapp.Settings; using Azure.Mcp.Tools.AppService.Services; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -50,6 +53,25 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var webappGet = serviceProvider.GetRequiredService(); webapp.AddCommand(webappGet.Name, webappGet); + // Add deployment subgroup + var deployment = new CommandGroup("deployment", "Operations for managing Azure App Service web app deployments"); + webapp.AddSubGroup(deployment); + + // Add deployment commands + var deploymentGet = serviceProvider.GetRequiredService(); + deployment.AddCommand(deploymentGet.Name, deploymentGet); + + // Add diagnostic subgroup under webapp + var diagnostic = new CommandGroup("diagnostic", "Operations for diagnosing Azure App Service web apps"); + webapp.AddSubGroup(diagnostic); + + // Add diagnostic commands + var detectorDiagnose = serviceProvider.GetRequiredService(); + diagnostic.AddCommand(detectorDiagnose.Name, detectorDiagnose); + + var detectorList = serviceProvider.GetRequiredService(); + diagnostic.AddCommand(detectorList.Name, detectorList); + // Add settings subgroup under webapp var settings = new CommandGroup("settings", "Operations for managing Azure App Service web settings"); webapp.AddSubGroup(settings); @@ -61,14 +83,6 @@ public CommandGroup RegisterCommands(IServiceProvider serviceProvider) var appSettingsUpdate = serviceProvider.GetRequiredService(); settings.AddCommand(appSettingsUpdate.Name, appSettingsUpdate); - // Add deployment subgroup - var deployment = new CommandGroup("deployment", "Operations for managing Azure App Service web app deployments"); - webapp.AddSubGroup(deployment); - - // Add deployment commands - var deploymentGet = serviceProvider.GetRequiredService(); - deployment.AddCommand(deploymentGet.Name, deploymentGet); - return appService; } } diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/AppServiceJsonContext.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/AppServiceJsonContext.cs index 14392a85c6..dc524b88ef 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/AppServiceJsonContext.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/AppServiceJsonContext.cs @@ -5,8 +5,10 @@ using Azure.Mcp.Tools.AppService.Commands.Database; using Azure.Mcp.Tools.AppService.Commands.Webapp; using Azure.Mcp.Tools.AppService.Commands.Webapp.Deployment; +using Azure.Mcp.Tools.AppService.Commands.Webapp.Diagnostic; using Azure.Mcp.Tools.AppService.Commands.Webapp.Settings; using Azure.Mcp.Tools.AppService.Models; +using Azure.ResourceManager.AppService.Models; namespace Azure.Mcp.Tools.AppService.Commands; @@ -15,6 +17,13 @@ namespace Azure.Mcp.Tools.AppService.Commands; [JsonSerializable(typeof(DatabaseAddCommand.DatabaseAddResult))] [JsonSerializable(typeof(DatabaseConnectionInfo))] [JsonSerializable(typeof(DeploymentGetCommand.DeploymentGetResult))] +[JsonSerializable(typeof(DetectorDiagnoseCommand.DetectorDiagnoseResult))] +[JsonSerializable(typeof(DetectorDetails))] +[JsonSerializable(typeof(DetectorInfo))] +[JsonSerializable(typeof(DetectorListCommand.DetectorListResult))] +[JsonSerializable(typeof(DiagnosticDataset))] +[JsonSerializable(typeof(DiagnosisResults))] +[JsonSerializable(typeof(IList))] [JsonSerializable(typeof(WebappDetails))] [JsonSerializable(typeof(WebappGetCommand.WebappGetResult))] public partial class AppServiceJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/BaseAppServiceCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/BaseAppServiceCommand.cs index e19c758c58..88abff2bc9 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/BaseAppServiceCommand.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/BaseAppServiceCommand.cs @@ -11,7 +11,7 @@ namespace Azure.Mcp.Tools.AppService.Commands; public abstract class BaseAppServiceCommand< - [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions>(bool resourceGroupRequired = false) + [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions>(bool resourceGroupRequired = false, bool appRequired = false) : SubscriptionCommand where TOptions : BaseAppServiceOptions, new() { @@ -21,12 +21,16 @@ protected override void RegisterOptions(Command command) command.Options.Add(resourceGroupRequired ? OptionDefinitions.Common.ResourceGroup.AsRequired() : OptionDefinitions.Common.ResourceGroup.AsOptional()); + command.Options.Add(appRequired + ? AppServiceOptionDefinitions.AppServiceName.AsRequired() + : AppServiceOptionDefinitions.AppServiceName.AsOptional()); } protected override TOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + options.AppName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName.Name); return options; } } diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Database/DatabaseAddCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Database/DatabaseAddCommand.cs index ca46d0ea6a..0ae5baf574 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/Database/DatabaseAddCommand.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Database/DatabaseAddCommand.cs @@ -12,7 +12,7 @@ namespace Azure.Mcp.Tools.AppService.Commands.Database; public sealed class DatabaseAddCommand(ILogger logger) - : BaseAppServiceCommand(resourceGroupRequired: true) + : BaseAppServiceCommand(resourceGroupRequired: true, appRequired: true) { private const string CommandTitle = "Add Database to App Service"; private readonly ILogger _logger = logger; @@ -43,7 +43,6 @@ public sealed class DatabaseAddCommand(ILogger logger) protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(AppServiceOptionDefinitions.AppServiceName); command.Options.Add(AppServiceOptionDefinitions.DatabaseTypeOption); command.Options.Add(AppServiceOptionDefinitions.DatabaseServerOption); command.Options.Add(AppServiceOptionDefinitions.DatabaseNameOption); @@ -53,7 +52,6 @@ protected override void RegisterOptions(Command command) protected override DatabaseAddOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); - options.AppName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName); options.DatabaseType = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.DatabaseTypeOption); options.DatabaseServer = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.DatabaseServerOption); options.DatabaseName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.DatabaseNameOption); diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Deployment/DeploymentGetCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Deployment/DeploymentGetCommand.cs index c9f601504c..233d4271e8 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Deployment/DeploymentGetCommand.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Deployment/DeploymentGetCommand.cs @@ -13,7 +13,7 @@ namespace Azure.Mcp.Tools.AppService.Commands.Webapp.Deployment; public sealed class DeploymentGetCommand(ILogger logger) - : BaseAppServiceCommand(resourceGroupRequired: true) + : BaseAppServiceCommand(resourceGroupRequired: true, appRequired: true) { private const string CommandTitle = "Gets Azure App Service Web App Deployment Details"; private readonly ILogger _logger = logger; @@ -44,14 +44,12 @@ public sealed class DeploymentGetCommand(ILogger logger) protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(AppServiceOptionDefinitions.AppServiceName); command.Options.Add(AppServiceOptionDefinitions.DeploymentIdOption); } protected override DeploymentGetOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); - options.AppName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName.Name); options.DeploymentId = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.DeploymentIdOption.Name); return options; } diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Diagnostic/DetectorDiagnoseCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Diagnostic/DetectorDiagnoseCommand.cs new file mode 100644 index 0000000000..7466271dfd --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Diagnostic/DetectorDiagnoseCommand.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.AppService.Models; +using Azure.Mcp.Tools.AppService.Options; +using Azure.Mcp.Tools.AppService.Options.Webapp.Diagnostic; +using Azure.Mcp.Tools.AppService.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Command; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.AppService.Commands.Webapp.Diagnostic; + +public sealed class DetectorDiagnoseCommand(ILogger logger) + : BaseAppServiceCommand(resourceGroupRequired: true, appRequired: true) +{ + private const string CommandTitle = "Diagnose an App Service Web App"; + private readonly ILogger _logger = logger; + public override string Id => "a8aa0966-4c0c-4e22-8854-cced583f0fb2"; + public override string Name => "diagnose"; + + public override string Description => + """ + Diagnoses an App Service Web App with the specified detector, returning the diagnostic results of the detector. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false + }; + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(AppServiceOptionDefinitions.DetectorName.AsRequired()); + command.Options.Add(AppServiceOptionDefinitions.StartTime); + command.Options.Add(AppServiceOptionDefinitions.EndTime); + command.Options.Add(AppServiceOptionDefinitions.Interval); + command.Validators.Add(result => + { + var startTime = result.GetValueOrDefault(AppServiceOptionDefinitions.StartTime.Name); + var endTime = result.GetValueOrDefault(AppServiceOptionDefinitions.EndTime.Name); + + bool hasStartTime = !string.IsNullOrEmpty(startTime); + bool hasEndTime = !string.IsNullOrEmpty(endTime); + + if (hasStartTime && !DateTimeOffset.TryParse(startTime, out _)) + { + result.AddError($"Invalid start time format: {startTime}. Please provide a valid ISO format date time string."); + } + + if (hasEndTime && !DateTimeOffset.TryParse(endTime, out _)) + { + result.AddError($"Invalid end time format: {endTime}. Please provide a valid ISO format date time string."); + } + + if (hasStartTime && hasEndTime + && DateTimeOffset.TryParse(startTime, out var start) + && DateTimeOffset.TryParse(endTime, out var end) + && start > end) + { + result.AddError($"Start time '{startTime}' must be earlier than end time '{endTime}'."); + } + }); + } + + protected override DetectorDiagnoseOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.DetectorName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.DetectorName.Name); + if (DateTimeOffset.TryParse(parseResult.GetValueOrDefault(AppServiceOptionDefinitions.StartTime.Name), out var startTime)) + { + options.StartTime = startTime.ToUniversalTime(); + } + if (DateTimeOffset.TryParse(parseResult.GetValueOrDefault(AppServiceOptionDefinitions.EndTime.Name), out var endTime)) + { + options.EndTime = endTime.ToUniversalTime(); + } + options.Interval = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.Interval.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + // Validate first, then bind + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + var appServiceService = context.GetService(); + var diagnoses = await appServiceService.DiagnoseDetectorAsync( + options.Subscription!, + options.ResourceGroup!, + options.AppName!, + options.DetectorName!, + options.StartTime, + options.EndTime, + options.Interval, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(new(diagnoses), AppServiceJsonContext.Default.DetectorDiagnoseResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get diagnostic detectors for Web App '{AppName}' in subscription {Subscription} and resource group {ResourceGroup}", + options.AppName, options.Subscription, options.ResourceGroup); + HandleException(context, ex); + } + + return context.Response; + } + + public record DetectorDiagnoseResult(DiagnosisResults Diagnoses); +} diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Diagnostic/DetectorListCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Diagnostic/DetectorListCommand.cs new file mode 100644 index 0000000000..f3dddd96e0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Diagnostic/DetectorListCommand.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.AppService.Models; +using Azure.Mcp.Tools.AppService.Options; +using Azure.Mcp.Tools.AppService.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.AppService.Commands.Webapp.Diagnostic; + +public sealed class DetectorListCommand(ILogger logger) + : BaseAppServiceCommand(resourceGroupRequired: true, appRequired: true) +{ + private const string CommandTitle = "List the Diagnostic Detectors for an App Service Web App"; + private readonly ILogger _logger = logger; + public override string Id => "7807fdb6-4b92-4361-8042-be61dd342e17"; + public override string Name => "list"; + + public override string Description => + """ + Retrieves detailed information about detectors detector for the specified App Service Web App, returning the name, + detector type, description, category, and analysis types for each detector. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + Secret = false, + LocalRequired = false + }; + + protected override void RegisterOptions(Command command) => base.RegisterOptions(command); + + protected override BaseAppServiceOptions BindOptions(ParseResult parseResult) => base.BindOptions(parseResult); + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + // Validate first, then bind + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + context.Activity?.AddTag("subscription", options.Subscription); + + var appServiceService = context.GetService(); + var detectors = await appServiceService.ListDetectorsAsync( + options.Subscription!, + options.ResourceGroup!, + options.AppName!, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(new(detectors), AppServiceJsonContext.Default.DetectorListResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get diagnostic detectors for Web App '{AppName}' in subscription {Subscription} and resource group {ResourceGroup}", + options.AppName, options.Subscription, options.ResourceGroup); + HandleException(context, ex); + } + + return context.Response; + } + + public record DetectorListResult(List Detectors); +} diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsGetCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsGetCommand.cs index 78840bea74..cfe5ec299a 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsGetCommand.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsGetCommand.cs @@ -5,14 +5,12 @@ using Azure.Mcp.Tools.AppService.Services; using Microsoft.Extensions.Logging; using Microsoft.Mcp.Core.Commands; -using Microsoft.Mcp.Core.Extensions; using Microsoft.Mcp.Core.Models.Command; -using Microsoft.Mcp.Core.Models.Option; namespace Azure.Mcp.Tools.AppService.Commands.Webapp.Settings; public sealed class AppSettingsGetCommand(ILogger logger) - : BaseAppServiceCommand(resourceGroupRequired: true) + : BaseAppServiceCommand(resourceGroupRequired: true, appRequired: true) { private const string CommandTitle = "Gets Azure App Service Web App Application Settings"; private readonly ILogger _logger = logger; @@ -37,18 +35,9 @@ setting. Application settings may contain sensitive information. LocalRequired = false }; - protected override void RegisterOptions(Command command) - { - base.RegisterOptions(command); - command.Options.Add(AppServiceOptionDefinitions.AppServiceName.AsRequired()); - } + protected override void RegisterOptions(Command command) => base.RegisterOptions(command); - protected override BaseAppServiceOptions BindOptions(ParseResult parseResult) - { - var options = base.BindOptions(parseResult); - options.AppName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName.Name); - return options; - } + protected override BaseAppServiceOptions BindOptions(ParseResult parseResult) => base.BindOptions(parseResult); public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsUpdateCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsUpdateCommand.cs index 19b2ef3e46..5df483fcea 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsUpdateCommand.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/Settings/AppSettingsUpdateCommand.cs @@ -12,7 +12,7 @@ namespace Azure.Mcp.Tools.AppService.Commands.Webapp.Settings; public sealed class AppSettingsUpdateCommand(ILogger logger) - : BaseAppServiceCommand(resourceGroupRequired: true) + : BaseAppServiceCommand(resourceGroupRequired: true, appRequired: true) { private const string CommandTitle = "Updates Azure App Service Web App Application Settings"; private readonly ILogger _logger = logger; @@ -47,7 +47,6 @@ public sealed class AppSettingsUpdateCommand(ILogger l protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(AppServiceOptionDefinitions.AppServiceName); command.Options.Add(AppServiceOptionDefinitions.AppSettingName); command.Options.Add(AppServiceOptionDefinitions.AppSettingValue); command.Options.Add(AppServiceOptionDefinitions.AppSettingUpdateType); @@ -91,10 +90,9 @@ internal static bool ValidateSettingValue(string? settingUpdateType, string? set return true; } - protected override AppSettingsUpdateCommandOptions BindOptions(ParseResult parseResult) + protected override AppSettingsUpdateOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); - options.AppName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName.Name); options.SettingName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppSettingName.Name); options.SettingValue = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppSettingValue.Name); options.SettingUpdateType = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppSettingUpdateType.Name); diff --git a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/WebappGetCommand.cs b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/WebappGetCommand.cs index fb92588554..46569f48b2 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/WebappGetCommand.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Commands/Webapp/WebappGetCommand.cs @@ -44,7 +44,6 @@ public sealed class WebappGetCommand(ILogger logger) protected override void RegisterOptions(Command command) { base.RegisterOptions(command); - command.Options.Add(AppServiceOptionDefinitions.AppServiceName.AsOptional()); command.Validators.Add(commandResult => { var appName = commandResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName.Name); @@ -56,12 +55,7 @@ protected override void RegisterOptions(Command command) }); } - protected override BaseAppServiceOptions BindOptions(ParseResult parseResult) - { - var options = base.BindOptions(parseResult); - options.AppName = parseResult.GetValueOrDefault(AppServiceOptionDefinitions.AppServiceName.Name); - return options; - } + protected override BaseAppServiceOptions BindOptions(ParseResult parseResult) => base.BindOptions(parseResult); public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { diff --git a/tools/Azure.Mcp.Tools.AppService/src/Models/DetectorDetails.cs b/tools/Azure.Mcp.Tools.AppService/src/Models/DetectorDetails.cs new file mode 100644 index 0000000000..958697d973 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/src/Models/DetectorDetails.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation.Expand commentComment on line R1Resolved +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.AppService.Models; + +/// +/// Represents details about a Web App detector. +/// +public sealed record DetectorDetails( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("category")] string? Category, + [property: JsonPropertyName("analysisTypes")] List? AnalysisTypes); diff --git a/tools/Azure.Mcp.Tools.AppService/src/Models/DiagnosisResult.cs b/tools/Azure.Mcp.Tools.AppService/src/Models/DiagnosisResult.cs new file mode 100644 index 0000000000..97eec1c566 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/src/Models/DiagnosisResult.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation.Expand commentComment on line R1Resolved +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.ResourceManager.AppService.Models; + +namespace Azure.Mcp.Tools.AppService.Models; + +/// +/// Represents diagnoses results after running a Web App detector. +/// +public sealed record DiagnosisResults( + [property: JsonPropertyName("datasets")] IList Datasets, + [property: JsonPropertyName("detector")] DetectorDetails Detector); diff --git a/tools/Azure.Mcp.Tools.AppService/src/Options/AppServiceOptionDefinitions.cs b/tools/Azure.Mcp.Tools.AppService/src/Options/AppServiceOptionDefinitions.cs index d5be933420..671ccb7c07 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Options/AppServiceOptionDefinitions.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Options/AppServiceOptionDefinitions.cs @@ -14,6 +14,10 @@ public static class AppServiceOptionDefinitions public const string AppSettingValueName = "setting-value"; public const string AppSettingUpdateTypeName = "setting-update-type"; public const string DeploymentIdName = "deployment-id"; + public const string DetectorNameName = "detector-name"; + public const string StartTimeName = "start-time"; + public const string EndTimeName = "end-time"; + public const string IntervalName = "interval"; public static readonly Option AppServiceName = new($"--{AppName}") { @@ -68,4 +72,28 @@ public static class AppServiceOptionDefinitions Description = "The ID of the deployment.", Required = false }; + + public static readonly Option DetectorName = new($"--{DetectorNameName}") + { + Description = "The name of the diagnostic detector to run (e.g., Availability, CpuAnalysis, MemoryAnalysis).", + Required = true + }; + + public static readonly Option StartTime = new($"--{StartTimeName}") + { + Description = "The start time in ISO format (e.g., 2023-01-01T00:00:00Z).", + Required = false + }; + + public static readonly Option EndTime = new($"--{EndTimeName}") + { + Description = "The end time in ISO format (e.g., 2023-01-01T00:00:00Z).", + Required = false + }; + + public static readonly Option Interval = new($"--{IntervalName}") + { + Description = "The time interval (e.g., PT1H for 1 hour, PT5M for 5 minutes).", + Required = false + }; } diff --git a/tools/Azure.Mcp.Tools.AppService/src/Options/Webapp/Diagnostic/DetectorDiagnoseOptions.cs b/tools/Azure.Mcp.Tools.AppService/src/Options/Webapp/Diagnostic/DetectorDiagnoseOptions.cs new file mode 100644 index 0000000000..8cb0d2b956 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/src/Options/Webapp/Diagnostic/DetectorDiagnoseOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.AppService.Options.Webapp.Diagnostic; + +public sealed class DetectorDiagnoseOptions : BaseAppServiceOptions +{ + [JsonPropertyName(AppServiceOptionDefinitions.DetectorNameName)] + public string? DetectorName { get; set; } + + [JsonPropertyName(AppServiceOptionDefinitions.StartTimeName)] + public DateTimeOffset? StartTime { get; set; } + + [JsonPropertyName(AppServiceOptionDefinitions.EndTimeName)] + public DateTimeOffset? EndTime { get; set; } + + [JsonPropertyName(AppServiceOptionDefinitions.IntervalName)] + public string? Interval { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.AppService/src/Options/Settings/AppSettingsUpdateCommandOptions.cs b/tools/Azure.Mcp.Tools.AppService/src/Options/Webapp/Settings/AppSettingsUpdateOptions.cs similarity index 87% rename from tools/Azure.Mcp.Tools.AppService/src/Options/Settings/AppSettingsUpdateCommandOptions.cs rename to tools/Azure.Mcp.Tools.AppService/src/Options/Webapp/Settings/AppSettingsUpdateOptions.cs index 9de4a1653a..6b9ad03637 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Options/Settings/AppSettingsUpdateCommandOptions.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Options/Webapp/Settings/AppSettingsUpdateOptions.cs @@ -5,7 +5,7 @@ namespace Azure.Mcp.Tools.AppService.Options.Webapp.Settings; -public sealed class AppSettingsUpdateCommandOptions : BaseAppServiceOptions +public sealed class AppSettingsUpdateOptions : BaseAppServiceOptions { [JsonPropertyName(AppServiceOptionDefinitions.AppSettingNameName)] public string? SettingName { get; set; } diff --git a/tools/Azure.Mcp.Tools.AppService/src/Services/AppServiceService.cs b/tools/Azure.Mcp.Tools.AppService/src/Services/AppServiceService.cs index 75d66a74ce..957a02d095 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Services/AppServiceService.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Services/AppServiceService.cs @@ -1,11 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net.Http.Headers; +using System.Text.Json; +using Azure.Core; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.Mcp.Core.Services.Azure.Subscription; using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.AppService.Commands; using Azure.Mcp.Tools.AppService.Commands.Webapp.Settings; using Azure.Mcp.Tools.AppService.Models; using Azure.ResourceManager.AppService; @@ -335,7 +339,6 @@ public async Task UpdateAppSettingsAsync( return updateResultMessage; } - public async Task> GetDeploymentsAsync( string subscription, string resourceGroup, @@ -370,4 +373,158 @@ public async Task> GetDeploymentsAsync( private static DeploymentDetails MapToDeploymentDetails(WebAppDeploymentData deployment) => new(deployment.Id.Name, deployment.ResourceType.ToString(), deployment.Kind, deployment.IsActive, deployment.Status, deployment.Author, deployment.Deployer, deployment.StartOn, deployment.EndOn); + + public async Task> ListDetectorsAsync( + string subscription, + string resourceGroup, + string appName, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroup), resourceGroup), (nameof(appName), appName)); + + // TODO (alzimmer): Once https://github.com/Azure/azure-sdk-for-net/issues/51444 is resolved, + // use WebSiteResource.GetSiteDetectors().GetAllAsync instead of using a direct HttpClient. + // var results = new List(); + // var webAppResource = await GetWebAppResourceAsync(subscription, resourceGroup, appName, tenant, retryPolicy, cancellationToken); + // await foreach (var detector = await webAppResource.GetSiteDetectors().GetAllAsync(cancellationToken)) + // { + // results.Add(MapToDetectorDetails(detector.Data)); + // } + return await CallDetectorsAsync(tenant, subscription, resourceGroup, appName, MapToListDetectorDetails, cancellationToken: cancellationToken); + } + + private static List MapToListDetectorDetails(JsonDocument jsonDocument) + { + if (!jsonDocument.RootElement.TryGetProperty("value", out var detectorsArray)) + { + throw new InvalidOperationException($"Unexpected response format: 'value' property is missing."); + } + + if (detectorsArray.ValueKind == JsonValueKind.Array) + { + var results = new List(); + foreach (var detectorElement in detectorsArray.EnumerateArray()) + { + results.Add(MapToDetectorDetails(detectorElement.GetProperty("properties").GetProperty("metadata"))); + } + + return results; + } + else if (detectorsArray.ValueKind == JsonValueKind.Null) + { + return []; + } + else + { + throw new InvalidOperationException($"Unexpected response format: 'value' property is not an array or null, was '{detectorsArray.ValueKind}'."); + } + } + + private static DetectorDetails MapToDetectorDetails(JsonElement metadata) + { + var name = metadata.GetProperty("name").GetString()!; + var type = metadata.GetProperty("type").GetString()!; + var description = metadata.GetProperty("description").GetString(); + var category = metadata.GetProperty("category").GetString(); + var categories = (metadata.TryGetProperty("analysisTypes", out var analysisTypesElement) && analysisTypesElement.ValueKind == JsonValueKind.Array) + ? analysisTypesElement.EnumerateArray().Select(at => at.GetString() ?? string.Empty).Where(at => !string.IsNullOrEmpty(at)).ToList() + : null; + + return new DetectorDetails(name, type, description, category, categories); + } + + public async Task DiagnoseDetectorAsync( + string subscription, + string resourceGroup, + string appName, + string detectorName, + DateTimeOffset? startTime = null, + DateTimeOffset? endTime = null, + string? interval = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters( + (nameof(subscription), subscription), + (nameof(resourceGroup), resourceGroup), + (nameof(appName), appName), + (nameof(detectorName), detectorName)); + + // TODO (alzimmer): Once https://github.com/Azure/azure-sdk-for-net/issues/51444 is resolved, + // // use WebSiteResource.GetSiteDetectorAsync instead of using a direct HttpClient. + // var webAppResource = await GetWebAppResourceAsync(subscription, resourceGroup, appName, tenant, retryPolicy, cancellationToken); + // var diagnoses = await webAppResource.GetSiteDetectorAsync(detectorName, startTime, endTime, interval, cancellationToken); + + // return new DiagnosesResults(diagnoses.Value.Data.Dataset, diagnoses.Value.Data.Metadata); + return await CallDetectorsAsync(tenant, subscription, resourceGroup, appName, MapToDiagnosesResults, detectorName: detectorName, cancellationToken: cancellationToken); + } + + private static DiagnosisResults MapToDiagnosesResults(JsonDocument jsonDocument) + { + if (!jsonDocument.RootElement.TryGetProperty("properties", out var properties)) + { + throw new InvalidOperationException($"Unexpected response format: 'properties' property is missing."); + } + + var dataset = JsonSerializer.Deserialize(properties.GetProperty("dataset"), AppServiceJsonContext.Default.IListDiagnosticDataset)!; + var detector = MapToDetectorDetails(properties.GetProperty("metadata")); + + return new DiagnosisResults(dataset, detector); + } + + private string GetDetectorsEndpoint(string subscriptionId, string resourceGroupName, string siteName, string? detectorName = null) + { + string subscriptionPath = string.IsNullOrEmpty(detectorName) + ? $"subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{siteName}/detectors?api-version=2025-05-01" + : $"subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{siteName}/detectors/{detectorName}?api-version=2025-05-01"; + return _tenantService.CloudConfiguration.CloudType switch + { + AzureCloudConfiguration.AzureCloud.AzurePublicCloud => $"https://management.azure.com/{subscriptionPath}", + AzureCloudConfiguration.AzureCloud.AzureChinaCloud => $"https://management.chinacloudapi.cn/{subscriptionPath}", + AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud => $"https://management.usgovcloudapi.net/{subscriptionPath}", + _ => $"https://management.azure.com/{subscriptionPath}" + }; + } + + private async Task CallDetectorsAsync( + string? tenant, + string subscription, + string resourceGroup, + string appName, + Func mapFunc, + string? detectorName = null, + CancellationToken cancellationToken = default) + { + var httpRequest = new HttpRequestMessage(HttpMethod.Get, GetDetectorsEndpoint(subscription, resourceGroup, appName, detectorName)); + var scopes = new string[] + { + _tenantService.CloudConfiguration.ArmEnvironment.DefaultScope + }; + var clientRequestId = "AzMcp" + Guid.NewGuid().ToString(); + var tokenRequestContext = new TokenRequestContext(scopes, clientRequestId); + + var tokenCredential = await _tenantService.GetTokenCredentialAsync(tenant, cancellationToken: cancellationToken); + var accessToken = await tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken.Token); + httpRequest.Headers.Add("User-Agent", UserAgent); + httpRequest.Headers.Add("x-ms-client-request-id", clientRequestId); + httpRequest.Headers.Add("x-ms-app", "AzureMCP"); + httpRequest.Headers.Add("x-ms-client-version", "AppService.Client.Light"); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var httpResponse = await TenantService.GetClient().SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, cancellationToken); + if (!httpResponse.IsSuccessStatusCode) + { + string errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Request failed with status code {httpResponse.StatusCode}: {errorContent}"); + } + + using var contentStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken); + using var jsonDoc = await JsonDocument.ParseAsync(contentStream, cancellationToken: cancellationToken); + + return mapFunc(jsonDoc); + } } diff --git a/tools/Azure.Mcp.Tools.AppService/src/Services/IAppServiceService.cs b/tools/Azure.Mcp.Tools.AppService/src/Services/IAppServiceService.cs index 1fbcb412d9..2797bc309b 100644 --- a/tools/Azure.Mcp.Tools.AppService/src/Services/IAppServiceService.cs +++ b/tools/Azure.Mcp.Tools.AppService/src/Services/IAppServiceService.cs @@ -55,4 +55,24 @@ Task> GetDeploymentsAsync( string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + + Task> ListDetectorsAsync( + string subscription, + string resourceGroup, + string appName, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); + + Task DiagnoseDetectorAsync( + string subscription, + string resourceGroup, + string appName, + string detectorName, + DateTimeOffset? startTime = null, + DateTimeOffset? endTime = null, + string? interval = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); } diff --git a/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/Webapp/Diagnostic/DetectorDiagnoseCommandLiveTests.cs b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/Webapp/Diagnostic/DetectorDiagnoseCommandLiveTests.cs new file mode 100644 index 0000000000..6fa7fd5be4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/Webapp/Diagnostic/DetectorDiagnoseCommandLiveTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tools.AppService.Commands; +using Xunit; + +namespace Azure.Mcp.Tools.AppService.LiveTests.Webapp.Diagnostic; + +[Trait("Command", "DetectorDiagnoseCommand")] +public class DetectorDiagnoseCommandLiveTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) + : BaseAppServiceCommandLiveTests(output, fixture, liveServerFixture) +{ + [Fact] + public async Task ExecuteAsync_DetectorsDiagnose_ReturnsDiagnostics() + { + var webappName = RegisterOrRetrieveDeploymentOutputVariable("webappName", "WEBAPPNAME"); + webappName = TestMode == Tests.Helpers.TestMode.Playback ? "Sanitized-webapp" : webappName; + var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + + var result = await CallToolAsync( + "appservice_webapp_diagnostic_diagnose", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", resourceGroupName }, + { "app", webappName }, + { "detector-name", "Memoryusage"} + }); + + var detectorsResult = JsonSerializer.Deserialize(result.Value, AppServiceJsonContext.Default.DetectorDiagnoseResult); + Assert.NotNull(detectorsResult); + Assert.NotNull(detectorsResult.Diagnoses); + Assert.NotEmpty(detectorsResult.Diagnoses.Datasets); + } + + [Fact] + public async Task ExecuteAsync_DetectorsDiagnoseWithOptionalParams_ReturnsDiagnostics() + { + var webappName = RegisterOrRetrieveDeploymentOutputVariable("webappName", "WEBAPPNAME"); + webappName = TestMode == Tests.Helpers.TestMode.Playback ? "Sanitized-webapp" : webappName; + var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + + var result = await CallToolAsync( + "appservice_webapp_diagnostic_diagnose", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", resourceGroupName }, + { "app", webappName }, + { "detector-name", "Memoryusage"}, + { "start-time", DateTimeOffset.UtcNow.AddHours(-1).ToString("o") }, + { "end-time", DateTimeOffset.UtcNow.ToString("o") }, + { "time-grain", "PT10M" } + }); + + var detectorsResult = JsonSerializer.Deserialize(result.Value, AppServiceJsonContext.Default.DetectorDiagnoseResult); + Assert.NotNull(detectorsResult); + Assert.NotNull(detectorsResult.Diagnoses); + Assert.NotEmpty(detectorsResult.Diagnoses.Datasets); + } +} diff --git a/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/Webapp/Diagnostic/DetectorListCommandLiveTests.cs b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/Webapp/Diagnostic/DetectorListCommandLiveTests.cs new file mode 100644 index 0000000000..9dd998fabd --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/Webapp/Diagnostic/DetectorListCommandLiveTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tools.AppService.Commands; +using Xunit; + +namespace Azure.Mcp.Tools.AppService.LiveTests.Webapp.Diagnostic; + +[Trait("Command", "DetectorListCommand")] +public class DetectorListCommandLiveTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) + : BaseAppServiceCommandLiveTests(output, fixture, liveServerFixture) +{ + [Fact] + public async Task ExecuteAsync_DetectorsList_ReturnsDetectors() + { + var webappName = RegisterOrRetrieveDeploymentOutputVariable("webappName", "WEBAPPNAME"); + webappName = TestMode == Tests.Helpers.TestMode.Playback ? "Sanitized-webapp" : webappName; + var resourceGroupName = RegisterOrRetrieveVariable("resourceGroupName", Settings.ResourceGroupName); + + var result = await CallToolAsync( + "appservice_webapp_diagnostic_list", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", resourceGroupName }, + { "app", webappName } + }); + + var detectorsResult = JsonSerializer.Deserialize(result.Value, AppServiceJsonContext.Default.DetectorListResult); + Assert.NotNull(detectorsResult); + Assert.NotEmpty(detectorsResult.Detectors); + } +} diff --git a/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/assets.json b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/assets.json index 22066dee2b..6efbf9ce7b 100644 --- a/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/assets.json +++ b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.LiveTests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.AppService.LiveTests", - "Tag": "Azure.Mcp.Tools.AppService.LiveTests_633995ecbb" + "Tag": "Azure.Mcp.Tools.AppService.LiveTests_027292fd92" } diff --git a/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.UnitTests/Commands/Webapp/Diagnostic/DetectorDiagnoseCommandTests.cs b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.UnitTests/Commands/Webapp/Diagnostic/DetectorDiagnoseCommandTests.cs new file mode 100644 index 0000000000..8a930be88c --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.UnitTests/Commands/Webapp/Diagnostic/DetectorDiagnoseCommandTests.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.AppService.Commands; +using Azure.Mcp.Tools.AppService.Commands.Webapp.Diagnostic; +using Azure.Mcp.Tools.AppService.Models; +using Azure.Mcp.Tools.AppService.Services; +using Azure.ResourceManager.AppService.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.AppService.UnitTests.Commands.Webapp.Diagnostic; + +[Trait("Command", "DetectorDiagnose")] +public class DetectorDiagnoseCommandTests +{ + private readonly IAppServiceService _appServiceService; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly DetectorDiagnoseCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public DetectorDiagnoseCommandTests() + { + _appServiceService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_appServiceService); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Theory] + [InlineData(null, null, null)] + [InlineData(null, null, "PT1H")] + [InlineData("2023-01-01T00:00:00Z", null, null)] + [InlineData(null, "2023-01-02T00:00:00Z", null)] + [InlineData("2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z", null)] + [InlineData("2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z", "PT1H")] + public async Task ExecuteAsync_WithValidParameters_CallsServiceWithCorrectArguments(string? startDateTimeString, string? endDateTimeString, string? interval) + { + var dataset = new DiagnosticDataset() + { + Table = new DataTableResponseObject(), + RenderingProperties = new DiagnosticDataRendering() + }; + var expectedValue = new DiagnosisResults([dataset], new DetectorDetails("name", "type", "description", "category", ["analysisType1", "analysisType2"])); + + var startTime = startDateTimeString != null ? DateTimeOffset.Parse(startDateTimeString).ToUniversalTime() : (DateTimeOffset?)null; + var endTime = endDateTimeString != null ? DateTimeOffset.Parse(endDateTimeString).ToUniversalTime() : (DateTimeOffset?)null; + + // Arrange + // Set up the mock to return success for any arguments + _appServiceService.DiagnoseDetectorAsync("sub123", "rg1", "test-app", "detector-name", startTime, endTime, + interval, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expectedValue); + + List unparsedArgs = [ + "--subscription", "sub123", + "--resource-group", "rg1", + "--app", "test-app", + "--detector-name", "detector-name" + ]; + if (startDateTimeString != null) + { + unparsedArgs.AddRange(["--start-time", startDateTimeString]); + } + if (endDateTimeString != null) + { + unparsedArgs.AddRange(["--end-time", endDateTimeString]); + } + if (interval != null) + { + unparsedArgs.AddRange(["--interval", interval]); + } + var args = _commandDefinition.Parse(unparsedArgs); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + // Verify that the mock was called with the expected parameters + await _appServiceService.Received(1).DiagnoseDetectorAsync("sub123", "rg1", "test-app", "detector-name", + startTime, endTime, interval, Arg.Any(), Arg.Any(), + Arg.Any()); + + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, AppServiceJsonContext.Default.DetectorDiagnoseResult); + + Assert.NotNull(result); + Assert.Single(result.Diagnoses.Datasets); + Assert.NotNull(result.Diagnoses.Datasets[0]); + Assert.Equal(expectedValue.Detector.Name, result.Diagnoses.Detector.Name); + Assert.Equal(expectedValue.Detector.Type, result.Diagnoses.Detector.Type); + Assert.Equal(expectedValue.Detector.Description, result.Diagnoses.Detector.Description); + Assert.Equal(expectedValue.Detector.Category, result.Diagnoses.Detector.Category); + Assert.Equal(expectedValue.Detector.AnalysisTypes, result.Diagnoses.Detector.AnalysisTypes); + } + + [Theory] + [InlineData()] // Missing all parameters + [InlineData("--subscription", "sub123")] // Missing resource group, app name, and detector name + [InlineData("--resource-group", "rg1")] // Missing subscription, app name, and detector name + [InlineData("--app", "app")] // Missing subscription, resource group, and detector name + [InlineData("--detector-name", "detector")] // Missing subscription, resource group, and app name + [InlineData("--subscription", "sub123", "--resource-group", "rg1")] // Missing app name and detector name + [InlineData("--subscription", "sub123", "--app", "test-app")] // Missing resource group and detector name + [InlineData("--subscription", "sub123", "--detector-name", "detector-name")] // Missing resource group and app name + [InlineData("--resource-group", "rg1", "--app", "test-app")] // Missing subscription and detector name + [InlineData("--resource-group", "rg1", "--detector-name", "detector-name")] // Missing subscription and app name + [InlineData("--app", "test-app", "--detector-name", "detector-name")] // Missing subscription and resource group + [InlineData("--subscription", "sub123", "--resource-group", "rg1", "--app", "test-app")] // Missing detector name + [InlineData("--subscription", "sub123", "--resource-group", "rg1", "--detector-name", "detector-name")] // Missing app name + [InlineData("--subscription", "sub123", "--app", "test-app", "--detector-name", "detector-name")] // Missing resource group + [InlineData("--resource-group", "rg1", "--app", "test-app", "--detector-name", "detector-name")] // Missing subscription + public async Task ExecuteAsync_MissingRequiredParameter_ReturnsErrorResponse(params string[] commandArgs) + { + // Arrange + var args = _commandDefinition.Parse(commandArgs); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + + await _appServiceService.DidNotReceive().DiagnoseDetectorAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData(null, null, null)] + [InlineData(null, null, "PT1H")] + [InlineData("2023-01-01T00:00:00Z", null, null)] + [InlineData(null, "2023-01-02T00:00:00Z", null)] + [InlineData("2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z", null)] + [InlineData("2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z", "PT1H")] + public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorResponse(string? startDateTimeString, string? endDateTimeString, string? interval) + { + var startTime = startDateTimeString != null ? DateTimeOffset.Parse(startDateTimeString).ToUniversalTime() : (DateTimeOffset?)null; + var endTime = endDateTimeString != null ? DateTimeOffset.Parse(endDateTimeString).ToUniversalTime() : (DateTimeOffset?)null; + + // Arrange + // Set up the mock to return success for any arguments + _appServiceService.DiagnoseDetectorAsync("sub123", "rg1", "test-app", "detector-name", startTime, endTime, + interval, Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Service error")); + + List unparsedArgs = [ + "--subscription", "sub123", + "--resource-group", "rg1", + "--app", "test-app", + "--detector-name", "detector-name" + ]; + if (startDateTimeString != null) + { + unparsedArgs.AddRange(["--start-time", startDateTimeString]); + } + if (endDateTimeString != null) + { + unparsedArgs.AddRange(["--end-time", endDateTimeString]); + } + if (interval != null) + { + unparsedArgs.AddRange(["--interval", interval]); + } + var args = _commandDefinition.Parse(unparsedArgs); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + + await _appServiceService.Received(1).DiagnoseDetectorAsync("sub123", "rg1", "test-app", "detector-name", + startTime, endTime, interval, Arg.Any(), Arg.Any(), + Arg.Any()); + } +} diff --git a/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.UnitTests/Commands/Webapp/Diagnostic/DetectorListCommandTests.cs b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.UnitTests/Commands/Webapp/Diagnostic/DetectorListCommandTests.cs new file mode 100644 index 0000000000..26eef0c5bf --- /dev/null +++ b/tools/Azure.Mcp.Tools.AppService/tests/Azure.Mcp.Tools.AppService.UnitTests/Commands/Webapp/Diagnostic/DetectorListCommandTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.AppService.Commands; +using Azure.Mcp.Tools.AppService.Commands.Webapp.Diagnostic; +using Azure.Mcp.Tools.AppService.Models; +using Azure.Mcp.Tools.AppService.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.AppService.UnitTests.Commands.Webapp.Diagnostic; + +[Trait("Command", "DetectorList")] +public class DetectorListCommandTests +{ + private readonly IAppServiceService _appServiceService; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly DetectorListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public DetectorListCommandTests() + { + _appServiceService = Substitute.For(); + _logger = Substitute.For>(); + + var collection = new ServiceCollection().AddSingleton(_appServiceService); + _serviceProvider = collection.BuildServiceProvider(); + + _command = new(_logger); + _context = new(_serviceProvider); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_WithValidParameters_CallsServiceWithCorrectArguments() + { + List expectedValue = [new DetectorDetails("name", "type", "description", "category", ["analysisType1", "analysisType2"])]; + // Arrange + // Set up the mock to return success for any arguments + _appServiceService.ListDetectorsAsync("sub123", "rg1", "test-app", Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(expectedValue); + + var args = _commandDefinition.Parse(["--subscription", "sub123", "--resource-group", "rg1", "--app", "test-app"]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + // Verify that the mock was called with the expected parameters + await _appServiceService.Received(1).ListDetectorsAsync("sub123", "rg1", "test-app", Arg.Any(), + Arg.Any(), Arg.Any()); + + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, AppServiceJsonContext.Default.DetectorListResult); + + Assert.NotNull(result); + Assert.Single(result.Detectors); + Assert.Equal(expectedValue[0].Name, result.Detectors[0].Name); + Assert.Equal(expectedValue[0].Type, result.Detectors[0].Type); + Assert.Equal(expectedValue[0].Description, result.Detectors[0].Description); + Assert.Equal(expectedValue[0].Category, result.Detectors[0].Category); + Assert.Equal(expectedValue[0].AnalysisTypes, result.Detectors[0].AnalysisTypes); + } + + [Theory] + [InlineData()] // Missing all parameters + [InlineData("--subscription", "sub123")] // Missing resource group and app name, + [InlineData("--resource-group", "rg1")] // Missing subscription and app name + [InlineData("--app", "app")] // Missing subscription and resource group + [InlineData("--subscription", "sub123", "--resource-group", "rg1")] // Missing app name + [InlineData("--subscription", "sub123", "--app", "test-app")] // Missing resource group + [InlineData("--resource-group", "rg1", "--app", "test-app")] // Missing subscription + public async Task ExecuteAsync_MissingRequiredParameter_ReturnsErrorResponse(params string[] commandArgs) + { + // Arrange + var args = _commandDefinition.Parse(commandArgs); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + + await _appServiceService.DidNotReceive().ListDetectorsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorResponse() + { + // Arrange + // Set up the mock to return success for any arguments + _appServiceService.ListDetectorsAsync("sub123", "rg1", "test-app", Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Service error")); + + var args = _commandDefinition.Parse(["--subscription", "sub123", "--resource-group", "rg1", "--app", "test-app"]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + + await _appServiceService.Received(1).ListDetectorsAsync("sub123", "rg1", "test-app", + Arg.Any(), Arg.Any(), Arg.Any()); + } +}