diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bf09f0114..f742ba621 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -55,6 +55,11 @@ # ServiceLabel: %area-BestPractices # ServiceOwners: @g2vinay @conniey +# PRLabel: %area-CloudArchitect +/areas/cloudarchitect/ @msalaman @Azure/azure-mcp + +# ServiceLabel: %area-CloudArchitect +# ServiceOwners: @msalaman # PRLabel: %area-CosmosDB /areas/cosmos/ @sajeetharan @xiangyan99 @Azure/azure-mcp diff --git a/AzureMcp.sln b/AzureMcp.sln index ace1c0a18..ef389c281 100644 --- a/AzureMcp.sln +++ b/AzureMcp.sln @@ -295,6 +295,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Core.UnitTests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Tests", "core\tests\AzureMcp.Tests\AzureMcp.Tests.csproj", "{527FE0F6-40AE-4E71-A483-0F0A2368F2A7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cloudarchitect", "cloudarchitect", "{3C677F0E-A3E3-0F75-DBE2-C3DFA8463D32}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C239126A-8A2E-168D-6172-7C58CE7AEB0A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.CloudArchitect", "areas\cloudarchitect\src\AzureMcp.CloudArchitect\AzureMcp.CloudArchitect.csproj", "{6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{90811FCA-7295-E394-37C4-E1FD75D058A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.CloudArchitect.UnitTests", "areas\cloudarchitect\tests\AzureMcp.CloudArchitect.UnitTests\AzureMcp.CloudArchitect.UnitTests.csproj", "{FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.FunctionApp", "areas\functionapp\src\AzureMcp.FunctionApp\AzureMcp.FunctionApp.csproj", "{E6E10688-A3CD-4C33-8E13-E0E905329272}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "functionapp", "functionapp", "{3310D97C-93BE-4434-BED7-81EB639B3141}" @@ -1204,6 +1213,30 @@ Global {527FE0F6-40AE-4E71-A483-0F0A2368F2A7}.Release|x64.Build.0 = Release|Any CPU {527FE0F6-40AE-4E71-A483-0F0A2368F2A7}.Release|x86.ActiveCfg = Release|Any CPU {527FE0F6-40AE-4E71-A483-0F0A2368F2A7}.Release|x86.Build.0 = Release|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Debug|x64.ActiveCfg = Debug|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Debug|x64.Build.0 = Debug|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Debug|x86.ActiveCfg = Debug|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Debug|x86.Build.0 = Debug|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Release|Any CPU.Build.0 = Release|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Release|x64.ActiveCfg = Release|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Release|x64.Build.0 = Release|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Release|x86.ActiveCfg = Release|Any CPU + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439}.Release|x86.Build.0 = Release|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Debug|x64.Build.0 = Debug|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Debug|x86.Build.0 = Debug|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Release|Any CPU.Build.0 = Release|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Release|x64.ActiveCfg = Release|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Release|x64.Build.0 = Release|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Release|x86.ActiveCfg = Release|Any CPU + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559}.Release|x86.Build.0 = Release|Any CPU {E6E10688-A3CD-4C33-8E13-E0E905329272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E6E10688-A3CD-4C33-8E13-E0E905329272}.Debug|Any CPU.Build.0 = Debug|Any CPU {E6E10688-A3CD-4C33-8E13-E0E905329272}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1533,6 +1566,11 @@ Global {2A3CD1B4-38A3-46A1-AEDC-2C2AC47CB8F1} = {8783C0BC-EE27-8E0C-7452-5882FB8E96CA} {1AE3FC50-8E8C-4637-AAB1-A871D5FB4535} = {8783C0BC-EE27-8E0C-7452-5882FB8E96CA} {527FE0F6-40AE-4E71-A483-0F0A2368F2A7} = {8783C0BC-EE27-8E0C-7452-5882FB8E96CA} + {3C677F0E-A3E3-0F75-DBE2-C3DFA8463D32} = {87783708-79E3-AD60-C783-1D52BE7DE4BB} + {C239126A-8A2E-168D-6172-7C58CE7AEB0A} = {3C677F0E-A3E3-0F75-DBE2-C3DFA8463D32} + {6250B2F1-D4C1-4B4D-B15E-F65CEDB05439} = {C239126A-8A2E-168D-6172-7C58CE7AEB0A} + {90811FCA-7295-E394-37C4-E1FD75D058A2} = {3C677F0E-A3E3-0F75-DBE2-C3DFA8463D32} + {FD8691F1-BFFA-403E-8E1C-4ED6ABB5B559} = {90811FCA-7295-E394-37C4-E1FD75D058A2} {E6E10688-A3CD-4C33-8E13-E0E905329272} = {4A85B59D-2802-46D2-B9D1-CDFE11A37945} {3310D97C-93BE-4434-BED7-81EB639B3141} = {87783708-79E3-AD60-C783-1D52BE7DE4BB} {4A85B59D-2802-46D2-B9D1-CDFE11A37945} = {3310D97C-93BE-4434-BED7-81EB639B3141} diff --git a/CHANGELOG.md b/CHANGELOG.md index 663aead4a..16e88278e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The Azure MCP Server updates automatically by default whenever a new release com ### Features Added +- Added new command for designing Azure Cloud Architecture through guided questions. [[#890](https://github.com/Azure/azure-mcp/pull/890)] - Added support for the following Azure Deploy and Azure Quota operations: [[#626](https://github.com/Azure/azure-mcp/pull/626)] - `azmcp_deploy_app_logs_get` - Get logs from Azure applications deployed using azd. - `azmcp_deploy_iac_rules_get` - Get Infrastructure as Code rules. diff --git a/README.md b/README.md index b73ba7883..4914200f8 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,10 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some * Get the Bicep schema for specific Azure resource types +### 🏗️ Cloud Architect + +* Design Azure cloud architectures through guided questions + Agents and models can discover and learn best practices and usage guidelines for the `azd` MCP tool. For more information, see [AZD Best Practices](https://github.com/Azure/azure-mcp/tree/main/areas/extension/src/AzureMcp.Extension/Resources/azd-best-practices.txt). diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/AssemblyInfo.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/AssemblyInfo.cs new file mode 100644 index 000000000..1ef597281 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AzureMcp.CloudArchitect.UnitTests")] diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/AzureMcp.CloudArchitect.csproj b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/AzureMcp.CloudArchitect.csproj new file mode 100644 index 000000000..4f66c540f --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/AzureMcp.CloudArchitect.csproj @@ -0,0 +1,20 @@ + + + true + + + + + + + + + + + + + + + + + diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/CloudArchitectJsonContext.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/CloudArchitectJsonContext.cs new file mode 100644 index 000000000..8705b3233 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/CloudArchitectJsonContext.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using AzureMcp.CloudArchitect.Commands.Design; +using AzureMcp.CloudArchitect.Models; +using AzureMcp.CloudArchitect.Options; + +namespace AzureMcp.CloudArchitect; + +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(CloudArchitectResponseObject))] +[JsonSerializable(typeof(CloudArchitectDesignResponse))] +[JsonSerializable(typeof(ArchitectureDesignToolState))] +[JsonSerializable(typeof(ArchitectureDesignTiers))] +[JsonSerializable(typeof(ArchitectureDesignRequirements))] +[JsonSerializable(typeof(ArchitectureDesignRequirement))] +[JsonSerializable(typeof(ArchitectureDesignConfidenceFactors))] +[JsonSerializable(typeof(RequirementImportance))] +public partial class CloudArchitectJsonContext : JsonSerializerContext +{ +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/CloudArchitectSetup.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/CloudArchitectSetup.cs new file mode 100644 index 000000000..25aa22760 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/CloudArchitectSetup.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.CloudArchitect.Commands.Design; +using AzureMcp.Core.Areas; +using AzureMcp.Core.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.CloudArchitect; + +public class CloudArchitectSetup : IAreaSetup +{ + public void ConfigureServices(IServiceCollection services) + { + } + + public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory) + { + // Create CloudArchitect command group + var cloudArchitect = new CommandGroup("cloudarchitect", "Cloud Architecture operations - Commands for generating Azure architecture designs and recommendations based on requirements."); + rootGroup.AddSubGroup(cloudArchitect); + + // Register CloudArchitect commands + cloudArchitect.AddCommand("design", new DesignCommand( + loggerFactory.CreateLogger())); + } +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Commands/Design/DesignCommand.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Commands/Design/DesignCommand.cs new file mode 100644 index 000000000..fcad6a806 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Commands/Design/DesignCommand.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using AzureMcp.CloudArchitect.Models; +using AzureMcp.CloudArchitect.Options; +using AzureMcp.Core.Commands; +using AzureMcp.Core.Helpers; +using AzureMcp.Core.Models; +using Microsoft.Extensions.Logging; + +namespace AzureMcp.CloudArchitect.Commands.Design; + +public sealed class DesignCommand(ILogger logger) : GlobalCommand +{ + private const string CommandTitle = "Design Azure cloud architectures through guided questions"; + private readonly ILogger _logger = logger; + + private readonly Option _questionOption = CloudArchitectOptionDefinitions.Question; + private readonly Option _questionNumberOption = CloudArchitectOptionDefinitions.QuestionNumber; + private readonly Option _questionTotalQuestions = CloudArchitectOptionDefinitions.TotalQuestions; + private readonly Option _answerOption = CloudArchitectOptionDefinitions.Answer; + private readonly Option _nextQuestionNeededOption = CloudArchitectOptionDefinitions.NextQuestionNeeded; + private readonly Option _confidenceScoreOption = CloudArchitectOptionDefinitions.ConfidenceScore; + + private readonly Option _architectureDesignToolState = CloudArchitectOptionDefinitions.State; + + private static readonly string s_designArchitectureText = LoadArchitectureDesignText(); + + private static string GetArchitectureDesignText() => s_designArchitectureText; + + public override string Name => "design"; + + public override string Description => """ + Azure architecture design tool that gathers requirements through guided questions and recommends optimal solutions. + + Key parameters: question, questionNumber, confidenceScore (0.0-1.0, present architecture when ≥0.7), totalQuestions, answer, nextQuestionNeeded, architectureComponent, architectureTier, state. + + Process: + 1. Ask about user role, business goals (1-2 questions at a time) + 2. Track confidence and update requirements (explicit/implicit/assumed) + 3. When confident enough, present architecture with table format, visual organization, ASCII diagrams + 4. Follow Azure Well-Architected Framework principles + 5. Cover all tiers: infrastructure, platform, application, data, security, operations + 6. Provide actionable advice and high-level overview + + State tracks components, requirements by category, and confidence factors. Be conservative with suggestions. + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + ReadOnly = true + }; + + private static string LoadArchitectureDesignText() + { + Assembly assembly = typeof(DesignCommand).Assembly; + string resourceName = EmbeddedResourceHelper.FindEmbeddedResource(assembly, "azure-architecture-design.txt"); + return EmbeddedResourceHelper.ReadEmbeddedResource(assembly, resourceName); + } + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.AddOption(_questionOption); + command.AddOption(_questionNumberOption); + command.AddOption(_questionTotalQuestions); + command.AddOption(_answerOption); + command.AddOption(_nextQuestionNeededOption); + command.AddOption(_confidenceScoreOption); + command.AddOption(_architectureDesignToolState); + + command.AddValidator(result => + { + // Validate confidence score is between 0.0 and 1.0 + var confidenceScore = result.GetValueForOption(_confidenceScoreOption); + if (confidenceScore < 0.0 || confidenceScore > 1.0) + { + result.ErrorMessage = "Confidence score must be between 0.0 and 1.0"; + return; + } + + // Validate question number is not negative + var questionNumber = result.GetValueForOption(_questionNumberOption); + if (questionNumber < 0) + { + result.ErrorMessage = "Question number cannot be negative"; + return; + } + + // Validate total questions is not negative + var totalQuestions = result.GetValueForOption(_questionTotalQuestions); + if (totalQuestions < 0) + { + result.ErrorMessage = "Total questions cannot be negative"; + return; + } + }); + } + + protected override ArchitectureDesignToolOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.Question = parseResult.GetValueForOption(_questionOption) ?? string.Empty; + options.QuestionNumber = parseResult.GetValueForOption(_questionNumberOption); + options.TotalQuestions = parseResult.GetValueForOption(_questionTotalQuestions); + options.Answer = parseResult.GetValueForOption(_answerOption); + options.NextQuestionNeeded = parseResult.GetValueForOption(_nextQuestionNeededOption); + options.ConfidenceScore = parseResult.GetValueForOption(_confidenceScoreOption); + options.State = DeserializeState(parseResult.GetValueForOption(_architectureDesignToolState)); + return options; + } + + private static ArchitectureDesignToolState DeserializeState(string? stateJson) + { + if (string.IsNullOrEmpty(stateJson)) + { + return new ArchitectureDesignToolState(); + } + + try + { + var state = JsonSerializer.Deserialize(stateJson, CloudArchitectJsonContext.Default.ArchitectureDesignToolState); + return state ?? new ArchitectureDesignToolState(); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Failed to deserialize state JSON: {ex.Message}", ex); + } + } + + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult) + { + try + { + var options = BindOptions(parseResult); + + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return Task.FromResult(context.Response); + } + + var designArchitecture = GetArchitectureDesignText(); + var responseObject = new CloudArchitectResponseObject + { + DisplayText = options.Question, + DisplayThought = options.State.Thought, + DisplayHint = options.State.SuggestedHint, + QuestionNumber = options.QuestionNumber, + TotalQuestions = options.TotalQuestions, + NextQuestionNeeded = options.NextQuestionNeeded, + State = options.State + }; + + var result = new CloudArchitectDesignResponse + { + DesignArchitecture = designArchitecture, + ResponseObject = responseObject + }; + + context.Response.Status = 200; + context.Response.Results = ResponseResult.Create(result, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + context.Response.Message = string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred in cloud architect design command"); + HandleException(context, ex); + } + return Task.FromResult(context.Response); + } +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/GlobalUsings.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/GlobalUsings.cs new file mode 100644 index 000000000..923301984 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/GlobalUsings.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System.CommandLine; +global using System.CommandLine.Parsing; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using AzureMcp.Core.Extensions; +global using AzureMcp.Core.Models; +global using AzureMcp.Core.Models.Command; +global using ModelContextProtocol.Server; diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Models/CloudArchitectResponseObject.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Models/CloudArchitectResponseObject.cs new file mode 100644 index 000000000..3c44387c4 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Models/CloudArchitectResponseObject.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.CloudArchitect.Options; + +namespace AzureMcp.CloudArchitect.Models; + +/// +/// Response object for the cloud architect design command. +/// +public class CloudArchitectResponseObject +{ + public string DisplayText { get; set; } = string.Empty; + + public string DisplayThought { get; set; } = string.Empty; + + public string DisplayHint { get; set; } = string.Empty; + + public int QuestionNumber { get; set; } + + public int TotalQuestions { get; set; } + + public bool NextQuestionNeeded { get; set; } + + public ArchitectureDesignToolState State { get; set; } = new(); +} + +/// +/// Complete response for the cloud architect design command including both response object and design architecture text. +/// +public class CloudArchitectDesignResponse +{ + public string DesignArchitecture { get; set; } = string.Empty; + + public CloudArchitectResponseObject ResponseObject { get; set; } = new(); +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignConfidenceFactors.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignConfidenceFactors.cs new file mode 100644 index 000000000..bd341e3b1 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignConfidenceFactors.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// Confidence factors for the architecture design. +/// +public class ArchitectureDesignConfidenceFactors +{ + public double ExplicitRequirementsCoverage { get; set; } + + public double ImplicitRequirementsCertainty { get; set; } + + public double AssumptionRisk { get; set; } +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignRequirement.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignRequirement.cs new file mode 100644 index 000000000..05309c9ab --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignRequirement.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// Represents a single architecture design requirement. +/// +public class ArchitectureDesignRequirement +{ + public string Category { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public string Source { get; set; } = string.Empty; + + public RequirementImportance Importance { get; set; } + + public double Confidence { get; set; } +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignRequirements.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignRequirements.cs new file mode 100644 index 000000000..e1d6031be --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignRequirements.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// Contains all requirements for the architecture design. +/// +public class ArchitectureDesignRequirements +{ + public List Explicit { get; set; } = new(); + + public List Implicit { get; set; } = new(); + + public List Assumed { get; set; } = new(); +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignTiers.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignTiers.cs new file mode 100644 index 000000000..52225f6ff --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignTiers.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// Represents the different architecture tiers. +/// +public class ArchitectureDesignTiers +{ + public List Infrastructure { get; set; } = new(); + + public List Platform { get; set; } = new(); + + public List Application { get; set; } = new(); + + public List Data { get; set; } = new(); + + public List Security { get; set; } = new(); + + public List Operations { get; set; } = new(); +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignToolOptions.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignToolOptions.cs new file mode 100644 index 000000000..2addee793 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignToolOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AzureMcp.Core.Options; + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// The set of parameters that the architecture design tool takes as input. +/// +public class ArchitectureDesignToolOptions : GlobalOptions +{ + public string Question { get; set; } = string.Empty; + + public int QuestionNumber { get; set; } + + public int TotalQuestions { get; set; } + + public string? Answer { get; set; } + + public bool NextQuestionNeeded { get; set; } + + public double? ConfidenceScore { get; set; } + + public string? ArchitectureComponent { get; set; } + + public ArchitectureTier? ArchitectureTier { get; set; } + + public ArchitectureDesignToolState State { get; set; } = new(); +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignToolState.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignToolState.cs new file mode 100644 index 000000000..e7283c349 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureDesignToolState.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// The state object for the architecture design tool. +/// +public class ArchitectureDesignToolState +{ + public List ArchitectureComponents { get; set; } = new(); + + public ArchitectureDesignTiers ArchitectureTiers { get; set; } = new(); + + public string Thought { get; set; } = string.Empty; + + public string SuggestedHint { get; set; } = string.Empty; + + public ArchitectureDesignRequirements Requirements { get; set; } = new(); + + public ArchitectureDesignConfidenceFactors ConfidenceFactors { get; set; } = new(); +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureTier.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureTier.cs new file mode 100644 index 000000000..6a4ad0ca2 --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/ArchitectureTier.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// Represents the different architecture tiers. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ArchitectureTier +{ + Infrastructure, + Platform, + Application, + Data, + Security, + Operations +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/CloudArchitectOptionDefinitions.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/CloudArchitectOptionDefinitions.cs new file mode 100644 index 000000000..0e0118d3a --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/CloudArchitectOptionDefinitions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; + +namespace AzureMcp.CloudArchitect.Options; + +public static class CloudArchitectOptionDefinitions +{ + public const string QuestionName = "question"; + public const string QuestionNumberName = "question-number"; + public const string TotalQuestionsName = "total-questions"; + public const string AnswerName = "answer"; + public const string NextQuestionNeededName = "next-question-needed"; + public const string ConfidenceScoreName = "confidence-score"; + public const string ArchitectureComponentName = "architecture-component"; + public const string ArchitectureTierName = "architecture-tier"; + public const string StateName = "state"; + + public static readonly Option Question = new( + $"--{QuestionName}", + "The current question being asked" + ) + { + IsRequired = false + }; + + public static readonly Option QuestionNumber = new( + $"--{QuestionNumberName}", + "Current question number" + ) + { + IsRequired = false + }; + + public static readonly Option TotalQuestions = new( + $"--{TotalQuestionsName}", + "Estimated total questions needed" + ) + { + IsRequired = false + }; + + public static readonly Option Answer = new( + $"--{AnswerName}", + "The user's response to the question" + ) + { + IsRequired = false + }; + + public static readonly Option NextQuestionNeeded = new( + $"--{NextQuestionNeededName}", + "Whether another question is needed" + ) + { + IsRequired = false + }; + + public static readonly Option ConfidenceScore = new( + $"--{ConfidenceScoreName}", + "A value between 0.0 and 1.0 representing confidence in understanding requirements. When this reaches 0.7 or higher, nextQuestionNeeded should be set to false." + ) + { + IsRequired = false + }; + + public static readonly Option State = new( + $"--{StateName}", + "The complete architecture state from the previous request as JSON, State input schema:\n{\n\"state\":{\n\"type\":\"object\",\n\"description\":\"The complete architecture state from the previous request\",\n\"properties\":{\n\"architectureComponents\":{\n\"type\":\"array\",\n\"description\":\"All architecture components suggested so far\",\n\"items\":{\n\"type\":\"string\"\n}\n},\n\"architectureTiers\":{\n\"type\":\"object\",\n\"description\":\"Components organized by architecture tier\",\n\"additionalProperties\":{\n\"type\":\"array\",\n\"items\":{\n\"type\":\"string\"\n}\n}\n},\n\"thought\":{\n\"type\":\"string\",\n\"description\":\"The calling agent's thoughts on the next question or reasoning process. The calling agent should use the requirements it has gathered to reason about the next question.\"\n},\n\"suggestedHint\":{\n\"type\":\"string\",\n\"description\":\"A suggested interaction hint to show the user, such as 'Ask me to create an ASCII art diagram of this architecture' or 'Ask about how this design handles disaster recovery'.\"\n},\n\"requirements\":{\n\"type\":\"object\",\n\"description\":\"Tracked requirements organized by type\",\n\"properties\":{\n\"explicit\":{\n\"type\":\"array\",\n\"description\":\"Requirements explicitly stated by the user\",\n\"items\":{\n\"type\":\"object\",\n\"properties\":{\n\"category\":{\n\"type\":\"string\"\n},\n\"description\":{\n\"type\":\"string\"\n},\n\"source\":{\n\"type\":\"string\"\n},\n\"importance\":{\n\"type\":\"string\",\n\"enum\":[\n\"high\",\n\"medium\",\n\"low\"\n]\n},\n\"confidence\":{\n\"type\":\"number\"\n}\n}\n}\n},\n\"implicit\":{\n\"type\":\"array\",\n\"description\":\"Requirements implied by user responses\",\n\"items\":{\n\"type\":\"object\",\n\"properties\":{\n\"category\":{\n\"type\":\"string\"\n},\n\"description\":{\n\"type\":\"string\"\n},\n\"source\":{\n\"type\":\"string\"\n},\n\"importance\":{\n\"type\":\"string\",\n\"enum\":[\n\"high\",\n\"medium\",\n\"low\"\n]\n},\n\"confidence\":{\n\"type\":\"number\"\n}\n}\n}\n},\n\"assumed\":{\n\"type\":\"array\",\n\"description\":\"Requirements assumed based on context/best practices\",\n\"items\":{\n\"type\":\"object\",\n\"properties\":{\n\"category\":{\n\"type\":\"string\"\n},\n\"description\":{\n\"type\":\"string\"\n},\n\"source\":{\n\"type\":\"string\"\n},\n\"importance\":{\n\"type\":\"string\",\n\"enum\":[\n\"high\",\n\"medium\",\n\"low\"\n]\n},\n\"confidence\":{\n\"type\":\"number\"\n}\n}\n}\n}\n}\n},\n\"confidenceFactors\":{\n\"type\":\"object\",\n\"description\":\"Factors that contribute to the overall confidence score\",\n\"properties\":{\n\"explicitRequirementsCoverage\":{\n\"type\":\"number\"\n},\n\"implicitRequirementsCertainty\":{\n\"type\":\"number\"\n},\n\"assumptionRisk\":{\n\"type\":\"number\"\n}\n}\n}\n}\n}\n}" + ) + { + IsRequired = false + }; +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/RequirementImportance.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/RequirementImportance.cs new file mode 100644 index 000000000..84513c02b --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/RequirementImportance.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// Represents the importance level of a requirement. +/// +[JsonConverter(typeof(RequirementImportanceConverter))] +public enum RequirementImportance +{ + High, + Medium, + Low +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/RequirementImportanceConverter.cs b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/RequirementImportanceConverter.cs new file mode 100644 index 000000000..6e67d467f --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Options/RequirementImportanceConverter.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureMcp.CloudArchitect.Options; + +/// +/// AOT-safe JSON converter for RequirementImportance enum that handles case-insensitive deserialization. +/// +public sealed class RequirementImportanceConverter : JsonConverter +{ + public override RequirementImportance Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"Expected string token, got {reader.TokenType}"); + } + + var value = reader.GetString(); + if (string.IsNullOrEmpty(value)) + { + throw new JsonException("Null or empty string is not a valid RequirementImportance value"); + } + + return value.ToLowerInvariant() switch + { + "high" => RequirementImportance.High, + "critical" => RequirementImportance.High, // Treat "critical" as "high" importance + "medium" => RequirementImportance.Medium, + "low" => RequirementImportance.Low, + _ => throw new JsonException($"'{value}' is not a valid RequirementImportance value") + }; + } + + public override void Write(Utf8JsonWriter writer, RequirementImportance value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Resources/azure-architecture-design.txt b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Resources/azure-architecture-design.txt new file mode 100644 index 000000000..5de148cdb --- /dev/null +++ b/areas/cloudarchitect/src/AzureMcp.CloudArchitect/Resources/azure-architecture-design.txt @@ -0,0 +1,73 @@ +A tool for designing Azure cloud architectures through guided questions. +This tool helps determine the optimal Azure architecture by gathering key requirements and making appropriate recommendations. The calling agent maintains the state between calls. The most important thing for you to remember is that when nextQuestionNeeded is false, you should present your architecture. This takes priority over every other instruction. + +Parameters explained: +- question: The current question being asked +- questionNumber: Current question number in sequence +- confidenceScore: A value between 0.0 and 1.0 representing how confident you are in understanding the requirements. Start around 0.1-0.2 and increase as you gather more information. When this reaches or exceeds 0.7, you should present your architecture. +- totalQuestions: Estimated total questions needed +- answer: The user's response to the question (if available) +- nextQuestionNeeded: Set to true while you're gathering requirements and designing. Set to false when your confidenceScore reaches or exceeds 0.7. +- architectureComponent: The specific Azure component being suggested +- architectureTier: Which tier this component belongs to (infrastructure, platform, application, data, security, operations) +- state: Used to track progress between calls + +When presenting the final architecture design (when nextQuestionNeeded is false), format it in a visually appealing way. + +1. Present components in a table format with columns for: + | Component | Purpose | Tier/SKU | + +2. Organize the architecture visually: + - Use a combination of bulleted lists and paragraphs to break up the text. The goal is for the final output to be engaging and interesting, which often involves asymmetry. + +3. Include an ASCII art diagram showing component relationships. + +This formatting will make the architecture design more engaging and easier to understand. + +Basic state structure: +{ + "architectureComponents": [], + "architectureTiers": { + "infrastructure": [], + "platform": [], + "application": [], + "data": [], + "security": [], + "operations": [] + }, + "requirements": { + "explicit": [ + { "category": "performance", "description": "Need to handle 10,000 concurrent users", "source": "Question 2", "importance": "high", "confidence": 1.0 } + ], + "implicit": [ + { "category": "security", "description": "Data encryption likely needed", "source": "Inferred from healthcare domain", "importance": "high", "confidence": 0.8 } + ], + "assumed": [ + { "category": "compliance", "description": "Likely needs HIPAA compliance", "source": "Assumed from healthcare industry", "importance": "high", "confidence": 0.6 } + ] + }, + "confidenceFactors": { + "explicitRequirementsCoverage": 0.4, + "implicitRequirementsCertainty": 0.6, + "assumptionRisk": 0.3 + } +} + +You should: +1. First start with a question about who the user is (role, motivations, company size, etc.) and what they do +2. Learn about their business goals and requirements +3. Ask 1 to 2 questions at a time, in order to not overload the user. +4. Track your confidence level in understanding requirements using the confidenceScore parameter +5. After each user response, update the requirements in the state object: + - Add explicit requirements directly stated by the user + - Add implicit requirements you can reasonably infer + - Add assumed requirements where you lack information but need to make progress + - Update confidence factors based on the quality and completeness of requirements +6. Ask follow-up questions to clarify technical needs, especially to confirm assumed requirements +7. Identify specific requirements and technical constraints from user responses +8. Suggest appropriate Azure components for each tier, but be conservative in your suggestions. Don't suggest components that are not necessary for the architecture. +9. Ensure you cover all architecture tiers. +10. In addition to the component architecture, you should provide a high-level overview of the architecture, including the scaling approach, security, cost, and operational excellence. Provide actionable advice for the user to follow up on. Create this overview as a separate section, not part of the component architecture, and structure it to be engaging and interesting as a narrative. +11. Follow Azure Well-Architected Framework principles (reliability, security, cost, operational excellence, performance efficiency) +12. Keep track of components you've suggested using the state object +13. Calculate your overall confidence score from the three confidence factors in the state \ No newline at end of file diff --git a/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/AzureMcp.CloudArchitect.UnitTests.csproj b/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/AzureMcp.CloudArchitect.UnitTests.csproj new file mode 100644 index 000000000..f6ad6a956 --- /dev/null +++ b/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/AzureMcp.CloudArchitect.UnitTests.csproj @@ -0,0 +1,19 @@ + + + true + false + true + + + + + + + + + + + + + + diff --git a/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/Design/DesignCommandTests.cs b/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/Design/DesignCommandTests.cs new file mode 100644 index 000000000..5b9a333ba --- /dev/null +++ b/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/Design/DesignCommandTests.cs @@ -0,0 +1,684 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Reflection; +using System.Text; +using System.Text.Json; +using AzureMcp.CloudArchitect; +using AzureMcp.CloudArchitect.Commands.Design; +using AzureMcp.CloudArchitect.Options; +using AzureMcp.Core.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace AzureMcp.CloudArchitect.UnitTests.Design; + +public class DesignCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly DesignCommand _command; + private readonly CommandContext _context; + private readonly Parser _parser; + + public DesignCommandTests() + { + _logger = Substitute.For>(); + + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + _command = new(_logger); + _context = new(_serviceProvider); + _parser = new(_command.GetCommand()); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("design", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + + // Check that the description contains the expected content + Assert.Contains("Azure architecture design tool that gathers requirements through guided questions and recommends optimal solutions.", command.Description); + Assert.Contains("Key parameters: question, questionNumber, confidenceScore (0.0-1.0, present architecture when ≥0.7), totalQuestions, answer, nextQuestionNeeded, architectureComponent, architectureTier, state.", command.Description); + Assert.Contains("Ask about user role, business goals (1-2 questions at a time)", command.Description); + Assert.Contains("Track confidence and update requirements (explicit/implicit/assumed)", command.Description); + Assert.Contains("When confident enough, present architecture with table format, visual organization, ASCII diagrams", command.Description); + Assert.Contains("Follow Azure Well-Architected Framework principles", command.Description); + Assert.Contains("Cover all tiers: infrastructure, platform, application, data, security, operations", command.Description); + Assert.Contains("State tracks components, requirements by category, and confidence factors. Be conservative with suggestions.", command.Description); + Assert.Contains("confidenceScore", command.Description); + Assert.Contains("nextQuestionNeeded", command.Description); + Assert.Contains("Azure Well-Architected Framework", command.Description); + } + + [Fact] + public void Command_HasCorrectOptions() + { + var command = _command.GetCommand(); + + // Check that the command has the expected options + var optionNames = command.Options.Select(o => o.Name).ToList(); + + Assert.Contains("question", optionNames); + Assert.Contains("question-number", optionNames); + Assert.Contains("total-questions", optionNames); + Assert.Contains("answer", optionNames); + Assert.Contains("next-question-needed", optionNames); + Assert.Contains("confidence-score", optionNames); + Assert.Contains("state", optionNames); + } + + [Theory] + [InlineData("")] + [InlineData("--question \"What is your application type?\"")] + [InlineData("--question-number 1")] + [InlineData("--total-questions 5")] + [InlineData("--answer \"Web application\"")] + [InlineData("--next-question-needed true")] + [InlineData("--confidence-score 0.8")] + [InlineData("--architecture-component \"Frontend\"")] + [InlineData("--architecture-tier Infrastructure")] + [InlineData("--question \"App type?\" --question-number 1 --total-questions 5")] + [InlineData("--architecture-tier Platform --architecture-component \"AKS Cluster\"")] + public async Task ExecuteAsync_ReturnsArchitectureDesignText(string args) + { + // Arrange + var parseResult = _parser.Parse(args.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Empty(response.Message); + + // Verify that results contain the architecture design response structure + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + response.Results.Write(writer); + writer.Flush(); + + var serializedResult = Encoding.UTF8.GetString(stream.ToArray()); + + + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + + Assert.NotNull(responseObject); + Assert.NotEmpty(responseObject.DesignArchitecture); + Assert.NotNull(responseObject.ResponseObject); + + // Verify it contains some expected architecture-related content + Assert.Contains("architecture", responseObject.DesignArchitecture.ToLower()); + } + + [Fact] + public async Task ExecuteAsync_ConsistentResults() + { + // Arrange + var parseResult1 = _parser.Parse(["--question", "test question 1"]); + var parseResult2 = _parser.Parse(["--question", "test question 2"]); + + // Act + var response1 = await _command.ExecuteAsync(_context, parseResult1); + var response2 = await _command.ExecuteAsync(_context, parseResult2); + + // Assert - Both calls should return the same architecture design text + Assert.Equal(200, response1.Status); + Assert.Equal(200, response2.Status); + + // Serialize both results to compare the design architecture text (which should be consistent) + string serializedResult1 = SerializeResponseResult(response1.Results!); + string serializedResult2 = SerializeResponseResult(response2.Results!); + + var responseObject1 = JsonSerializer.Deserialize(serializedResult1, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + var responseObject2 = JsonSerializer.Deserialize(serializedResult2, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + + Assert.NotNull(responseObject1); + Assert.NotNull(responseObject2); + + // The design architecture text should be consistent across calls + Assert.Equal(responseObject1.DesignArchitecture, responseObject2.DesignArchitecture); + Assert.NotEmpty(responseObject1.DesignArchitecture); + } + + [Fact] + public async Task ExecuteAsync_WithAllOptionsSet() + { + // Arrange + var args = new[] + { + "--question", "What is your application type?", + "--question-number", "1", + "--total-questions", "5", + "--answer", "Web application", + "--next-question-needed", "true", + "--confidence-score", "0.8", + }; + + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Empty(response.Message); + + // Verify the command executed successfully regardless of the input options + string serializedResult = SerializeResponseResult(response.Results); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + Assert.NotNull(responseObject); + Assert.NotEmpty(responseObject.DesignArchitecture); + Assert.NotNull(responseObject.ResponseObject); + Assert.Equal("What is your application type?", responseObject.ResponseObject.DisplayText); + } + + [Theory] + [InlineData("What's your app type?", "What's your app type?")] + [InlineData("How \"big\" is your app?", "How \"big\" is your app?")] + [InlineData("Is it a \"web app\" or \"mobile app\"?", "Is it a \"web app\" or \"mobile app\"?")] + [InlineData("What's the app's \"main purpose\"?", "What's the app's \"main purpose\"?")] + [InlineData("Use 'single quotes' here", "Use 'single quotes' here")] + [InlineData("Mixed \"quotes\" and 'apostrophes'", "Mixed \"quotes\" and 'apostrophes'")] + public async Task ExecuteAsync_HandlesQuotesAndEscapingProperly(string questionWithQuotes, string expectedQuestion) + { + // Arrange + var args = new[] { "--question", questionWithQuotes }; + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Empty(response.Message); + + // Verify that the command executed successfully with the quoted input + string serializedResult = SerializeResponseResult(response.Results); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + Assert.NotNull(responseObject); + Assert.NotEmpty(responseObject.DesignArchitecture); + Assert.NotNull(responseObject.ResponseObject); + + // Verify the question was parsed correctly by checking the DisplayText in response + Assert.Equal(expectedQuestion, responseObject.ResponseObject.DisplayText); + } + + [Fact] + public async Task ExecuteAsync_HandlesComplexEscapingScenarios() + { + // Arrange - Test multiple options with various escaping scenarios + var complexQuestion = "What is your \"primary\" application 'type' and how \"big\" will it be?"; + var complexAnswer = "It's a \"web application\" with 'high' scalability requirements"; + + var args = new[] + { + "--question", complexQuestion, + "--answer", complexAnswer, + "--question-number", "2", + "--total-questions", "10" + }; + + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Empty(response.Message); + + // Verify all options were parsed correctly + var questionValue = parseResult.GetValueForOption(_command.GetCommand().Options.First(o => o.Name == "question")); + var answerValue = parseResult.GetValueForOption(_command.GetCommand().Options.First(o => o.Name == "answer")); + + Assert.Equal(complexQuestion, questionValue); + Assert.Equal(complexAnswer, answerValue); + } + + [Fact] + public void Metadata_IsConfiguredCorrectly() + { + // Arrange & Act + var metadata = _command.Metadata; + + // Assert + Assert.False(metadata.Destructive); + Assert.True(metadata.ReadOnly); + } + + [Fact] + public void Properties_AreConfiguredCorrectly() + { + // Arrange & Act + var name = _command.Name; + var title = _command.Title; + var description = _command.Description; + + // Assert + Assert.Equal("design", name); + Assert.Equal("Design Azure cloud architectures through guided questions", title); + Assert.NotEmpty(description); + Assert.Contains("guided questions", description); + } + + [Fact] + public async Task ExecuteAsync_LoadsEmbeddedResourceText() + { + // Arrange + var args = new[] { "--question", "Test question" }; + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + + string serializedResult = SerializeResponseResult(response.Results!); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + + Assert.NotNull(responseObject); + Assert.NotEmpty(responseObject.DesignArchitecture); + // The embedded resource should contain Azure architecture guidance + Assert.Contains("Azure", responseObject.DesignArchitecture); + } + + [Fact] + public async Task ExecuteAsync_WithStateOption() + { + // Arrange - Create a simple JSON state object + var stateJson = "{\"architectureComponents\":[],\"architectureTiers\":{\"infrastructure\":[],\"platform\":[],\"application\":[],\"data\":[],\"security\":[],\"operations\":[]},\"requirements\":{\"explicit\":[],\"implicit\":[],\"assumed\":[]},\"confidenceFactors\":{\"explicitRequirementsCoverage\":0.5,\"implicitRequirementsCertainty\":0.7,\"assumptionRisk\":0.3}}"; + var args = new[] { "--state", stateJson }; + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Empty(response.Message); + + // Verify the command executed successfully with state option + string serializedResult = SerializeResponseResult(response.Results); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + Assert.NotNull(responseObject); + Assert.NotEmpty(responseObject.DesignArchitecture); + Assert.NotNull(responseObject.ResponseObject); + Assert.NotNull(responseObject.ResponseObject.State); + } + + [Fact] + public async Task ExecuteAsync_WithCompleteOptionSet() + { + // Arrange - Test all options together including the new ones + var args = new[] + { + "--question", "What type of application are you building?", + "--question-number", "3", + "--total-questions", "8", + "--answer", "A financial trading platform", + "--next-question-needed", "false", + "--confidence-score", "0.9", + }; + + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, response.Status); + Assert.NotNull(response.Results); + Assert.Empty(response.Message); + + // Verify all options were parsed correctly + var command = _command.GetCommand(); + var questionValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "question")); + var questionNumberValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "question-number")); + var totalQuestionsValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "total-questions")); + var answerValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "answer")); + var nextQuestionNeededValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "next-question-needed")); + var confidenceScoreValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "confidence-score")); + + Assert.Equal("What type of application are you building?", questionValue); + Assert.Equal(3, questionNumberValue); + Assert.Equal(8, totalQuestionsValue); + Assert.Equal("A financial trading platform", answerValue); + Assert.Equal(false, nextQuestionNeededValue); + Assert.Equal(0.9, confidenceScoreValue); + + // Verify the response structure + string serializedResult = SerializeResponseResult(response.Results); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + Assert.NotNull(responseObject); + Assert.NotEmpty(responseObject.DesignArchitecture); + Assert.NotNull(responseObject.ResponseObject); + Assert.Equal(questionValue, responseObject.ResponseObject.DisplayText); + Assert.Equal(questionNumberValue, responseObject.ResponseObject.QuestionNumber); + Assert.Equal(totalQuestionsValue, responseObject.ResponseObject.TotalQuestions); + Assert.Equal(nextQuestionNeededValue, responseObject.ResponseObject.NextQuestionNeeded); + } + + private static string SerializeResponseResult(ResponseResult responseResult) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + responseResult.Write(writer); + writer.Flush(); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + #region Validation Tests + + [Theory] + [InlineData(-0.1)] + [InlineData(1.1)] + [InlineData(2.0)] + [InlineData(-1.0)] + public void Parse_InvalidConfidenceScore_ReturnsError(double invalidScore) + { + // Arrange + var args = new[] { "--confidence-score", invalidScore.ToString() }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.NotEmpty(parseResult.Errors); + Assert.Contains("Confidence score must be between 0.0 and 1.0", parseResult.Errors.Select(e => e.Message)); + } + + [Theory] + [InlineData(0.0)] + [InlineData(0.5)] + [InlineData(1.0)] + [InlineData(0.1)] + [InlineData(0.9)] + public void Parse_ValidConfidenceScore_NoErrors(double validScore) + { + // Arrange + var args = new[] { "--confidence-score", validScore.ToString() }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.Empty(parseResult.Errors); + } + + [Theory] + [InlineData(-1)] + [InlineData(-5)] + [InlineData(-100)] + public void Parse_NegativeQuestionNumber_ReturnsError(int invalidQuestionNumber) + { + // Arrange + var args = new[] { "--question-number", invalidQuestionNumber.ToString() }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.NotEmpty(parseResult.Errors); + Assert.Contains("Question number cannot be negative", parseResult.Errors.Select(e => e.Message)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(5)] + [InlineData(100)] + public void Parse_ValidQuestionNumber_NoErrors(int validQuestionNumber) + { + // Arrange + var args = new[] { "--question-number", validQuestionNumber.ToString() }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.Empty(parseResult.Errors); + } + + [Theory] + [InlineData(-1)] + [InlineData(-5)] + [InlineData(-100)] + public void Parse_NegativeTotalQuestions_ReturnsError(int invalidTotalQuestions) + { + // Arrange + var args = new[] { "--total-questions", invalidTotalQuestions.ToString() }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.NotEmpty(parseResult.Errors); + Assert.Contains("Total questions cannot be negative", parseResult.Errors.Select(e => e.Message)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(5)] + [InlineData(100)] + public void Parse_ValidTotalQuestions_NoErrors(int validTotalQuestions) + { + // Arrange + var args = new[] { "--total-questions", validTotalQuestions.ToString() }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.Empty(parseResult.Errors); + } + + [Theory] + [InlineData(1, 5)] + [InlineData(5, 5)] + [InlineData(3, 10)] + [InlineData(0, 5)] // Zero is valid for question number + public void Parse_QuestionNumberWithinTotalQuestions_NoErrors(int questionNumber, int totalQuestions) + { + // Arrange + var args = new[] + { + "--question-number", questionNumber.ToString(), + "--total-questions", totalQuestions.ToString() + }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.Empty(parseResult.Errors); + } + + [Fact] + public void Parse_MultipleValidationErrors_ReturnsFirstError() + { + // Arrange - Set both invalid confidence score and negative question number + var args = new[] + { + "--confidence-score", "1.5", + "--question-number", "-1" + }; + + // Act + var parseResult = _parser.Parse(args); + + // Assert + Assert.NotEmpty(parseResult.Errors); + // Should return the first validation error encountered + Assert.Contains("Confidence score must be between 0.0 and 1.0", parseResult.Errors.Select(e => e.Message)); + } + + [Fact] + public async Task ExecuteAsync_WithComplexStateJson_ParsesSuccessfully() + { + // Arrange - Use the exact JSON from the original error + var stateJson = """ + { + "architectureComponents": [], + "architectureTiers": { + "infrastructure": [], + "platform": [], + "application": [], + "data": [], + "security": [], + "operations": [] + }, + "requirements": { + "explicit": [ + { + "category": "functionality", + "description": "Video upload capability for users", + "source": "User statement", + "importance": "high", + "confidence": 1 + }, + { + "category": "functionality", + "description": "Video viewing/playback capability for users", + "source": "User statement", + "importance": "high", + "confidence": 1 + } + ], + "implicit": [ + { + "category": "performance", + "description": "Large-scale video processing and streaming required", + "source": "Inferred from 'large video playback company'", + "importance": "high", + "confidence": 0.9 + } + ], + "assumed": [ + { + "category": "scale", + "description": "Potentially thousands of concurrent users", + "source": "Assumed from 'large' company description", + "importance": "medium", + "confidence": 0.6 + } + ] + }, + "confidenceFactors": { + "explicitRequirementsCoverage": 0.4, + "implicitRequirementsCertainty": 0.8, + "assumptionRisk": 0.4 + } + } + """; + + var args = new[] + { + "--state", stateJson, + "--question", "What is your primary business goal?", + "--confidence-score", "0.5" + }; + + // Act + var parseResult = _parser.Parse(args); + var result = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Empty(parseResult.Errors); + Assert.Equal(200, _context.Response.Status); + + // Verify that the state was parsed correctly by checking the response + string serializedResult = SerializeResponseResult(_context.Response.Results!); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + Assert.NotNull(responseObject); + Assert.NotNull(responseObject.ResponseObject.State); + Assert.Empty(responseObject.ResponseObject.State.ArchitectureComponents); + Assert.NotNull(responseObject.ResponseObject.State.Requirements); + Assert.Equal(2, responseObject.ResponseObject.State.Requirements.Explicit.Count); + Assert.Single(responseObject.ResponseObject.State.Requirements.Implicit); + Assert.Single(responseObject.ResponseObject.State.Requirements.Assumed); + } + + [Fact] + public async Task ExecuteAsync_WithInvalidStateJson_HandlesGracefully() + { + // Arrange + var invalidStateJson = "{ invalid json }"; + var args = new[] { "--state", invalidStateJson }; + var parseResult = _parser.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult); + + // Assert - The command should handle the error gracefully and return an error response + Assert.NotEqual(200, response.Status); + Assert.NotEmpty(response.Message); + } + + [Fact] + public async Task ExecuteAsync_WithEmptyState_CreatesDefaultState() + { + // Arrange + var args = new[] { "--state", "" }; + var parseResult = _parser.Parse(args); + + // Act + var result = await _command.ExecuteAsync(_context, parseResult); + + // Assert + Assert.Equal(200, _context.Response.Status); + + string serializedResult = SerializeResponseResult(_context.Response.Results!); + var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse); + Assert.NotNull(responseObject); + Assert.NotNull(responseObject.ResponseObject.State); + Assert.Empty(responseObject.ResponseObject.State.ArchitectureComponents); + Assert.NotNull(responseObject.ResponseObject.State.Requirements); + Assert.Empty(responseObject.ResponseObject.State.Requirements.Explicit); + } + + [Fact] + public void BindOptions_WithInvalidStateJson_ThrowsException() + { + // Arrange + var invalidStateJson = "{ invalid json }"; + var args = new[] { "--state", invalidStateJson }; + var parseResult = _parser.Parse(args); + + // Act & Assert + var exception = Assert.Throws(() => + { + // Access the protected BindOptions method via reflection to test state deserialization + var command = _command.GetCommand(); + var stateOption = command.Options.First(o => o.Name == "state"); + var stateValue = parseResult.GetValueForOption((Option)stateOption); + + // Manually call the state deserialization that happens in BindOptions + var deserializeMethod = typeof(DesignCommand).GetMethod("DeserializeState", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(deserializeMethod); + deserializeMethod.Invoke(null, new object?[] { stateValue }); + }); + + // Verify the inner exception is the InvalidOperationException we expect + Assert.IsType(exception.InnerException); + Assert.Contains("Failed to deserialize state JSON", exception.InnerException!.Message); + } + + #endregion +} diff --git a/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/GlobalUsings.cs b/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/GlobalUsings.cs new file mode 100644 index 000000000..a5a9067cc --- /dev/null +++ b/areas/cloudarchitect/tests/AzureMcp.CloudArchitect.UnitTests/GlobalUsings.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System.CommandLine; +global using System.CommandLine.Parsing; +global using AzureMcp.Core.Models; +global using AzureMcp.Core.Models.Command; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using NSubstitute; +global using Xunit; diff --git a/core/src/AzureMcp.Cli/Program.cs b/core/src/AzureMcp.Cli/Program.cs index 810607c1a..ac0d27311 100644 --- a/core/src/AzureMcp.Cli/Program.cs +++ b/core/src/AzureMcp.Cli/Program.cs @@ -69,6 +69,7 @@ private static IAreaSetup[] RegisterAreas() new AzureMcp.AzureTerraformBestPractices.AzureTerraformBestPracticesSetup(), new AzureMcp.Deploy.DeploySetup(), new AzureMcp.Acr.AcrSetup(), + new AzureMcp.CloudArchitect.CloudArchitectSetup(), new AzureMcp.Foundry.FoundrySetup(), new AzureMcp.FunctionApp.FunctionAppSetup(), new AzureMcp.Grafana.GrafanaSetup(), diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md index 0bd2f93b9..bea497568 100644 --- a/docs/azmcp-commands.md +++ b/docs/azmcp-commands.md @@ -1018,6 +1018,26 @@ azmcp workbooks update --workbook-id \ azmcp bicepschema get --resource-type \ ``` +### Cloud Architect + +```bash +# Design Azure cloud architectures through guided questions +azmcp cloudarchitect design [--question ] \ + [--question-number ] \ + [--total-questions ] \ + [--answer ] \ + [--next-question-needed ] \ + [--confidence-score ] \ + [--architecture-component ] + +# Example: +# Start an interactive architecture design session +azmcp cloudarchitect design --question "What type of application are you building?" \ + --question-number 1 \ + --total-questions 5 \ + --confidence-score 0.1 +``` + ## Response Format All responses follow a consistent JSON format: @@ -1038,3 +1058,4 @@ The CLI returns structured JSON responses for errors, including: - Service availability issues - Authentication errors + diff --git a/docs/e2eTestPrompts.md b/docs/e2eTestPrompts.md index 9d5542a41..eba3543b8 100644 --- a/docs/e2eTestPrompts.md +++ b/docs/e2eTestPrompts.md @@ -402,3 +402,12 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | Tool Name | Test Prompt | |:----------|:----------| | azmcp-bicepschema-get | How can I use Bicep to create an Azure OpenAI service? | + +## Cloud Architect + +| Tool Name | Test Prompt | +|:----------|:----------| +| azmcp-cloudarchitect-design | Please help me design an architecture for a large-scale file upload, storage, and retrieval service | +| azmcp-cloudarchitect-design | Help me create a cloud service that will serve as ATM for users | +| azmcp-cloudarchitect-design | I want to design a cloud app for ordering groceries | +| azmcp-cloudarchitect-design | How can I design a cloud service in Azure that will store and present videos for users? |