From 24a2d481bd5c531c93f3f7b547aaa54f7ab84de0 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Thu, 13 Nov 2025 19:19:12 -0800 Subject: [PATCH 1/7] Add MCP server management commands to Agent 365 CLI Introduced the `develop-mcp` command for managing MCP servers in Dataverse environments. Added subcommands for listing environments and servers, publishing, unpublishing, approving, and blocking MCP servers. All commands support `--dry-run` and `--config` options. Updated `README.md` and `DEVELOPER.md` with examples and usage details for the new commands. Refactored the CLI codebase to include a new `DevelopMcpCommand` class and models for environments and servers. Implemented `Agent365ToolingService` for API interactions with detailed logging and error handling. Enhanced configuration and logging in `install-cli.ps1`. Added regression and unit tests to ensure Azure CLI-style parameters, dry-run functionality, and consistent option patterns. Introduced `JsonDeserializationHelper` for handling double-serialized JSON responses. Updated constants and utilities for streamlined configuration. Improved developer experience with better error handling and documentation. Ensured compliance with Azure CLI patterns and added comprehensive logging for debugging and audit trails. --- README.md | 25 + scripts/cli/install-cli.ps1 | 11 +- src/DEVELOPER.md | 53 +- .../Commands/DevelopMcpCommand.cs | 611 ++++++++++++++++++ .../Constants/McpConstants.cs | 21 +- .../Models/DataverseEnvironment.cs | 101 +++ .../Models/DataverseMcpServer.cs | 150 +++++ .../Models/PublishMcpServerRequest.cs | 21 + .../Models/PublishMcpServerResponse.cs | 27 + .../Program.cs | 13 +- .../Services/Agent365ToolingService.cs | 610 +++++++++++++++++ .../Helpers/JsonDeserializationHelper.cs | 99 +++ .../Services/IAgent365ToolingService.cs | 72 +++ .../DevelopMcpCommandRegressionTests.cs | 236 +++++++ .../Commands/DevelopMcpCommandTests.cs | 276 ++++++++ 15 files changed, 2300 insertions(+), 26 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs diff --git a/README.md b/README.md index f3eac9b2..b82c2edb 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,28 @@ a365 query-entra instance-scopes a365 develop --list ``` +### MCP Server Management +```bash +# List Dataverse environments +a365 develop-mcp list-environments + +# List MCP servers in a specific environment +a365 develop-mcp list-servers -e "Default-12345678-1234-1234-1234-123456789abc" + +# Publish an MCP server +a365 develop-mcp publish -e "Default-12345678-1234-1234-1234-123456789abc" -s "msdyn_MyMcpServer" + +# Unpublish an MCP server +a365 develop-mcp unpublish -e "Default-12345678-1234-1234-1234-123456789abc" -s "msdyn_MyMcpServer" + +# Approve/block MCP servers +a365 develop-mcp approve -s "msdyn_MyMcpServer" +a365 develop-mcp block -s "msdyn_MyMcpServer" + +# All commands support dry-run for safe testing +a365 develop-mcp publish -e "myenv" -s "myserver" --dry-run +``` + --- ## Multiplatform Deployment Support @@ -283,6 +305,9 @@ a365 setup --help a365 create-instance --help a365 deploy --help a365 develop --help +a365 develop-mcp --help +a365 query-entra --help +a365 config --help ``` diff --git a/scripts/cli/install-cli.ps1 b/scripts/cli/install-cli.ps1 index 4afe7139..e371215b 100644 --- a/scripts/cli/install-cli.ps1 +++ b/scripts/cli/install-cli.ps1 @@ -2,9 +2,16 @@ # This script installs the Agent 365 CLI from a local NuGet package in the publish folder. # Usage: Run this script from the root of the extracted package (where publish/ exists) +# Get the repository root directory (two levels up from scripts/cli/) +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$projectPath = Join-Path $repoRoot 'src\Microsoft.Agents.A365.DevTools.Cli\Microsoft.Agents.A365.DevTools.Cli.csproj' +# Verify the project file exists +if (-not (Test-Path $projectPath)) { + Write-Error "ERROR: Project file not found at $projectPath" + exit 1 +} -$projectPath = Join-Path $PSScriptRoot 'Microsoft.Agents.A365.DevTools.Cli\Microsoft.Agents.A365.DevTools.Cli.csproj' $outputDir = Join-Path $PSScriptRoot 'nupkg' if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null @@ -17,7 +24,7 @@ if ($LASTEXITCODE -ne 0) { exit 1 } Write-Host "Packing CLI tool to $outputDir (Release configuration)..." -dotnet pack $projectPath -c Release -o $outputDir --no-build +dotnet pack $projectPath -c Release -o $outputDir --no-build -p:IncludeSymbols=false -p:TreatWarningsAsErrors=false if ($LASTEXITCODE -ne 0) { Write-Error "ERROR: dotnet pack failed. Check output above for details." exit 1 diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index c4c44ed7..41335e6d 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -68,7 +68,8 @@ Microsoft.Agents.A365.DevTools.Cli/ │ ├─ CreateInstanceCommand.cs # a365 create-instance (identity, licenses, enable-notifications) │ ├─ DeployCommand.cs # a365 deploy │ ├─ QueryEntraCommand.cs # a365 query-entra (blueprint-scopes, instance-scopes) -│ └─ DevelopCommand.cs # a365 develop +│ ├─ DevelopCommand.cs # a365 develop +│ └─ DevelopMcpCommand.cs # a365 develop-mcp (MCP server management) ├─ Services/ # Business logic services │ ├─ ConfigService.cs # Configuration management │ ├─ DeploymentService.cs # Multiplatform Azure deployment @@ -98,6 +99,54 @@ The CLI provides a `config` command for managing configuration: - `a365 config init -c ` — Imports and validates a config file, then writes it to the standard location. - `a365 config display` — Prints the current configuration. +### MCP Server Management Command + +The CLI provides a `develop-mcp` command for managing Model Context Protocol (MCP) servers in Dataverse environments: + +**Environment Management:** +- `a365 develop-mcp list-environments` — List all available Dataverse environments for MCP server management + +**Server Management:** +- `a365 develop-mcp list-servers -e ` — List MCP servers in a specific Dataverse environment +- `a365 develop-mcp publish -e -s ` — Publish an MCP server to a Dataverse environment +- `a365 develop-mcp unpublish -e -s ` — Unpublish an MCP server from a Dataverse environment + +**Server Approval:** +- `a365 develop-mcp approve -s ` — Approve an MCP server +- `a365 develop-mcp block -s ` — Block an MCP server + +**Key Features:** +- **Azure CLI Style Parameters:** Uses named options (`--environment-id/-e`, `--server-name/-s`) for better UX +- **Dry Run Support:** All commands support `--dry-run` for safe testing +- **Consistent Configuration:** All commands support `--config/-c` for custom configuration files +- **Interactive Prompts:** Missing required parameters prompt for user input +- **Comprehensive Logging:** Detailed logging for debugging and audit trails + +**Examples:** +```bash +# List all environments +a365 develop-mcp list-environments + +# List servers in a specific environment +a365 develop-mcp list-servers -e "Default-12345678-1234-1234-1234-123456789abc" + +# Publish a server with alias and display name +a365 develop-mcp publish \ + --environment-id "Default-12345678-1234-1234-1234-123456789abc" \ + --server-name "msdyn_MyMcpServer" \ + --alias "my-server" \ + --display-name "My Custom MCP Server" + +# Quick unpublish with short aliases +a365 develop-mcp unpublish -e "Default-12345678-1234-1234-1234-123456789abc" -s "msdyn_MyMcpServer" + +# Approve a server +a365 develop-mcp approve --server-name "msdyn_MyMcpServer" + +# Test commands safely with dry-run +a365 develop-mcp publish -e "myenv" -s "myserver" --dry-run +``` + ## Inheritable Permissions: Best Practice Agent 365 CLI and the Agent 365 platform are designed to use inheritable permissions on agent blueprints. This means: @@ -495,7 +544,7 @@ dotnet test Use the convenient script: ```bash -# From developer/ directory +# From scripts/cli directory .\install-cli.ps1 ``` diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs new file mode 100644 index 00000000..00660508 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -0,0 +1,611 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using System.CommandLine; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands; + +/// +/// Command for managing MCP server environments in Dataverse +/// +public static class DevelopMcpCommand +{ + /// + /// Creates the develop-mcp command with subcommands for MCP server management in Dataverse + /// + public static Command CreateCommand( + ILogger logger, + IAgent365ToolingService toolingService) + { + var developMcpCommand = new Command("develop-mcp", "Manage MCP servers in Dataverse environments"); + + // Add common options + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Enable verbose logging"); + + developMcpCommand.AddOption(configOption); + developMcpCommand.AddOption(verboseOption); + + // Add subcommands + developMcpCommand.AddCommand(CreateListEnvironmentsSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreateListServersSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreateApproveSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreateBlockSubcommand(logger, toolingService)); + + return developMcpCommand; + } + + /// + /// Creates the list-environments subcommand + /// + private static Command CreateListEnvironmentsSubcommand( + ILogger logger, + IAgent365ToolingService toolingService) + { + var command = new Command("list-environments", "List all Dataverse environments available for MCP server management"); + + var configOption = new Option( + ["-c", "--config"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path" + ); + command.AddOption(configOption); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + command.SetHandler(async (configPath, dryRun) => + { + logger.LogInformation("Starting list-environments operation..."); + + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would query Dataverse environments endpoint"); + logger.LogInformation("[DRY RUN] Would display list of available environments"); + await Task.CompletedTask; + return; + } + + // Call service + var environmentsResponse = await toolingService.ListEnvironmentsAsync(); + + if (environmentsResponse == null || environmentsResponse.Environments.Length == 0) + { + logger.LogInformation("No Dataverse environments found"); + return; + } + + // Display available environments + logger.LogInformation("Available Dataverse Environments:"); + logger.LogInformation("=================================="); + + foreach (var env in environmentsResponse.Environments) + { + var envId = env.GetEnvironmentId() ?? "Unknown"; + var envName = env.DisplayName ?? "Unknown"; + var envType = env.Type ?? "Unknown"; + + logger.LogInformation("Environment ID: {EnvId}", envId); + logger.LogInformation(" Name: {Name}", envName); + logger.LogInformation(" Type: {Type}", envType); + + if (!string.IsNullOrWhiteSpace(env.Url)) + { + logger.LogInformation(" URL: {Url}", env.Url); + } + if (!string.IsNullOrWhiteSpace(env.Geo)) + { + logger.LogInformation(" Region: {Geo}", env.Geo); + } + } + + logger.LogInformation("Listed {Count} Dataverse environment(s)", environmentsResponse.Environments.Length); + + }, configOption, dryRunOption); + + return command; + } + + /// + /// Creates the list-servers subcommand + /// + private static Command CreateListServersSubcommand( + ILogger logger, + IAgent365ToolingService toolingService) + { + var command = new Command("list-servers", "List MCP servers in a specific Dataverse environment"); + + var envIdOption = new Option( + ["--environment-id", "-e"], + description: "Dataverse environment ID" + ); + envIdOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(envIdOption); + + var configOption = new Option( + ["-c", "--config"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path" + ); + command.AddOption(configOption); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + command.SetHandler(async (envId, configPath, dryRun) => + { + // Prompt for missing required argument + if (string.IsNullOrWhiteSpace(envId)) + { + Console.Write("Enter Dataverse environment ID: "); + envId = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(envId)) + { + logger.LogError("Environment ID is required"); + return; + } + } + + logger.LogInformation("Starting list-servers operation for environment {EnvId}...", envId); + + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would query MCP servers in environment {EnvId}", envId); + logger.LogInformation("[DRY RUN] Would display list of MCP servers"); + await Task.CompletedTask; + return; + } + + // Call service + var serversResponse = await toolingService.ListServersAsync(envId); + + if (serversResponse == null) + { + logger.LogError("Failed to list MCP servers in environment {EnvId}", envId); + return; + } + + // Log response details + if (!string.IsNullOrWhiteSpace(serversResponse.Status)) + { + logger.LogInformation("API Response Status: {Status}", serversResponse.Status); + } + if (!string.IsNullOrWhiteSpace(serversResponse.Message)) + { + logger.LogInformation("API Response Message: {Message}", serversResponse.Message); + } + if (!string.IsNullOrWhiteSpace(serversResponse.Warning)) + { + logger.LogWarning("API Warning: {Warning}", serversResponse.Warning); + } + + var servers = serversResponse.GetServers(); + + if (servers.Length == 0) + { + logger.LogInformation("No MCP servers found in environment {EnvId}", envId); + return; + } + + // Display MCP servers + logger.LogInformation("MCP Servers in Environment {EnvId}:", envId); + logger.LogInformation("======================================"); + + foreach (var server in servers) + { + var serverName = server.McpServerName ?? "Unknown"; + var displayName = server.DisplayName ?? serverName; + var url = server.Url ?? "Unknown"; + var status = server.Status ?? "Unknown"; + + logger.LogInformation("{DisplayName}", displayName); + if (!string.IsNullOrWhiteSpace(server.Name) && server.Name != displayName) + { + logger.LogInformation(" Name: {Name}", server.Name); + } + if (!string.IsNullOrWhiteSpace(server.Id)) + { + logger.LogInformation(" ID: {Id}", server.Id); + } + logger.LogInformation(" URL: {Url}", url); + logger.LogInformation(" Status: {Status}", status); + + if (!string.IsNullOrWhiteSpace(server.Description)) + { + logger.LogInformation(" Description: {Description}", server.Description); + } + if (!string.IsNullOrWhiteSpace(server.Version)) + { + logger.LogInformation(" Version: {Version}", server.Version); + } + if (server.PublishedDate.HasValue) + { + logger.LogInformation(" Published: {PublishedDate:yyyy-MM-dd HH:mm:ss}", server.PublishedDate.Value); + } + if (!string.IsNullOrWhiteSpace(server.EnvironmentId)) + { + logger.LogInformation(" Environment ID: {EnvironmentId}", server.EnvironmentId); + } + } + logger.LogInformation("Listed {Count} MCP server(s) in environment {EnvId}", servers.Length, envId); + + }, envIdOption, configOption, dryRunOption); + + return command; + } + + /// + /// Creates the publish subcommand + /// + private static Command CreatePublishSubcommand( + ILogger logger, + IAgent365ToolingService toolingService) + { + var command = new Command("publish", "Publish an MCP server to a Dataverse environment"); + + var envIdOption = new Option( + ["--environment-id", "-e"], + description: "Dataverse environment ID" + ); + envIdOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(envIdOption); + + var serverNameOption = new Option( + ["--server-name", "-s"], + description: "MCP server name to publish" + ); + serverNameOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(serverNameOption); + + var aliasOption = new Option( + ["--alias", "-a"], + description: "Alias for the MCP server" + ); + command.AddOption(aliasOption); + + var displayNameOption = new Option( + ["--display-name", "-d"], + description: "Display name for the MCP server" + ); + command.AddOption(displayNameOption); + + var configOption = new Option( + ["-c", "--config"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path" + ); + command.AddOption(configOption); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + command.SetHandler(async (envId, serverName, alias, displayName, configPath, dryRun) => + { + // Prompt for missing required arguments + if (string.IsNullOrWhiteSpace(envId)) + { + Console.Write("Enter Dataverse environment ID: "); + envId = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(envId)) + { + logger.LogError("Environment ID is required"); + return; + } + } + + if (string.IsNullOrWhiteSpace(serverName)) + { + Console.Write("Enter MCP server name to publish: "); + serverName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + + logger.LogInformation("Starting publish operation for server {ServerName} in environment {EnvId}...", serverName, envId); + + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would publish MCP server {ServerName} to environment {EnvId}", serverName, envId); + logger.LogInformation("[DRY RUN] Alias: {Alias}", alias ?? "[would prompt]"); + logger.LogInformation("[DRY RUN] Display Name: {DisplayName}", displayName ?? "[would prompt]"); + await Task.CompletedTask; + return; + } + + // Prompt for missing optional values + if (string.IsNullOrWhiteSpace(alias)) + { + Console.Write("Enter alias for the MCP server: "); + alias = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(alias)) + { + logger.LogError("Alias is required"); + return; + } + } + + if (string.IsNullOrWhiteSpace(displayName)) + { + Console.Write("Enter display name for the MCP server: "); + displayName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(displayName)) + { + logger.LogError("Display name is required"); + return; + } + } + + // Create request + var request = new PublishMcpServerRequest + { + Alias = alias, + DisplayName = displayName + }; + + // Call service + var response = await toolingService.PublishServerAsync(envId, serverName, request); + + if (response == null || !response.IsSuccess) + { + if (response?.Message != null) + { + logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: {ErrorMessage}", serverName, envId, response.Message); + } + else + { + logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: No response received", serverName, envId); + } + return; + } + + logger.LogInformation("Successfully published MCP server {ServerName} to environment {EnvId}", serverName, envId); + + }, envIdOption, serverNameOption, aliasOption, displayNameOption, configOption, dryRunOption); + + return command; + } + + /// + /// Creates the unpublish subcommand + /// + private static Command CreateUnpublishSubcommand( + ILogger logger, + IAgent365ToolingService toolingService) + { + var command = new Command("unpublish", "Unpublish an MCP server from a Dataverse environment"); + + var envIdOption = new Option( + ["--environment-id", "-e"], + description: "Dataverse environment ID" + ); + envIdOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(envIdOption); + + var serverNameOption = new Option( + ["--server-name", "-s"], + description: "MCP server name to unpublish" + ); + serverNameOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(serverNameOption); + + var configOption = new Option( + ["-c", "--config"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path" + ); + command.AddOption(configOption); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + command.SetHandler(async (envId, serverName, configPath, dryRun) => + { + // Prompt for missing required arguments + if (string.IsNullOrWhiteSpace(envId)) + { + Console.Write("Enter Dataverse environment ID: "); + envId = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(envId)) + { + logger.LogError("Environment ID is required"); + return; + } + } + + if (string.IsNullOrWhiteSpace(serverName)) + { + Console.Write("Enter MCP server name to unpublish: "); + serverName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + + logger.LogInformation("Starting unpublish operation for server {ServerName} in environment {EnvId}...", serverName, envId); + + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would unpublish MCP server {ServerName} from environment {EnvId}", serverName, envId); + await Task.CompletedTask; + return; + } + + // Call service + var success = await toolingService.UnpublishServerAsync(envId, serverName); + + if (!success) + { + logger.LogError("Failed to unpublish MCP server {ServerName} from environment {EnvId}", serverName, envId); + return; + } + + logger.LogInformation("Successfully unpublished MCP server {ServerName} from environment {EnvId}", serverName, envId); + + }, envIdOption, serverNameOption, configOption, dryRunOption); + + return command; + } + + /// + /// Creates the approve subcommand (not implemented) + /// + private static Command CreateApproveSubcommand(ILogger logger, IAgent365ToolingService toolingService) + { + var command = new Command("approve", "Approve an MCP server"); + + var serverNameOption = new Option( + ["--server-name", "-s"], + description: "MCP server name to approve" + ); + serverNameOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(serverNameOption); + + var configOption = new Option( + ["-c", "--config"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path" + ); + command.AddOption(configOption); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + command.SetHandler(async (serverName, configPath, dryRun) => + { + // Prompt for missing required arguments + if (string.IsNullOrWhiteSpace(serverName)) + { + Console.Write("Enter MCP server name to approve: "); + serverName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + + logger.LogInformation("Starting approve operation for server {ServerName}...", serverName); + + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would approve MCP server {ServerName}", serverName); + await Task.CompletedTask; + return; + } + + // Call service + var success = await toolingService.ApproveServerAsync(serverName); + + if (!success) + { + logger.LogError("Failed to approve MCP server {ServerName}", serverName); + return; + } + + logger.LogInformation("Successfully approved MCP server {ServerName}", serverName); + + }, serverNameOption, configOption, dryRunOption); + + return command; + } + + /// + /// Creates the block subcommand (not yet implemented) + /// + private static Command CreateBlockSubcommand(ILogger logger, IAgent365ToolingService toolingService) + { + var command = new Command("block", "Block an MCP server"); + + var serverNameOption = new Option( + ["--server-name", "-s"], + description: "MCP server name to block" + ); + serverNameOption.IsRequired = false; // Allow null so we can prompt + command.AddOption(serverNameOption); + + var configOption = new Option( + ["-c", "--config"], + getDefaultValue: () => "a365.config.json", + description: "Configuration file path" + ); + command.AddOption(configOption); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + command.SetHandler(async (serverName, configPath, dryRun) => + { + // Prompt for missing required arguments + if (string.IsNullOrWhiteSpace(serverName)) + { + Console.Write("Enter MCP server name to block: "); + serverName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + + logger.LogInformation("Starting block operation for server {ServerName}...", serverName); + + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would block MCP server {ServerName}", serverName); + await Task.CompletedTask; + return; + } + + // Call service + var success = await toolingService.BlockServerAsync(serverName); + + if (!success) + { + logger.LogError("Failed to block MCP server {ServerName}", serverName); + return; + } + + logger.LogInformation("Successfully blocked MCP server {ServerName}", serverName); + + }, serverNameOption, configOption, dryRunOption); + + return command; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs index 8b0b7af1..fc7ee694 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs @@ -7,9 +7,9 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Constants; /// Constants for MCP (Model Context Protocol) operations /// public static class McpConstants -{ - - // Agent 365 Tools App IDs for different environments +{ + + // Agent 365 Tools App IDs for different environments public const string Agent365ToolsProdAppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"; /// @@ -17,21 +17,6 @@ public static class McpConstants /// public const string ToolingManifestFileName = "ToolingManifest.json"; - /// - /// Get MCP server base URL for environment (configurable via environment variables) - /// Override via A365_MCP_SERVER_BASE_URL_{ENVIRONMENT} environment variable - /// - public static string GetMcpServerBaseUrl(string environment) - { - var customUrl = Environment.GetEnvironmentVariable($"A365_MCP_SERVER_BASE_URL_{environment?.ToUpper()}"); - if (!string.IsNullOrEmpty(customUrl)) - return customUrl; - - // Default to production-equivalent URL - return "https://agent365.svc.cloud.microsoft/mcp/servers"; - } - - /// /// JSON-RPC version /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs new file mode 100644 index 00000000..e70a18aa --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Model representing a Dataverse environment +/// +public class DataverseEnvironment +{ + /// + /// The unique identifier for the environment + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// The display name of the environment + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// + /// The type of environment (e.g., Production, Developer, Sandbox, Default) + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// The URL for accessing the environment + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// The tenant ID associated with the environment + /// + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } + + /// + /// The geographical region where the environment is hosted (e.g., "unitedstates") + /// + [JsonPropertyName("geo")] + public string? Geo { get; set; } + + /// + /// Gets the environment ID + /// + public string? GetEnvironmentId() => Id; + + /// + /// Validates that the environment has required fields + /// + /// True if valid, false otherwise + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Id); + } + + /// + /// Gets a display-friendly string representation of the environment + /// + /// Formatted string with environment name and ID + public override string ToString() + { + var envId = Id ?? "Unknown"; + var envName = DisplayName ?? "Unknown"; + var envType = Type ?? "Unknown"; + return $"{envName} ({envType}) - {envId}"; + } +} + +/// +/// Response model for the list-environments endpoint +/// +public class DataverseEnvironmentsResponse +{ + /// + /// Status of the API call (e.g., "Success") + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// Message describing the result + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Array of Dataverse environments + /// + [JsonPropertyName("environments")] + public DataverseEnvironment[] Environments { get; set; } = Array.Empty(); + + /// + /// Timestamp of the response + /// + [JsonPropertyName("timestamp")] + public DateTime? Timestamp { get; set; } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs new file mode 100644 index 00000000..3ad3255b --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs @@ -0,0 +1,150 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Model representing an MCP server in a Dataverse environment +/// +public class DataverseMcpServer +{ + /// + /// The unique ID of the MCP server + /// + [JsonPropertyName("Id")] + public string? Id { get; set; } + + /// + /// The name of the MCP server + /// + [JsonPropertyName("Name")] + public string? Name { get; set; } + + /// + /// The display name of the MCP server + /// + [JsonPropertyName("DisplayName")] + public string? DisplayName { get; set; } + + /// + /// The description of the MCP server + /// + [JsonPropertyName("Description")] + public string? Description { get; set; } + + /// + /// The environment ID where this server is published + /// + [JsonPropertyName("EnvironmentId")] + public string? EnvironmentId { get; set; } + + // Legacy/Compatibility properties for existing code + + /// + /// Gets the MCP server name (compatibility property, maps to Name) + /// + [JsonIgnore] + public string? McpServerName => Name ?? DisplayName; + + /// + /// The URL endpoint for the MCP server (may be computed/derived) + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// The publication status of the server (may be derived) + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// The version of the MCP server + /// + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// + /// When the server was published + /// + [JsonPropertyName("publishedDate")] + public DateTime? PublishedDate { get; set; } + + /// + /// Validates that the MCP server has required fields + /// + /// True if valid, false otherwise + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Name) || !string.IsNullOrWhiteSpace(DisplayName); + } + + /// + /// Gets a display-friendly string representation of the server + /// + /// Formatted string with server name and status + public override string ToString() + { + var name = DisplayName ?? Name ?? "Unknown"; + var status = Status ?? "Unknown"; + return $"{name} ({status})"; + } +} + +/// +/// Response model for the list-servers endpoint +/// Matches the actual API wrapper response structure +/// +public class DataverseMcpServersResponse +{ + /// + /// Status of the API response + /// + [JsonPropertyName("Status")] + public string? Status { get; set; } + + /// + /// Message from the API response + /// + [JsonPropertyName("Message")] + public string? Message { get; set; } + + /// + /// Environment ID from the response + /// + [JsonPropertyName("EnvironmentId")] + public string? EnvironmentId { get; set; } + + /// + /// Array of MCP servers in the environment + /// Primary property for deserialization - supports both "MCPServers", "mcpServers", and "servers" + /// + [JsonPropertyName("MCPServers")] + public DataverseMcpServer[] MCPServers { get; set; } = Array.Empty(); + + /// + /// Timestamp from the response + /// + [JsonPropertyName("Timestamp")] + public string? Timestamp { get; set; } + + /// + /// Warning message from the response + /// + [JsonPropertyName("Warning")] + public string? Warning { get; set; } + + /// + /// Optional: total count of servers + /// + [JsonPropertyName("count")] + public int? Count { get; set; } + + /// + /// Gets the servers array, with fallback logic for different API response formats + /// + public DataverseMcpServer[] GetServers() + { + // Return the primary MCPServers array + return MCPServers ?? Array.Empty(); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs new file mode 100644 index 00000000..d4dd60c5 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Request model for publishing an MCP server to a Dataverse environment +/// +public class PublishMcpServerRequest +{ + /// + /// Alias for the MCP server + /// + [JsonPropertyName("alias")] + public required string Alias { get; set; } + + /// + /// Display name for the MCP server + /// + [JsonPropertyName("DisplayName")] + public required string DisplayName { get; set; } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs new file mode 100644 index 00000000..6b54d477 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Response model for MCP server publish operation +/// +public class PublishMcpServerResponse +{ + /// + /// Status of the publish operation + /// + [JsonPropertyName("Status")] + public string? Status { get; set; } + + /// + /// Message from the API response + /// + [JsonPropertyName("Message")] + public string? Message { get; set; } + + /// + /// Whether the operation was successful + /// + [JsonIgnore] + public bool IsSuccess => Status?.Equals("Success", StringComparison.OrdinalIgnoreCase) ?? false; +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 6e9fd332..770e5faf 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -1,6 +1,6 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using System.CommandLine; using System.Reflection; using Microsoft.Agents.A365.DevTools.Cli.Commands; @@ -66,7 +66,8 @@ static async Task Main(string[] args) var executor = serviceProvider.GetRequiredService(); var authService = serviceProvider.GetRequiredService(); var azureValidator = serviceProvider.GetRequiredService(); - + var toolingService = serviceProvider.GetRequiredService(); + // Get services needed by commands var deploymentService = serviceProvider.GetRequiredService(); var botConfigurator = serviceProvider.GetRequiredService(); @@ -76,6 +77,7 @@ static async Task Main(string[] args) // Add commands rootCommand.AddCommand(DevelopCommand.CreateCommand(developLogger, configService, executor, authService)); + rootCommand.AddCommand(DevelopMcpCommand.CreateCommand(developLogger, toolingService)); rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor, deploymentService, botConfigurator, azureValidator, webAppCreator, platformDetector)); rootCommand.AddCommand(CreateInstanceCommand.CreateCommand(createInstanceLogger, configService, executor, @@ -166,6 +168,9 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + // Add Agent365 Tooling Service + services.AddSingleton(); + // Add Azure validators (individual validators for composition) services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs new file mode 100644 index 00000000..128238eb --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -0,0 +1,610 @@ +using System.Net.Http; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for interacting with Agent365 Tooling API endpoints for MCP server management in Dataverse +/// Handles authentication, HTTP communication, and response deserialization +/// +public class Agent365ToolingService : IAgent365ToolingService +{ + private readonly IConfigService _configService; + private readonly AuthenticationService _authService; + private readonly ILogger _logger; + + public Agent365ToolingService( + IConfigService configService, + AuthenticationService authService, + ILogger logger) + { + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Common helper method to handle HTTP response validation and logging. + /// Handles double-serialized JSON responses from the Agent365 API. + /// + /// The HTTP response message + /// Name of the operation for logging purposes + /// Cancellation token + /// Tuple of (isSuccess, responseContent) + private async Task<(bool IsSuccess, string ResponseContent)> ValidateResponseAsync( + HttpResponseMessage response, + string operationName, + CancellationToken cancellationToken) + { + // Check HTTP status first + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to {Operation}. Status: {Status}", operationName, response.StatusCode); + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Error response: {Error}", errorContent); + return (false, errorContent); + } + + // Read response content + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogInformation("Received response from {Operation} endpoint", operationName); + _logger.LogInformation("Response content: {ResponseContent}", responseContent); + + // Check if response content indicates failure (Agent365 API pattern) + // The API may return double-serialized JSON, so we use JsonDeserializationHelper + if (!string.IsNullOrWhiteSpace(responseContent)) + { + try + { + // Use JsonDeserializationHelper to handle both normal and double-serialized JSON + var statusResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent, _logger); + + if (statusResponse != null && !string.IsNullOrEmpty(statusResponse.Status)) + { + if (statusResponse.Status != "Success") + { + // Extract error message + string errorMessage = statusResponse.Message ?? $"{operationName} failed"; + + // Also check for Error property which might contain additional details + if (!string.IsNullOrEmpty(statusResponse.Error)) + { + errorMessage += $" - {statusResponse.Error}"; + } + + _logger.LogError("{Operation} failed: {Message}", operationName, errorMessage); + return (false, responseContent); + } + } + } + catch (JsonException ex) + { + _logger.LogDebug(ex, "Response content is not valid JSON for {Operation}, treating as success", operationName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error parsing response content for {Operation}", operationName); + return (false, responseContent); + } + } + + return (true, responseContent); + } + + /// + /// Internal model for API status responses (used for validation) + /// + private class ApiStatusResponse + { + public string? Status { get; set; } + public string? Message { get; set; } + public string? Error { get; set; } + } + + /// + /// Common helper method to log HTTP request details + /// + /// HTTP method + /// Request URL + /// Request payload (optional) + private void LogRequest(string method, string url, string? payload = null) + { + _logger.LogInformation("HTTP Method: {Method}", method); + _logger.LogInformation("Request URL: {Url}", url); + if (!string.IsNullOrEmpty(payload)) + { + _logger.LogInformation("Request Payload: {Payload}", payload); + } + _logger.LogInformation("Making {Method} request to: {Url}", method, url); + } + + /// + /// Builds base URL for Agent365 Tools API based on environment + /// + /// Environment name (test, preprod, prod) + /// Base URL for the Agent365 Tools API + private string BuildAgent365ToolsBaseUrl(string environment) + { + // Get from ConfigConstants to leverage existing URL construction logic + var discoverUrl = ConfigConstants.GetDiscoverEndpointUrl(environment); + var uri = new Uri(discoverUrl); + return $"{uri.Scheme}://{uri.Host}"; + } + + /// + /// Builds URL for listing Dataverse environments + /// + /// Environment name + /// Full URL for list environments endpoint + private string BuildListEnvironmentsUrl(string environment) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/dataverse/environments"; + } + + /// + /// Builds URL for listing MCP servers in a Dataverse environment + /// + /// Environment name + /// Dataverse environment ID + /// Full URL for list MCP servers endpoint + private string BuildListMcpServersUrl(string environment, string environmentId) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/dataverse/environments/{environmentId}/mcpServers"; + } + + /// + /// Builds URL for publishing an MCP server to a Dataverse environment + /// + /// Environment name + /// Dataverse environment ID + /// MCP server name + /// Full URL for publish MCP server endpoint + private string BuildPublishMcpServerUrl(string environment, string environmentId, string serverName) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/dataverse/environments/{environmentId}/mcpServers/{serverName}/publish"; + } + + /// + /// Builds URL for unpublishing an MCP server from a Dataverse environment + /// + /// Environment name + /// Dataverse environment ID + /// MCP server name + /// Full URL for unpublish endpoint + private string BuildUnpublishMcpServerUrl(string environment, string environmentId, string serverName) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/dataverse/environments/{environmentId}/mcpServers/{serverName}/unpublish"; + } + + /// + /// Builds URL for approving an MCP server + /// + /// Environment name + /// MCP server name + /// Full URL for approve endpoint + private string BuildApproveMcpServerUrl(string environment, string serverName) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/mcpServers/{serverName}/approve"; + } + + /// + /// Builds URL for blocking an MCP server + /// + /// Environment name + /// MCP server name + /// Full URL for block endpoint + private string BuildBlockMcpServerUrl(string environment, string serverName) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/mcpServers/{serverName}/block"; + } + + /// + public async Task ListEnvironmentsAsync(CancellationToken cancellationToken = default) + { + try + { + // Load configuration + var config = await _configService.LoadAsync(); + + // Build URL using private helper method + var endpointUrl = BuildListEnvironmentsUrl(config.Environment); + + _logger.LogInformation("Listing Dataverse environments"); + _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); + + var authToken = await _authService.GetAccessTokenAsync(audience); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return null; + } + + // Create authenticated HTTP client + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken); + + // Log request details + LogRequest("GET", endpointUrl); + + // Make request + var response = await httpClient.GetAsync(endpointUrl, cancellationToken); + + // Validate response using common helper + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "list environments", cancellationToken); + if (!isSuccess) + { + return null; + } + + var environmentsResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent, _logger); + + // Fallback: try to parse as raw array if primary deserialization fails + if (environmentsResponse == null) + { + _logger.LogDebug("Attempting to parse response as raw array..."); + try + { + var rawArray = JsonSerializer.Deserialize(responseContent); + if (rawArray != null && rawArray.Length > 0) + { + _logger.LogDebug("Successfully parsed as raw array with {Count} items", rawArray.Length); + environmentsResponse = new DataverseEnvironmentsResponse { Environments = rawArray }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse as raw array"); + } + } + + return environmentsResponse; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list Dataverse environments"); + return null; + } + } + + /// + public async Task ListServersAsync( + string environmentId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(environmentId)) + throw new ArgumentException("Environment ID cannot be null or empty", nameof(environmentId)); + + try + { + // Load configuration + var config = await _configService.LoadAsync(); + + // Build URL using private helper method + var endpointUrl = BuildListMcpServersUrl(config.Environment, environmentId); + + _logger.LogInformation("Listing MCP servers for environment {EnvId}", environmentId); + _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); + + var authToken = await _authService.GetAccessTokenAsync(audience); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return null; + } + + // Create authenticated HTTP client + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken); + + // Log request details + LogRequest("GET", endpointUrl); + + // Make request + var response = await httpClient.GetAsync(endpointUrl, cancellationToken); + + // Validate response using common helper + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "list MCP servers", cancellationToken); + if (!isSuccess) + { + return null; + } + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var serversResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent, _logger, options); + + return serversResponse; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list MCP servers for environment {EnvId}", environmentId); + return null; + } + } + + /// + public async Task PublishServerAsync( + string environmentId, + string serverName, + PublishMcpServerRequest request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(environmentId)) + throw new ArgumentException("Environment ID cannot be null or empty", nameof(environmentId)); + if (string.IsNullOrWhiteSpace(serverName)) + throw new ArgumentException("Server name cannot be null or empty", nameof(serverName)); + if (request == null) + throw new ArgumentNullException(nameof(request)); + + try + { + // Load configuration + var config = await _configService.LoadAsync(); + + // Build URL using private helper method + var endpointUrl = BuildPublishMcpServerUrl(config.Environment, environmentId, serverName); + + _logger.LogInformation("Publishing MCP server {ServerName} to environment {EnvId}", serverName, environmentId); + _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); + + var authToken = await _authService.GetAccessTokenAsync(audience); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return null; + } + + // Create authenticated HTTP client + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken); + + // Serialize request body + var requestPayload = JsonSerializer.Serialize(request); + var jsonContent = new StringContent( + requestPayload, + System.Text.Encoding.UTF8, + "application/json"); + + // Log request details + LogRequest("POST", endpointUrl, requestPayload); + + // Make request + var response = await httpClient.PostAsync(endpointUrl, jsonContent, cancellationToken); + + // Validate response using common helper + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "publish MCP server", cancellationToken); + if (!isSuccess) + { + return null; + } + + // Try to deserialize response, but allow for empty/null response + if (string.IsNullOrWhiteSpace(responseContent)) + { + return new PublishMcpServerResponse + { + Status = "Success", + Message = $"Successfully published {serverName}" + }; + } + + var publishResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent, _logger); + + return publishResponse ?? new PublishMcpServerResponse + { + Status = "Success", + Message = $"Successfully published {serverName}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish MCP server {ServerName} to environment {EnvId}", serverName, environmentId); + return null; + } + } + + /// + public async Task UnpublishServerAsync( + string environmentId, + string serverName, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(environmentId)) + throw new ArgumentException("Environment ID cannot be null or empty", nameof(environmentId)); + if (string.IsNullOrWhiteSpace(serverName)) + throw new ArgumentException("Server name cannot be null or empty", nameof(serverName)); + + try + { + // Load configuration + var config = await _configService.LoadAsync(); + + // Build URL using private helper method + var endpointUrl = BuildUnpublishMcpServerUrl(config.Environment, environmentId, serverName); + + _logger.LogInformation("Unpublishing MCP server {ServerName} from environment {EnvId}", serverName, environmentId); + _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); + + var authToken = await _authService.GetAccessTokenAsync(audience); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return false; + } + + // Create authenticated HTTP client + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken); + + // Log request details + LogRequest("DELETE", endpointUrl); + + // Make request + var response = await httpClient.DeleteAsync(endpointUrl, cancellationToken); + + // Validate response using common helper + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "unpublish MCP server", cancellationToken); + if (!isSuccess) + { + return false; + } + + _logger.LogInformation("Successfully unpublished MCP server"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to unpublish MCP server {ServerName} from environment {EnvId}", serverName, environmentId); + return false; + } + } + + /// + public async Task ApproveServerAsync( + string serverName, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serverName)) + throw new ArgumentException("Server name cannot be null or empty", nameof(serverName)); + + try + { + // Load configuration + var config = await _configService.LoadAsync(); + + // Build URL using private helper method + var endpointUrl = BuildApproveMcpServerUrl(config.Environment, serverName); + + _logger.LogInformation("Approving MCP server {ServerName}", serverName); + _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); + + var authToken = await _authService.GetAccessTokenAsync(audience); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return false; + } + + // Create authenticated HTTP client + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken); + + // Log request details + LogRequest("POST", endpointUrl); + + // Make request with empty content + var content = new StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + var response = await httpClient.PostAsync(endpointUrl, content, cancellationToken); + + // Validate response using common helper + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "approve MCP server", cancellationToken); + if (!isSuccess) + { + return false; + } + + _logger.LogInformation("Successfully approved MCP server"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to approve MCP server {ServerName}", serverName); + return false; + } + } + + /// + public async Task BlockServerAsync( + string serverName, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serverName)) + throw new ArgumentException("Server name cannot be null or empty", nameof(serverName)); + + try + { + // Load configuration + var config = await _configService.LoadAsync(); + + // Build URL using private helper method + var endpointUrl = BuildBlockMcpServerUrl(config.Environment, serverName); + + _logger.LogInformation("Blocking MCP server {ServerName}", serverName); + _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); + + var authToken = await _authService.GetAccessTokenAsync(audience); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return false; + } + + // Create authenticated HTTP client + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken); + + // Log request details + LogRequest("POST", endpointUrl); + + // Make request with empty content + var content = new StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + var response = await httpClient.PostAsync(endpointUrl, content, cancellationToken); + + // Validate response using common helper + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "block MCP server", cancellationToken); + if (!isSuccess) + { + return false; + } + + _logger.LogInformation("Successfully blocked MCP server"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to block MCP server {ServerName}", serverName); + return false; + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs new file mode 100644 index 00000000..b3fa0405 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; + +/// +/// Helper for deserializing JSON responses that may be double-serialized +/// (where the entire JSON object is itself serialized as a JSON string) +/// +public static class JsonDeserializationHelper +{ + /// + /// Deserializes JSON content, handling both normal and double-serialized JSON. + /// Double-serialized JSON is when the API returns a JSON string that contains escaped JSON. + /// + /// The type to deserialize to + /// The raw JSON string from the API + /// Logger for diagnostic information + /// Optional JSON serializer options + /// The deserialized object, or null if deserialization fails + public static T? DeserializeWithDoubleSerialization( + string responseContent, + ILogger logger, + JsonSerializerOptions? options = null) where T : class + { + options ??= new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + try + { + // First, try to deserialize directly (normal case - single serialization) + return JsonSerializer.Deserialize(responseContent, options); + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to deserialize response directly, checking for double-serialization"); + + // Check if response is double-serialized JSON (starts with quote and contains escaped JSON) + if (responseContent.Length > 0 && responseContent[0] == '"') + { + try + { + logger.LogDebug("Detected double-serialized JSON. Attempting to unwrap..."); + var actualJson = JsonSerializer.Deserialize(responseContent); + if (!string.IsNullOrWhiteSpace(actualJson)) + { + var result = JsonSerializer.Deserialize(actualJson, options); + logger.LogDebug("Successfully deserialized double-encoded response"); + return result; + } + } + catch (Exception unwrapEx) + { + logger.LogError(unwrapEx, "Failed to unwrap double-serialized response"); + } + } + + logger.LogError(ex, "Failed to deserialize response"); + logger.LogDebug("Response content: {Content}", responseContent); + return null; + } + } + + /// + /// Attempts deserialization with a fallback strategy. + /// First tries to deserialize as T, then as TFallback if T fails. + /// + /// Primary type to deserialize to + /// Fallback type if primary deserialization fails + /// The raw JSON string from the API + /// Logger for diagnostic information + /// Optional JSON serializer options + /// Result with the deserialized object and which type was used + public static (T? result, bool usedFallback) DeserializeWithFallback( + string responseContent, + ILogger logger, + JsonSerializerOptions? options = null) + where T : class + where TFallback : class + { + var primaryResult = DeserializeWithDoubleSerialization(responseContent, logger, options); + if (primaryResult != null) + { + return (primaryResult, false); + } + + logger.LogDebug("Primary deserialization failed, attempting fallback type {FallbackType}", typeof(TFallback).Name); + var fallbackResult = DeserializeWithDoubleSerialization(responseContent, logger, options); + + if (fallbackResult != null && fallbackResult is T converted) + { + return (converted, true); + } + + return (null, false); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs new file mode 100644 index 00000000..e5939821 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs @@ -0,0 +1,72 @@ +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for interacting with Agent365 Tooling API endpoints for MCP server management in Dataverse +/// +public interface IAgent365ToolingService +{ + /// + /// Lists all available Dataverse environments + /// + /// Cancellation token + /// Response containing list of Dataverse environments + Task ListEnvironmentsAsync(CancellationToken cancellationToken = default); + + /// + /// Lists MCP servers in a specific Dataverse environment + /// + /// Dataverse environment ID + /// Cancellation token + /// Response containing list of MCP servers + Task ListServersAsync( + string environmentId, + CancellationToken cancellationToken = default); + + /// + /// Publishes an MCP server to a Dataverse environment + /// + /// Dataverse environment ID + /// MCP server name to publish + /// Publish request with alias, display name, and description + /// Cancellation token + /// Response from the publish operation + Task PublishServerAsync( + string environmentId, + string serverName, + PublishMcpServerRequest request, + CancellationToken cancellationToken = default); + + /// + /// Unpublishes an MCP server from a Dataverse environment + /// + /// Dataverse environment ID + /// MCP server name to unpublish + /// Cancellation token + /// True if successful, false otherwise + Task UnpublishServerAsync( + string environmentId, + string serverName, + CancellationToken cancellationToken = default); + + /// + /// Approves an MCP server + /// + /// MCP server name to approve + /// Cancellation token + /// True if successful, false otherwise + Task ApproveServerAsync( + string serverName, + CancellationToken cancellationToken = default); + + /// + /// Blocks an MCP server + /// + /// MCP server name to block + /// Cancellation token + /// True if successful, false otherwise + Task BlockServerAsync( + string serverName, + CancellationToken cancellationToken = default); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs new file mode 100644 index 00000000..9eb97857 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using NSubstitute; +using FluentAssertions; +using System.CommandLine; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Core regression tests for the MCP commands focusing on critical scenarios +/// These tests ensure key functionality works and prevent regressions from architectural changes +/// +public class DevelopMcpCommandRegressionTests +{ + private readonly ILogger _mockLogger; + private readonly IAgent365ToolingService _mockToolingService; + private readonly Command _command; + + public DevelopMcpCommandRegressionTests() + { + _mockLogger = Substitute.For(); + _mockToolingService = Substitute.For(); + _command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + } + + [Fact] + public async Task DryRunMode_NeverCallsActualServices() + { + // This test ensures dry-run mode is properly implemented across all commands + // and prevents accidental service calls during dry runs + + // Arrange & Act - Test all dry run scenarios + var dryRunCommands = new[] + { + new[] { "list-environments", "--dry-run" }, + new[] { "list-servers", "-e", "test-env", "--dry-run" }, + new[] { "publish", "-e", "test-env", "-s", "test-server", "--dry-run" }, + new[] { "unpublish", "-e", "test-env", "-s", "test-server", "--dry-run" }, + new[] { "approve", "-s", "test-server", "--dry-run" }, + new[] { "block", "-s", "test-server", "--dry-run" } + }; + + foreach (var commandArgs in dryRunCommands) + { + var result = await _command.InvokeAsync(commandArgs); + result.Should().Be(0, $"Command {string.Join(" ", commandArgs)} should succeed"); + } + + // Verify no service methods were called + await _mockToolingService.DidNotReceive().ListEnvironmentsAsync(); + await _mockToolingService.DidNotReceive().ListServersAsync(Arg.Any()); + await _mockToolingService.DidNotReceive().PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _mockToolingService.DidNotReceive().UnpublishServerAsync(Arg.Any(), Arg.Any()); + await _mockToolingService.DidNotReceive().ApproveServerAsync(Arg.Any()); + await _mockToolingService.DidNotReceive().BlockServerAsync(Arg.Any()); + } + + [Theory] + [InlineData("list-servers", "-e", "test-env")] + [InlineData("list-servers", "--environment-id", "test-env")] + [InlineData("publish", "-e", "test-env", "-s", "test-server")] + [InlineData("publish", "--environment-id", "test-env", "--server-name", "test-server")] + [InlineData("unpublish", "-e", "test-env", "-s", "test-server")] + [InlineData("approve", "-s", "test-server")] + [InlineData("approve", "--server-name", "test-server")] + [InlineData("block", "-s", "test-server")] + [InlineData("block", "--server-name", "test-server")] + public async Task AzureCliStyleParameters_AreAcceptedCorrectly(string command, params string[] args) + { + // This test ensures we maintain Azure CLI compatibility with named options + // Regression test: Prevents reverting back to positional arguments + + // Arrange + _mockToolingService.ListServersAsync(Arg.Any()).Returns(new DataverseMcpServersResponse()); + _mockToolingService.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse { Status = "Success" }); + _mockToolingService.UnpublishServerAsync(Arg.Any(), Arg.Any()).Returns(true); + _mockToolingService.ApproveServerAsync(Arg.Any()).Returns(true); + _mockToolingService.BlockServerAsync(Arg.Any()).Returns(true); + + var fullCommand = new List { command }; + fullCommand.AddRange(args); + fullCommand.Add("--dry-run"); // Use dry run to avoid actual service calls + + // Act + var result = await _command.InvokeAsync(fullCommand.ToArray()); + + // Assert + result.Should().Be(0, $"Azure CLI style command should be accepted: {string.Join(" ", fullCommand)}"); + } + + [Fact] + public async Task ServiceIntegration_PublishCommand_PassesCorrectParameters() + { + // Core functionality test: Ensures publish command integration works correctly + + // Arrange + var testEnvId = "test-environment-123"; + var testServerName = "msdyn_TestServer"; + var testAlias = "test-alias"; + var testDisplayName = "Test Server Display Name"; + + var mockResponse = new PublishMcpServerResponse + { + Status = "Success", + Message = "Server published successfully" + }; + + _mockToolingService.PublishServerAsync(testEnvId, testServerName, Arg.Any()) + .Returns(mockResponse); + + // Act + var result = await _command.InvokeAsync(new[] + { + "publish", + "--environment-id", testEnvId, + "--server-name", testServerName, + "--alias", testAlias, + "--display-name", testDisplayName + }); + + // Assert + result.Should().Be(0); + + await _mockToolingService.Received(1).PublishServerAsync( + testEnvId, + testServerName, + Arg.Is(req => + req.Alias == testAlias && + req.DisplayName == testDisplayName) + ); + } + + [Fact] + public async Task ServiceIntegration_UnpublishCommand_PassesCorrectParameters() + { + // Core functionality test: Ensures unpublish command integration works correctly + + // Arrange + var testEnvId = "test-environment-456"; + var testServerName = "msdyn_TestServer"; + + _mockToolingService.UnpublishServerAsync(testEnvId, testServerName).Returns(true); + + // Act + var result = await _command.InvokeAsync(new[] + { + "unpublish", + "-e", testEnvId, + "-s", testServerName + }); + + // Assert + result.Should().Be(0); + await _mockToolingService.Received(1).UnpublishServerAsync(testEnvId, testServerName); + } + + [Theory] + [InlineData("approve")] + [InlineData("block")] + public async Task NewCommands_ApproveAndBlock_WorkCorrectly(string commandName) + { + // Regression test: Ensures newly implemented approve/block commands function properly + + // Arrange + var testServerName = "msdyn_TestServer"; + + _mockToolingService.ApproveServerAsync(testServerName).Returns(true); + _mockToolingService.BlockServerAsync(testServerName).Returns(true); + + // Act + var result = await _command.InvokeAsync(new[] { commandName, "-s", testServerName }); + + // Assert + result.Should().Be(0); + + if (commandName == "approve") + { + await _mockToolingService.Received(1).ApproveServerAsync(testServerName); + } + else + { + await _mockToolingService.Received(1).BlockServerAsync(testServerName); + } + } + + [Fact] + public void CommandStructure_HasNoPositionalArguments() + { + // Critical regression test: Ensures we don't accidentally revert to positional arguments + // This was a key architectural decision to follow Azure CLI patterns + + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert + foreach (var subcommand in command.Subcommands) + { + subcommand.Arguments.Should().BeEmpty( + $"Subcommand '{subcommand.Name}' must not have positional arguments - Azure CLI compliance requires named options only"); + } + } + + [Fact] + public void CommandStructure_AllSubcommandsHaveConsistentOptions() + { + // Regression test: Ensures consistent option patterns across all commands + + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert + foreach (var subcommand in command.Subcommands) + { + var options = subcommand.Options.ToList(); + + // All commands should have config option + options.Should().Contain(o => o.Name == "config", + $"Subcommand '{subcommand.Name}' should have --config option"); + + // All commands should have dry-run option + options.Should().Contain(o => o.Name == "dry-run", + $"Subcommand '{subcommand.Name}' should have --dry-run option"); + + // Config option should have -c alias + var configOption = options.First(o => o.Name == "config"); + configOption.Aliases.Should().Contain("-c", + $"Config option in '{subcommand.Name}' should have -c alias"); + } + } +} \ No newline at end of file diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs new file mode 100644 index 00000000..0c56e64d --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using NSubstitute; +using FluentAssertions; +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +public class DevelopMcpCommandTests +{ + private readonly ILogger _mockLogger; + private readonly IAgent365ToolingService _mockToolingService; + + public DevelopMcpCommandTests() + { + _mockLogger = Substitute.For(); + _mockToolingService = Substitute.For(); + } + + [Fact] + public void CreateCommand_ReturnsCommandWithCorrectName() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert + command.Name.Should().Be("develop-mcp"); + command.Description.Should().Be("Manage MCP servers in Dataverse environments"); + } + + [Fact] + public void CreateCommand_HasAllExpectedSubcommands() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert + command.Subcommands.Should().HaveCount(6); + + var subcommandNames = command.Subcommands.Select(sc => sc.Name).ToList(); + subcommandNames.Should().Contain(new[] + { + "list-environments", + "list-servers", + "publish", + "unpublish", + "approve", + "block" + }); + } + + [Fact] + public void ListEnvironmentsSubcommand_HasCorrectOptionsAndAliases() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "list-environments"); + + // Assert + subcommand.Description.Should().Be("List all Dataverse environments available for MCP server management"); + + var options = subcommand.Options.ToList(); + options.Should().HaveCount(2); // config, dry-run (plus help automatically) + + // Verify config option + var configOption = options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("-c"); + configOption.Aliases.Should().Contain("--config"); + + // Verify dry-run option + var dryRunOption = options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + dryRunOption!.Aliases.Should().Contain("--dry-run"); + } + + [Fact] + public void ListServersSubcommand_HasCorrectOptionsWithAliases() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "list-servers"); + + // Assert + subcommand.Description.Should().Be("List MCP servers in a specific Dataverse environment"); + + var options = subcommand.Options.ToList(); + options.Should().HaveCount(3); // environment-id, config, dry-run + + // Verify environment-id option with short alias + var envOption = options.FirstOrDefault(o => o.Name == "environment-id"); + envOption.Should().NotBeNull(); + envOption!.Aliases.Should().Contain("-e"); + envOption.Aliases.Should().Contain("--environment-id"); + + // Verify config option + var configOption = options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("-c"); + } + + [Fact] + public void PublishSubcommand_HasCorrectOptionsWithAliases() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "publish"); + + // Assert + subcommand.Description.Should().Be("Publish an MCP server to a Dataverse environment"); + + var options = subcommand.Options.ToList(); + + // Verify all expected options exist + var optionNames = options.Select(o => o.Name).ToList(); + optionNames.Should().Contain("environment-id"); + optionNames.Should().Contain("server-name"); + optionNames.Should().Contain("alias"); + optionNames.Should().Contain("display-name"); + optionNames.Should().Contain("config"); + optionNames.Should().Contain("dry-run"); + + // Verify critical aliases for Azure CLI compliance + var envOption = options.FirstOrDefault(o => o.Name == "environment-id"); + envOption!.Aliases.Should().Contain("-e"); + + var serverOption = options.FirstOrDefault(o => o.Name == "server-name"); + serverOption!.Aliases.Should().Contain("-s"); + + var aliasOption = options.FirstOrDefault(o => o.Name == "alias"); + aliasOption!.Aliases.Should().Contain("-a"); + + var displayNameOption = options.FirstOrDefault(o => o.Name == "display-name"); + displayNameOption!.Aliases.Should().Contain("-d"); + } + + [Fact] + public void UnpublishSubcommand_HasCorrectOptionsWithAliases() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "unpublish"); + + // Assert + subcommand.Description.Should().Be("Unpublish an MCP server from a Dataverse environment"); + + var options = subcommand.Options.ToList(); + + // Verify expected options + var optionNames = options.Select(o => o.Name).ToList(); + optionNames.Should().Contain("environment-id"); + optionNames.Should().Contain("server-name"); + optionNames.Should().Contain("config"); + optionNames.Should().Contain("dry-run"); + + // Verify Azure CLI style aliases + var envOption = options.FirstOrDefault(o => o.Name == "environment-id"); + envOption!.Aliases.Should().Contain("-e"); + + var serverOption = options.FirstOrDefault(o => o.Name == "server-name"); + serverOption!.Aliases.Should().Contain("-s"); + } + + [Fact] + public void ApproveSubcommand_IsImplementedWithCorrectOptions() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "approve"); + + // Assert + subcommand.Description.Should().Be("Approve an MCP server"); + + var options = subcommand.Options.ToList(); + var optionNames = options.Select(o => o.Name).ToList(); + optionNames.Should().Contain("server-name"); + optionNames.Should().Contain("config"); + optionNames.Should().Contain("dry-run"); + + // Verify server-name has short alias + var serverOption = options.FirstOrDefault(o => o.Name == "server-name"); + serverOption!.Aliases.Should().Contain("-s"); + } + + [Fact] + public void BlockSubcommand_IsImplementedWithCorrectOptions() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "block"); + + // Assert + subcommand.Description.Should().Be("Block an MCP server"); + + var options = subcommand.Options.ToList(); + var optionNames = options.Select(o => o.Name).ToList(); + optionNames.Should().Contain("server-name"); + optionNames.Should().Contain("config"); + optionNames.Should().Contain("dry-run"); + + // Verify server-name has short alias + var serverOption = options.FirstOrDefault(o => o.Name == "server-name"); + serverOption!.Aliases.Should().Contain("-s"); + } + + [Fact] + public void AllSubcommands_SupportDryRunOption() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert - All subcommands should have dry-run option for safety + foreach (var subcommand in command.Subcommands) + { + var dryRunOption = subcommand.Options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull($"Subcommand '{subcommand.Name}' should have --dry-run option"); + } + } + + [Fact] + public void AllSubcommands_SupportConfigOption() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert - All subcommands should have config option for consistency + foreach (var subcommand in command.Subcommands) + { + var configOption = subcommand.Options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull($"Subcommand '{subcommand.Name}' should have --config option"); + configOption!.Aliases.Should().Contain("-c", $"Config option should have -c alias in '{subcommand.Name}'"); + } + } + + [Theory] + [InlineData("list-servers", "environment-id", "-e")] + [InlineData("publish", "environment-id", "-e")] + [InlineData("unpublish", "environment-id", "-e")] + [InlineData("publish", "server-name", "-s")] + [InlineData("unpublish", "server-name", "-s")] + [InlineData("approve", "server-name", "-s")] + [InlineData("block", "server-name", "-s")] + public void CriticalOptions_HaveConsistentAliases(string subcommandName, string optionName, string expectedAlias) + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == subcommandName); + var option = subcommand.Options.FirstOrDefault(o => o.Name == optionName); + + // Assert + option.Should().NotBeNull($"Option '{optionName}' should exist in '{subcommandName}' command"); + option!.Aliases.Should().Contain(expectedAlias, + $"Option '{optionName}' in '{subcommandName}' should have alias '{expectedAlias}'"); + } + + [Fact] + public void NoSubcommands_UsePositionalArguments_OnlyOptions() + { + // This is a regression test to ensure we don't accidentally revert to positional arguments + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + + // Assert + foreach (var subcommand in command.Subcommands) + { + subcommand.Arguments.Should().BeEmpty( + $"Subcommand '{subcommand.Name}' should not have positional arguments - use named options for Azure CLI compliance"); + } + } +} From 573631401b96ef7883cb32d0456e7b677a6ea096 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Thu, 13 Nov 2025 21:46:43 -0800 Subject: [PATCH 2/7] Add verbose logging, input validation, and refactoring Enhanced `a365 develop-mcp` commands with a `--verbose` option for detailed logging and improved input validation via a new `InputValidator` class. Simplified configuration handling by making `--config` optional and defaulting to production. Refactored `Agent365ToolingService` to use constructor-injected environments and adjusted logging levels for better debugging. Improved error handling for API responses and JSON deserialization. Updated documentation to reflect new features and architecture principles. Added tests for `--verbose` and ensured compliance with the MIT License. General code cleanup for consistency and maintainability. --- README.md | 8 +- src/DEVELOPER.md | 37 +- .../Commands/DevelopMcpCommand.cs | 437 ++++++++++++++---- .../Models/DataverseEnvironment.cs | 2 + .../Models/DataverseMcpServer.cs | 2 + .../Models/PublishMcpServerRequest.cs | 2 + .../Models/PublishMcpServerResponse.cs | 2 + .../Program.cs | 52 ++- .../Services/Agent365ToolingService.cs | 88 ++-- .../Helpers/JsonDeserializationHelper.cs | 47 +- .../Services/IAgent365ToolingService.cs | 2 + .../Commands/DevelopMcpCommandTests.cs | 16 +- 12 files changed, 508 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index b82c2edb..250d18a2 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ a365 develop --list ``` ### MCP Server Management + +Manage Model Context Protocol (MCP) servers in Dataverse environments. The CLI automatically uses the production environment unless a configuration file is specified with `--config`. + ```bash # List Dataverse environments a365 develop-mcp list-environments @@ -136,12 +139,15 @@ a365 develop-mcp publish -e "Default-12345678-1234-1234-1234-123456789abc" -s "m # Unpublish an MCP server a365 develop-mcp unpublish -e "Default-12345678-1234-1234-1234-123456789abc" -s "msdyn_MyMcpServer" -# Approve/block MCP servers +# Approve/block MCP servers (global operations, no environment needed) a365 develop-mcp approve -s "msdyn_MyMcpServer" a365 develop-mcp block -s "msdyn_MyMcpServer" # All commands support dry-run for safe testing a365 develop-mcp publish -e "myenv" -s "myserver" --dry-run + +# Use verbose output for detailed logging +a365 develop-mcp list-environments --verbose ``` --- diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index 41335e6d..e455739c 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -101,7 +101,13 @@ The CLI provides a `config` command for managing configuration: ### MCP Server Management Command -The CLI provides a `develop-mcp` command for managing Model Context Protocol (MCP) servers in Dataverse environments: +The CLI provides a `develop-mcp` command for managing Model Context Protocol (MCP) servers in Dataverse environments. The command follows a **minimal configuration approach** - it defaults to the production environment and only requires additional configuration when needed. + +**Configuration Approach:** +- **Default Environment**: Uses "prod" environment automatically +- **Optional Config File**: Use `--config/-c` to specify custom environment from a365.config.json +- **Production First**: Optimized for production workflows with minimal setup +- **KISS Principle**: Avoids over-engineering common use cases **Environment Management:** - `a365 develop-mcp list-environments` — List all available Dataverse environments for MCP server management @@ -111,20 +117,28 @@ The CLI provides a `develop-mcp` command for managing Model Context Protocol (MC - `a365 develop-mcp publish -e -s ` — Publish an MCP server to a Dataverse environment - `a365 develop-mcp unpublish -e -s ` — Unpublish an MCP server from a Dataverse environment -**Server Approval:** +**Server Approval (Global Operations):** - `a365 develop-mcp approve -s ` — Approve an MCP server - `a365 develop-mcp block -s ` — Block an MCP server **Key Features:** - **Azure CLI Style Parameters:** Uses named options (`--environment-id/-e`, `--server-name/-s`) for better UX - **Dry Run Support:** All commands support `--dry-run` for safe testing -- **Consistent Configuration:** All commands support `--config/-c` for custom configuration files +- **Optional Configuration:** Use `--config/-c` only when non-production environment is needed +- **Production Default:** Works out-of-the-box with prod environment, no config file required +- **Verbose Logging:** Use `--verbose` for detailed output and debugging - **Interactive Prompts:** Missing required parameters prompt for user input - **Comprehensive Logging:** Detailed logging for debugging and audit trails +**Configuration Options:** +- **No Config (Default)**: Uses production environment automatically +- **With Config File**: `--config path/to/a365.config.json` to specify custom environment +- **Verbose Output**: `--verbose` for detailed logging and debugging information + **Examples:** + ```bash -# List all environments +# Default usage (production environment, no config needed) a365 develop-mcp list-environments # List servers in a specific environment @@ -140,13 +154,26 @@ a365 develop-mcp publish \ # Quick unpublish with short aliases a365 develop-mcp unpublish -e "Default-12345678-1234-1234-1234-123456789abc" -s "msdyn_MyMcpServer" -# Approve a server +# Approve a server (global operation) a365 develop-mcp approve --server-name "msdyn_MyMcpServer" # Test commands safely with dry-run a365 develop-mcp publish -e "myenv" -s "myserver" --dry-run + +# Use custom environment from config file (internal developers) +a365 develop-mcp list-environments --config ./dev-config.json + +# Verbose output for debugging +a365 develop-mcp list-servers -e "myenv" --verbose ``` +**Architecture Notes:** +- Uses constructor injection pattern for environment configuration +- Agent365ToolingService receives environment parameter via dependency injection +- Program.cs detects --config option and extracts environment from config file +- Defaults to "prod" when no config file is specified +- Follows KISS principles to avoid over-engineering common scenarios + ## Inheritable Permissions: Best Practice Agent 365 CLI and the Agent 365 platform are designed to use inheritable permissions on agent blueprints. This means: diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 00660508..91b2e09d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using Microsoft.Extensions.Logging; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; @@ -19,17 +21,11 @@ public static Command CreateCommand( { var developMcpCommand = new Command("develop-mcp", "Manage MCP servers in Dataverse environments"); - // Add common options - var configOption = new Option( - ["--config", "-c"], - getDefaultValue: () => "a365.config.json", - description: "Configuration file path"); - + // Add minimal options - config is optional and not advertised (for internal developers only) var verboseOption = new Option( ["--verbose", "-v"], description: "Enable verbose logging"); - developMcpCommand.AddOption(configOption); developMcpCommand.AddOption(verboseOption); // Add subcommands @@ -65,8 +61,19 @@ private static Command CreateListEnvironmentsSubcommand( ); command.AddOption(dryRunOption); - command.SetHandler(async (configPath, dryRun) => + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Enable verbose logging" + ); + command.AddOption(verboseOption); + + command.SetHandler(async (configPath, dryRun, verbose) => { + if (verbose) + { + logger.LogInformation("Verbose mode enabled - showing detailed information"); + } + logger.LogInformation("Starting list-environments operation..."); if (dryRun) @@ -81,6 +88,12 @@ private static Command CreateListEnvironmentsSubcommand( // Call service var environmentsResponse = await toolingService.ListEnvironmentsAsync(); + if (verbose) + { + logger.LogInformation("API call completed - received response with {Count} environment(s)", + environmentsResponse?.Environments?.Length ?? 0); + } + if (environmentsResponse == null || environmentsResponse.Environments.Length == 0) { logger.LogInformation("No Dataverse environments found"); @@ -109,11 +122,20 @@ private static Command CreateListEnvironmentsSubcommand( { logger.LogInformation(" Region: {Geo}", env.Geo); } + + // Show additional details in verbose mode + if (verbose) + { + if (!string.IsNullOrWhiteSpace(env.TenantId)) + { + logger.LogInformation(" Tenant ID: {TenantId}", env.TenantId); + } + } } logger.LogInformation("Listed {Count} Dataverse environment(s)", environmentsResponse.Environments.Length); - }, configOption, dryRunOption); + }, configOption, dryRunOption, verboseOption); return command; } @@ -147,18 +169,46 @@ private static Command CreateListServersSubcommand( ); command.AddOption(dryRunOption); - command.SetHandler(async (envId, configPath, dryRun) => + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Enable verbose logging" + ); + command.AddOption(verboseOption); + + command.SetHandler(async (envId, configPath, dryRun, verbose) => { - // Prompt for missing required argument - if (string.IsNullOrWhiteSpace(envId)) + if (verbose) { - Console.Write("Enter Dataverse environment ID: "); - envId = Console.ReadLine(); + logger.LogInformation("Verbose mode enabled - showing detailed information"); + } + + try + { + // Validate and prompt for missing required argument with security checks if (string.IsNullOrWhiteSpace(envId)) { - logger.LogError("Environment ID is required"); - return; + envId = InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID"); + if (string.IsNullOrWhiteSpace(envId)) + { + logger.LogError("Environment ID is required"); + return; + } } + else + { + // Validate provided environment ID + envId = InputValidator.ValidateInput(envId, "Environment ID"); + if (envId == null) + { + logger.LogError("Invalid environment ID format"); + return; + } + } + } + catch (ArgumentException ex) + { + logger.LogError("Input validation failed: {Message}", ex.Message); + return; } logger.LogInformation("Starting list-servers operation for environment {EnvId}...", envId); @@ -245,7 +295,7 @@ private static Command CreateListServersSubcommand( } logger.LogInformation("Listed {Count} MCP server(s) in environment {EnvId}", servers.Length, envId); - }, envIdOption, configOption, dryRunOption); + }, envIdOption, configOption, dryRunOption, verboseOption); return command; } @@ -300,63 +350,107 @@ private static Command CreatePublishSubcommand( command.SetHandler(async (envId, serverName, alias, displayName, configPath, dryRun) => { - // Prompt for missing required arguments - if (string.IsNullOrWhiteSpace(envId)) + try { - Console.Write("Enter Dataverse environment ID: "); - envId = Console.ReadLine(); + // Validate and prompt for missing required arguments with security checks if (string.IsNullOrWhiteSpace(envId)) { - logger.LogError("Environment ID is required"); - return; + envId = InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID"); + if (string.IsNullOrWhiteSpace(envId)) + { + logger.LogError("Environment ID is required"); + return; + } + } + else + { + // Validate provided environment ID + envId = InputValidator.ValidateInput(envId, "Environment ID"); + if (envId == null) + { + logger.LogError("Invalid environment ID format"); + return; + } } - } - if (string.IsNullOrWhiteSpace(serverName)) - { - Console.Write("Enter MCP server name to publish: "); - serverName = Console.ReadLine(); if (string.IsNullOrWhiteSpace(serverName)) { - logger.LogError("Server name is required"); - return; + serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to publish: ", "Server name", 100); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + else + { + // Validate provided server name + serverName = InputValidator.ValidateInput(serverName, "Server name"); + if (serverName == null) + { + logger.LogError("Invalid server name format"); + return; + } } - } - logger.LogInformation("Starting publish operation for server {ServerName} in environment {EnvId}...", serverName, envId); + logger.LogInformation("Starting publish operation for server {ServerName} in environment {EnvId}...", serverName, envId); - if (dryRun) - { - logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); - logger.LogInformation("[DRY RUN] Would publish MCP server {ServerName} to environment {EnvId}", serverName, envId); - logger.LogInformation("[DRY RUN] Alias: {Alias}", alias ?? "[would prompt]"); - logger.LogInformation("[DRY RUN] Display Name: {DisplayName}", displayName ?? "[would prompt]"); - await Task.CompletedTask; - return; - } + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would read config from {ConfigPath}", configPath); + logger.LogInformation("[DRY RUN] Would publish MCP server {ServerName} to environment {EnvId}", serverName, envId); + logger.LogInformation("[DRY RUN] Alias: {Alias}", alias ?? "[would prompt]"); + logger.LogInformation("[DRY RUN] Display Name: {DisplayName}", displayName ?? "[would prompt]"); + await Task.CompletedTask; + return; + } - // Prompt for missing optional values - if (string.IsNullOrWhiteSpace(alias)) - { - Console.Write("Enter alias for the MCP server: "); - alias = Console.ReadLine(); + // Validate and prompt for missing optional values with security checks if (string.IsNullOrWhiteSpace(alias)) { - logger.LogError("Alias is required"); - return; + alias = InputValidator.PromptAndValidateRequiredInput("Enter alias for the MCP server: ", "Alias", 50); + if (string.IsNullOrWhiteSpace(alias)) + { + logger.LogError("Alias is required"); + return; + } + } + else + { + // Validate provided alias + alias = InputValidator.ValidateInput(alias, "Alias", maxLength: 50); + if (alias == null) + { + logger.LogError("Invalid alias format"); + return; + } } - } - if (string.IsNullOrWhiteSpace(displayName)) - { - Console.Write("Enter display name for the MCP server: "); - displayName = Console.ReadLine(); if (string.IsNullOrWhiteSpace(displayName)) { - logger.LogError("Display name is required"); - return; + displayName = InputValidator.PromptAndValidateRequiredInput("Enter display name for the MCP server: ", "Display name", 100); + if (string.IsNullOrWhiteSpace(displayName)) + { + logger.LogError("Display name is required"); + return; + } + } + else + { + // Validate provided display name + displayName = InputValidator.ValidateInput(displayName, "Display name", maxLength: 100); + if (displayName == null) + { + logger.LogError("Invalid display name format"); + return; + } } } + catch (ArgumentException ex) + { + logger.LogError("Input validation failed: {Message}", ex.Message); + return; + } // Create request var request = new PublishMcpServerRequest @@ -426,28 +520,54 @@ private static Command CreateUnpublishSubcommand( command.SetHandler(async (envId, serverName, configPath, dryRun) => { - // Prompt for missing required arguments - if (string.IsNullOrWhiteSpace(envId)) + try { - Console.Write("Enter Dataverse environment ID: "); - envId = Console.ReadLine(); + // Validate and prompt for missing required arguments with security checks if (string.IsNullOrWhiteSpace(envId)) { - logger.LogError("Environment ID is required"); - return; + envId = InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID"); + if (string.IsNullOrWhiteSpace(envId)) + { + logger.LogError("Environment ID is required"); + return; + } + } + else + { + // Validate provided environment ID + envId = InputValidator.ValidateInput(envId, "Environment ID"); + if (envId == null) + { + logger.LogError("Invalid environment ID format"); + return; + } } - } - if (string.IsNullOrWhiteSpace(serverName)) - { - Console.Write("Enter MCP server name to unpublish: "); - serverName = Console.ReadLine(); if (string.IsNullOrWhiteSpace(serverName)) { - logger.LogError("Server name is required"); - return; + serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to unpublish: ", "Server name", 100); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + else + { + // Validate provided server name + serverName = InputValidator.ValidateInput(serverName, "Server name"); + if (serverName == null) + { + logger.LogError("Invalid server name format"); + return; + } } } + catch (ArgumentException ex) + { + logger.LogError("Input validation failed: {Message}", ex.Message); + return; + } logger.LogInformation("Starting unpublish operation for server {ServerName} in environment {EnvId}...", serverName, envId); @@ -476,7 +596,7 @@ private static Command CreateUnpublishSubcommand( } /// - /// Creates the approve subcommand (not implemented) + /// Creates the approve subcommand /// private static Command CreateApproveSubcommand(ILogger logger, IAgent365ToolingService toolingService) { @@ -504,17 +624,34 @@ private static Command CreateApproveSubcommand(ILogger logger, IAgent365ToolingS command.SetHandler(async (serverName, configPath, dryRun) => { - // Prompt for missing required arguments - if (string.IsNullOrWhiteSpace(serverName)) + try { - Console.Write("Enter MCP server name to approve: "); - serverName = Console.ReadLine(); + // Validate and prompt for missing required arguments with security checks if (string.IsNullOrWhiteSpace(serverName)) { - logger.LogError("Server name is required"); - return; + serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to approve: ", "Server name", 100); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + else + { + // Validate provided server name + serverName = InputValidator.ValidateInput(serverName, "Server name"); + if (serverName == null) + { + logger.LogError("Invalid server name format"); + return; + } } } + catch (ArgumentException ex) + { + logger.LogError("Input validation failed: {Message}", ex.Message); + return; + } logger.LogInformation("Starting approve operation for server {ServerName}...", serverName); @@ -543,7 +680,7 @@ private static Command CreateApproveSubcommand(ILogger logger, IAgent365ToolingS } /// - /// Creates the block subcommand (not yet implemented) + /// Creates the block subcommand /// private static Command CreateBlockSubcommand(ILogger logger, IAgent365ToolingService toolingService) { @@ -571,17 +708,34 @@ private static Command CreateBlockSubcommand(ILogger logger, IAgent365ToolingSer command.SetHandler(async (serverName, configPath, dryRun) => { - // Prompt for missing required arguments - if (string.IsNullOrWhiteSpace(serverName)) + try { - Console.Write("Enter MCP server name to block: "); - serverName = Console.ReadLine(); + // Validate and prompt for missing required arguments with security checks if (string.IsNullOrWhiteSpace(serverName)) { - logger.LogError("Server name is required"); - return; + serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to block: ", "Server name", 100); + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("Server name is required"); + return; + } + } + else + { + // Validate provided server name + serverName = InputValidator.ValidateInput(serverName, "Server name"); + if (serverName == null) + { + logger.LogError("Invalid server name format"); + return; + } } } + catch (ArgumentException ex) + { + logger.LogError("Input validation failed: {Message}", ex.Message); + return; + } logger.LogInformation("Starting block operation for server {ServerName}...", serverName); @@ -608,4 +762,123 @@ private static Command CreateBlockSubcommand(ILogger logger, IAgent365ToolingSer return command; } + + /// + /// Validates and sanitizes user input following Azure CLI security patterns + /// + private static class InputValidator + { + private static readonly char[] InvalidChars = ['<', '>', '"', '|', '\0', '\u0001', '\u0002', '\u0003', '\u0004', '\u0005', '\u0006', '\u0007', '\u0008', '\u0009', '\u000a', '\u000b', '\u000c', '\u000d', '\u000e', '\u000f', '\u0010', '\u0011', '\u0012', '\u0013', '\u0014', '\u0015', '\u0016', '\u0017', '\u0018', '\u0019', '\u001a', '\u001b', '\u001c', '\u001d', '\u001e', '\u001f']; + + /// + /// Prompts for and validates a required string input + /// + public static string? PromptAndValidateRequiredInput(string promptText, string fieldName, int maxLength = 255) + { + Console.Write(promptText); + var input = Console.ReadLine()?.Trim(); + + return ValidateInput(input, fieldName, isRequired: true, maxLength); + } + + /// + /// Prompts for and validates an optional string input + /// + public static string? PromptAndValidateOptionalInput(string promptText, string fieldName, int maxLength = 255) + { + Console.Write(promptText); + var input = Console.ReadLine()?.Trim(); + + return ValidateInput(input, fieldName, isRequired: false, maxLength); + } + + /// + /// Validates string input following Azure CLI security patterns + /// + public static string? ValidateInput(string? input, string fieldName, bool isRequired = true, int maxLength = 255) + { + // Handle null or empty input + if (string.IsNullOrWhiteSpace(input)) + { + return isRequired ? null : string.Empty; + } + + // Trim and validate length + input = input.Trim(); + if (input.Length > maxLength) + { + throw new ArgumentException($"{fieldName} cannot exceed {maxLength} characters"); + } + + // Check for dangerous characters that could be used in injection attacks + if (input.IndexOfAny(InvalidChars) != -1) + { + throw new ArgumentException($"{fieldName} contains invalid characters"); + } + + // Additional validation for environment ID (must be reasonable identifier) + if (fieldName.Equals("Environment ID", StringComparison.OrdinalIgnoreCase)) + { + if (!IsValidEnvironmentId(input)) + { + throw new ArgumentException("Environment ID must be a valid identifier (GUID or alphanumeric with hyphens)"); + } + } + + // Additional validation for server names (alphanumeric, hyphens, underscores only) + if (fieldName.Equals("Server name", StringComparison.OrdinalIgnoreCase)) + { + if (!IsValidServerName(input)) + { + throw new ArgumentException("Server name can only contain alphanumeric characters, hyphens, and underscores"); + } + } + + return input; + } + + /// + /// Validates environment ID format (GUID or reasonable test identifier) + /// + private static bool IsValidEnvironmentId(string input) + { + // Accept GUID format (production case) + if (Guid.TryParse(input, out _)) + return true; + + // Accept alphanumeric identifiers with hyphens for test scenarios + // Must start with alphanumeric character and contain only safe characters + if (string.IsNullOrWhiteSpace(input)) + return false; + + if (!char.IsLetterOrDigit(input[0])) + return false; + + return input.All(c => char.IsLetterOrDigit(c) || c == '-'); + } + + /// + /// Validates GUID format for strict GUID requirements + /// + private static bool IsValidGuidFormat(string input) + { + return Guid.TryParse(input, out _); + } + + /// + /// Validates server name format (alphanumeric, hyphens, underscores) + /// + private static bool IsValidServerName(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + // Must start with alphanumeric character + if (!char.IsLetterOrDigit(input[0])) + return false; + + // Can contain only letters, digits, hyphens, and underscores + return input.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'); + } + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs index e70a18aa..4a508f51 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using System.Text.Json.Serialization; namespace Microsoft.Agents.A365.DevTools.Cli.Models; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs index 3ad3255b..6f672809 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using System.Text.Json.Serialization; namespace Microsoft.Agents.A365.DevTools.Cli.Models; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs index d4dd60c5..966def0b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using System.Text.Json.Serialization; namespace Microsoft.Agents.A365.DevTools.Cli.Models; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs index 6b54d477..1b874682 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using System.Text.Json.Serialization; namespace Microsoft.Agents.A365.DevTools.Cli.Models; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 770e5faf..6e98879d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -19,9 +19,12 @@ static async Task Main(string[] args) var commandName = DetectCommandName(args); var logFilePath = ConfigService.GetCommandLogPath(commandName); + // Check if verbose flag is present to adjust logging level + var isVerbose = args.Contains("--verbose") || args.Contains("-v"); + // Configure Serilog with both console and file output Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() + .MinimumLevel.Is(isVerbose ? Serilog.Events.LogEventLevel.Debug : Serilog.Events.LogEventLevel.Information) .WriteTo.Console() // Console output (user-facing) .WriteTo.File( // File output (for debugging) path: logFilePath, @@ -34,14 +37,13 @@ static async Task Main(string[] args) try { - // Log startup info to file - Log.Information("=========================================================="); - Log.Information("Agent 365 CLI - Command: {Command}", commandName); - Log.Information("Version: {Version}", GetDisplayVersion()); - Log.Information("Log file: {LogFile}", logFilePath); - Log.Information("Started at: {Time}", DateTime.Now); - Log.Information("=========================================================="); - Log.Information(""); + // Log startup info to file only (debug level - not shown to users by default) + Log.Debug("=========================================================="); + Log.Debug("Agent 365 CLI - Command: {Command}", commandName); + Log.Debug("Version: {Version}", GetDisplayVersion()); + Log.Debug("Log file: {LogFile}", logFilePath); + Log.Debug("Started at: {Time}", DateTime.Now); + Log.Debug("=========================================================="); // Log version information var version = GetDisplayVersion(); @@ -168,8 +170,36 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - // Add Agent365 Tooling Service - services.AddSingleton(); + // Add Agent365 Tooling Service with environment detection + services.AddSingleton(provider => + { + var configService = provider.GetRequiredService(); + var authService = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + + // Determine environment: try to load from config if --config option is provided, otherwise default to prod + string environment = "prod"; // Default + + // Check if --config argument was provided (for internal developers) + var args = Environment.GetCommandLineArgs(); + var configIndex = Array.FindIndex(args, arg => arg == "--config" || arg == "-c"); + if (configIndex >= 0 && configIndex < args.Length - 1) + { + try + { + // Try to load config file to get environment + var config = configService.LoadAsync(args[configIndex + 1]).Result; + environment = config.Environment; + } + catch + { + // If config loading fails, stick with default "prod" + // This is fine - the service will work with default environment + } + } + + return new Agent365ToolingService(configService, authService, logger, environment); + }); // Add Azure validators (individual validators for composition) services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 128238eb..c268d213 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using System.Net.Http; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -16,15 +18,18 @@ public class Agent365ToolingService : IAgent365ToolingService private readonly IConfigService _configService; private readonly AuthenticationService _authService; private readonly ILogger _logger; + private readonly string _environment; public Agent365ToolingService( IConfigService configService, AuthenticationService authService, - ILogger logger) + ILogger logger, + string environment = "prod") { _configService = configService ?? throw new ArgumentNullException(nameof(configService)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _environment = environment ?? "prod"; } /// @@ -52,7 +57,7 @@ public Agent365ToolingService( // Read response content var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogInformation("Received response from {Operation} endpoint", operationName); - _logger.LogInformation("Response content: {ResponseContent}", responseContent); + _logger.LogDebug("Response content: {ResponseContent}", responseContent); // Check if response content indicates failure (Agent365 API pattern) // The API may return double-serialized JSON, so we use JsonDeserializationHelper @@ -64,22 +69,19 @@ public Agent365ToolingService( var statusResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization( responseContent, _logger); - if (statusResponse != null && !string.IsNullOrEmpty(statusResponse.Status)) + if (statusResponse != null && !string.IsNullOrEmpty(statusResponse.Status) && statusResponse.Status != "Success") { - if (statusResponse.Status != "Success") + // Extract error message + string errorMessage = statusResponse.Message ?? $"{operationName} failed"; + + // Also check for Error property which might contain additional details + if (!string.IsNullOrEmpty(statusResponse.Error)) { - // Extract error message - string errorMessage = statusResponse.Message ?? $"{operationName} failed"; - - // Also check for Error property which might contain additional details - if (!string.IsNullOrEmpty(statusResponse.Error)) - { - errorMessage += $" - {statusResponse.Error}"; - } - - _logger.LogError("{Operation} failed: {Message}", operationName, errorMessage); - return (false, responseContent); + errorMessage += $" - {statusResponse.Error}"; } + + _logger.LogError("{Operation} failed: {Message}", operationName, errorMessage); + return (false, responseContent); } } catch (JsonException ex) @@ -214,18 +216,15 @@ private string BuildBlockMcpServerUrl(string environment, string serverName) { try { - // Load configuration - var config = await _configService.LoadAsync(); - - // Build URL using private helper method - var endpointUrl = BuildListEnvironmentsUrl(config.Environment); + // Build URL using environment from constructor + var endpointUrl = BuildListEnvironmentsUrl(_environment); _logger.LogInformation("Listing Dataverse environments"); - _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Environment: {Env}", _environment); _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); // Get authentication token - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); var authToken = await _authService.GetAccessTokenAsync(audience); @@ -292,18 +291,15 @@ private string BuildBlockMcpServerUrl(string environment, string serverName) try { - // Load configuration - var config = await _configService.LoadAsync(); - - // Build URL using private helper method - var endpointUrl = BuildListMcpServersUrl(config.Environment, environmentId); + // Build URL using environment from constructor + var endpointUrl = BuildListMcpServersUrl(_environment, environmentId); _logger.LogInformation("Listing MCP servers for environment {EnvId}", environmentId); - _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Environment: {Env}", _environment); _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); // Get authentication token - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); var authToken = await _authService.GetAccessTokenAsync(audience); @@ -363,17 +359,17 @@ private string BuildBlockMcpServerUrl(string environment, string serverName) try { // Load configuration - var config = await _configService.LoadAsync(); + // Use environment from constructor // Build URL using private helper method - var endpointUrl = BuildPublishMcpServerUrl(config.Environment, environmentId, serverName); + var endpointUrl = BuildPublishMcpServerUrl(_environment, environmentId, serverName); _logger.LogInformation("Publishing MCP server {ServerName} to environment {EnvId}", serverName, environmentId); - _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Environment: {Env}", _environment); _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); // Get authentication token - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); var authToken = await _authService.GetAccessTokenAsync(audience); @@ -446,17 +442,17 @@ public async Task UnpublishServerAsync( try { // Load configuration - var config = await _configService.LoadAsync(); + // Use environment from constructor // Build URL using private helper method - var endpointUrl = BuildUnpublishMcpServerUrl(config.Environment, environmentId, serverName); + var endpointUrl = BuildUnpublishMcpServerUrl(_environment, environmentId, serverName); _logger.LogInformation("Unpublishing MCP server {ServerName} from environment {EnvId}", serverName, environmentId); - _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Environment: {Env}", _environment); _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); // Get authentication token - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); var authToken = await _authService.GetAccessTokenAsync(audience); @@ -476,7 +472,7 @@ public async Task UnpublishServerAsync( var response = await httpClient.DeleteAsync(endpointUrl, cancellationToken); // Validate response using common helper - var (isSuccess, responseContent) = await ValidateResponseAsync(response, "unpublish MCP server", cancellationToken); + var (isSuccess, _) = await ValidateResponseAsync(response, "unpublish MCP server", cancellationToken); if (!isSuccess) { return false; @@ -503,17 +499,17 @@ public async Task ApproveServerAsync( try { // Load configuration - var config = await _configService.LoadAsync(); + // Use environment from constructor // Build URL using private helper method - var endpointUrl = BuildApproveMcpServerUrl(config.Environment, serverName); + var endpointUrl = BuildApproveMcpServerUrl(_environment, serverName); _logger.LogInformation("Approving MCP server {ServerName}", serverName); - _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Environment: {Env}", _environment); _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); // Get authentication token - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); var authToken = await _authService.GetAccessTokenAsync(audience); @@ -561,17 +557,17 @@ public async Task BlockServerAsync( try { // Load configuration - var config = await _configService.LoadAsync(); + // Use environment from constructor // Build URL using private helper method - var endpointUrl = BuildBlockMcpServerUrl(config.Environment, serverName); + var endpointUrl = BuildBlockMcpServerUrl(_environment, serverName); _logger.LogInformation("Blocking MCP server {ServerName}", serverName); - _logger.LogInformation("Environment: {Env}", config.Environment); + _logger.LogInformation("Environment: {Env}", _environment); _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); // Get authentication token - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); var authToken = await _authService.GetAccessTokenAsync(audience); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs index b3fa0405..e6857995 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using System.Text.Json; using Microsoft.Extensions.Logging; @@ -33,10 +35,8 @@ public static class JsonDeserializationHelper // First, try to deserialize directly (normal case - single serialization) return JsonSerializer.Deserialize(responseContent, options); } - catch (JsonException ex) + catch (JsonException) { - logger.LogDebug(ex, "Failed to deserialize response directly, checking for double-serialization"); - // Check if response is double-serialized JSON (starts with quote and contains escaped JSON) if (responseContent.Length > 0 && responseContent[0] == '"') { @@ -51,49 +51,16 @@ public static class JsonDeserializationHelper return result; } } - catch (Exception unwrapEx) + catch (JsonException) { - logger.LogError(unwrapEx, "Failed to unwrap double-serialized response"); + // Fall through to final error logging } } - logger.LogError(ex, "Failed to deserialize response"); + // Only log as error when all deserialization attempts fail + logger.LogWarning("Failed to deserialize response as {Type}", typeof(T).Name); logger.LogDebug("Response content: {Content}", responseContent); return null; } } - - /// - /// Attempts deserialization with a fallback strategy. - /// First tries to deserialize as T, then as TFallback if T fails. - /// - /// Primary type to deserialize to - /// Fallback type if primary deserialization fails - /// The raw JSON string from the API - /// Logger for diagnostic information - /// Optional JSON serializer options - /// Result with the deserialized object and which type was used - public static (T? result, bool usedFallback) DeserializeWithFallback( - string responseContent, - ILogger logger, - JsonSerializerOptions? options = null) - where T : class - where TFallback : class - { - var primaryResult = DeserializeWithDoubleSerialization(responseContent, logger, options); - if (primaryResult != null) - { - return (primaryResult, false); - } - - logger.LogDebug("Primary deserialization failed, attempting fallback type {FallbackType}", typeof(TFallback).Name); - var fallbackResult = DeserializeWithDoubleSerialization(responseContent, logger, options); - - if (fallbackResult != null && fallbackResult is T converted) - { - return (converted, true); - } - - return (null, false); - } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs index e5939821..de946af0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. using Microsoft.Agents.A365.DevTools.Cli.Models; namespace Microsoft.Agents.A365.DevTools.Cli.Services; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs index 0c56e64d..c17a5738 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -66,7 +66,7 @@ public void ListEnvironmentsSubcommand_HasCorrectOptionsAndAliases() subcommand.Description.Should().Be("List all Dataverse environments available for MCP server management"); var options = subcommand.Options.ToList(); - options.Should().HaveCount(2); // config, dry-run (plus help automatically) + options.Should().HaveCount(3); // config, dry-run, verbose (plus help automatically) // Verify config option var configOption = options.FirstOrDefault(o => o.Name == "config"); @@ -78,6 +78,12 @@ public void ListEnvironmentsSubcommand_HasCorrectOptionsAndAliases() var dryRunOption = options.FirstOrDefault(o => o.Name == "dry-run"); dryRunOption.Should().NotBeNull(); dryRunOption!.Aliases.Should().Contain("--dry-run"); + + // Verify verbose option + var verboseOption = options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("-v"); + verboseOption!.Aliases.Should().Contain("--verbose"); } [Fact] @@ -91,7 +97,7 @@ public void ListServersSubcommand_HasCorrectOptionsWithAliases() subcommand.Description.Should().Be("List MCP servers in a specific Dataverse environment"); var options = subcommand.Options.ToList(); - options.Should().HaveCount(3); // environment-id, config, dry-run + options.Should().HaveCount(4); // environment-id, config, dry-run, verbose // Verify environment-id option with short alias var envOption = options.FirstOrDefault(o => o.Name == "environment-id"); @@ -103,6 +109,12 @@ public void ListServersSubcommand_HasCorrectOptionsWithAliases() var configOption = options.FirstOrDefault(o => o.Name == "config"); configOption.Should().NotBeNull(); configOption!.Aliases.Should().Contain("-c"); + + // Verify verbose option + var verboseOption = options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("-v"); + verboseOption!.Aliases.Should().Contain("--verbose"); } [Fact] From 3e141687d0913def025651f551a6a43c4d87cc1e Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 14 Nov 2025 09:10:16 -0800 Subject: [PATCH 3/7] Enhance a365 CLI with interactive config wizard - Added an interactive configuration wizard (`a365 config init`) with Azure CLI integration, smart defaults, and minimal input requirements. - Introduced `AzureCliService` and `ConfigurationWizardService` to streamline Azure resource detection and configuration. - Updated `README.md` and `DEVELOPER.md` to document new features and usage. - Refactored `ConfigCommand` to support file imports, global configurations, and improved logging. - Added models for Azure resources (`AzureAccountInfo`, `AzureResourceGroup`, etc.) to enhance data handling. - Improved test coverage with new tests for configuration import, Azure CLI interactions, and error handling. - Enhanced logging, validation, and error messages for better user experience. - Updated `ProjectSettingsSyncHelper` and added utility methods for resource name generation and validation. --- README.md | 50 +- src/DEVELOPER.md | 13 +- .../Commands/ConfigCommand.cs | 387 ++----- .../Helpers/ProjectSettingsSyncHelper.cs | 19 +- .../Models/AzureModels.cs | 58 + .../Models/ConfigDerivedNames.cs | 16 + .../Program.cs | 7 +- .../Services/AzureCliService.cs | 172 +++ .../Services/ConfigurationWizardService.cs | 533 +++++++++ .../Services/IAzureCliService.cs | 40 + .../Commands/ConfigCommandTests.cs | 1032 +++++++++-------- .../Services/AzureCliServiceTests.cs | 278 +++++ 12 files changed, 1786 insertions(+), 819 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs diff --git a/README.md b/README.md index 250d18a2..09106a0d 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,42 @@ dotnet tool install -g Microsoft.Agents.A365.DevTools.Cli ### 2. Configure -You can configure the CLI in two ways: - -#### a) Interactive setup +Configure the CLI using the interactive wizard: ```bash a365 config init ``` -This will prompt you for all required Azure and agent details, then write `a365.config.json` to the correct location. -The interactive setup includes helpful prompts with: -- **Smart defaults** based on your current context -- **Format validation** for fields like User Principal Names -- **Path verification** to ensure your project directory exists -- **Clear examples** and guidance for each field +The wizard provides: +- **Azure CLI integration** - Automatically detects your Azure subscription, tenant, and resources +- **Smart defaults** - Uses values from existing configuration or generates sensible defaults +- **Minimal input** - Only requires 2-3 core values (agent name, deployment path, manager email) +- **Auto-generation** - Creates related resource names from your agent name +- **Platform detection** - Validates your project type (.NET, Node.js, or Python) + +**What you'll be prompted for:** +- **Agent name** - A unique identifier for your agent (alphanumeric only) +- **Deployment project path** - Path to your agent project directory +- **Manager email** - Email of the manager overseeing this agent +- **Azure resources** - Select from existing resource groups and app service plans + +The wizard will automatically generate: +- Web app names +- Agent identity names +- User principal names +- Display names + +**Import from file:** +```bash +a365 config init -c path/to/config.json +``` -**Minimum required properties:** +**Global configuration:** +```bash +a365 config init --global +``` + +**Minimum required configuration:** ```json { "tenantId": "your-tenant-id", @@ -48,15 +68,7 @@ The interactive setup includes helpful prompts with: } ``` -**Required Fields Explained:** - -- **`agentUserPrincipalName`**: The User Principal Name (UPN) for the agentic user in email format (e.g., `demo.agent@contoso.onmicrosoft.com`). This creates a dedicated user identity for your agent within Microsoft 365. -- **`agentUserDisplayName`**: Human-readable display name shown in Microsoft 365 applications (e.g., "Sales Assistant Agent" or "Demo Agent User"). -- **`deploymentProjectPath`**: Path to your agent project directory containing the application files. Supports both relative paths (e.g., `./src`) and absolute paths. - -**Note:** The CLI automatically detects your project type (.NET, Node.js, or Python) and builds accordingly. No need to specify project files manually. - -See `a365.config.example.json` for all available options and required properties. +See `a365.config.example.json` for all available options. ### 3. Setup (Blueprint + Messaging Endpoint) diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index e455739c..3a8298cf 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -95,10 +95,19 @@ Microsoft.Agents.A365.DevTools.Cli/ The CLI provides a `config` command for managing configuration: -- `a365 config init` — Interactively prompts for required config values and writes `a365.config.json`. -- `a365 config init -c ` — Imports and validates a config file, then writes it to the standard location. +- `a365 config init` — Interactive wizard with Azure CLI integration and smart defaults. Prompts for agent name, deployment path, and manager email. Auto-generates resource names and validates configuration. +- `a365 config init -c ` — Imports and validates a config file from the specified path. +- `a365 config init --global` — Creates configuration in global directory (AppData) instead of current directory. - `a365 config display` — Prints the current configuration. +**Configuration Wizard Features:** +- **Azure CLI Integration**: Automatically detects subscription, tenant, resource groups, and app service plans +- **Smart Defaults**: Uses existing configuration values or generates intelligent defaults +- **Minimal Input**: Only requires 2-3 core fields (agent name, deployment path, manager email) +- **Auto-Generation**: Creates webapp names, identity names, and UPNs from the agent name +- **Platform Detection**: Validates project type (.NET, Node.js, Python) in deployment path +- **Dual Save**: Saves to both local project directory and global cache for reuse + ### MCP Server Management Command The CLI provides a `develop-mcp` command for managing Model Context Protocol (MCP) servers in Dataverse environments. The command follows a **minimal configuration approach** - it defaults to the production environment and only requires additional configuration when needed. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs index 0163175c..bc522ac0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs @@ -6,25 +6,33 @@ using Microsoft.Extensions.Logging; using System.Runtime.InteropServices; using System.Globalization; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; namespace Microsoft.Agents.A365.DevTools.Cli.Commands; public static class ConfigCommand { - public static Command CreateCommand(ILogger logger, string? configDir = null) + public static Command CreateCommand(ILogger logger, string? configDir = null, IConfigurationWizardService? wizardService = null) { var directory = configDir ?? Services.ConfigService.GetGlobalConfigDirectory(); var command = new Command("config", "Configure Azure subscription, resource settings, and deployment options\nfor a365 CLI commands"); - command.AddCommand(CreateInitSubcommand(logger, directory)); + + if (wizardService != null) + { + command.AddCommand(CreateInitSubcommand(logger, directory, wizardService)); + } + command.AddCommand(CreateDisplaySubcommand(logger, directory)); + return command; } - private static Command CreateInitSubcommand(ILogger logger, string configDir) + private static Command CreateInitSubcommand(ILogger logger, string configDir, IConfigurationWizardService wizardService) { - var cmd = new Command("init", "Initialize configuration settings for Azure resources, agent identity,\nand deployment options used by subsequent Agent 365 commands") + var cmd = new Command("init", "Interactive wizard to configure Agent 365 with Azure CLI integration and smart defaults") { - new Option(new[] { "-c", "--configfile" }, "Path to a config file to import"), + new Option(new[] { "-c", "--configfile" }, "Path to an existing config file to import"), new Option(new[] { "--global", "-g" }, "Create config in global directory (AppData) instead of current directory") }; @@ -36,24 +44,17 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir) string? configFile = context.ParseResult.GetValueForOption(configFileOption); bool useGlobal = context.ParseResult.GetValueForOption(globalOption); - // Create local config by default, unless --global flag is used + // Determine config path string configPath = useGlobal ? Path.Combine(configDir, "a365.config.json") : Path.Combine(Environment.CurrentDirectory, "a365.config.json"); - if (!useGlobal) - { - logger.LogInformation("Initializing local configuration..."); - } - else + if (useGlobal) { Directory.CreateDirectory(configDir); - logger.LogInformation("Initializing global configuration..."); } - var configModelType = typeof(Models.Agent365Config); - Models.Agent365Config config; - + // If config file is specified, import it directly if (!string.IsNullOrEmpty(configFile)) { if (!File.Exists(configFile)) @@ -61,324 +62,102 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir) logger.LogError($"Config file '{configFile}' not found."); return; } - var json = await File.ReadAllTextAsync(configFile); - try - { - config = JsonSerializer.Deserialize(json) ?? new Models.Agent365Config(); - } - catch (Exception ex) - { - logger.LogError($"Failed to parse config file: {ex.Message}"); - return; - } - } - else - { - // Check for existing configuration to use as defaults - Models.Agent365Config? existingConfig = null; - var localConfigPath = Path.Combine(Environment.CurrentDirectory, "a365.config.json"); - var globalConfigPath = Path.Combine(configDir, "a365.config.json"); - bool hasExistingConfig = false; - // Try to load existing config (local first, then global) - if (File.Exists(localConfigPath)) - { - try - { - var existingJson = await File.ReadAllTextAsync(localConfigPath); - existingConfig = JsonSerializer.Deserialize(existingJson); - hasExistingConfig = true; - } - catch (Exception ex) - { - logger.LogWarning($"Could not parse existing local config: {ex.Message}"); - } - } - else if (File.Exists(globalConfigPath)) + try { - try - { - var existingJson = await File.ReadAllTextAsync(globalConfigPath); - existingConfig = JsonSerializer.Deserialize(existingJson); - hasExistingConfig = true; - } - catch (Exception ex) + var json = await File.ReadAllTextAsync(configFile); + var importedConfig = JsonSerializer.Deserialize(json); + + if (importedConfig == null) { - logger.LogWarning($"Could not parse existing global config: {ex.Message}"); + logger.LogError("Failed to parse config file."); + return; } - } - string PromptWithHelp(string prompt, string help, string? defaultValue = null, Func? validator = null) - { - // Validate default value and fix if needed - if (defaultValue != null && validator != null) + // Validate imported config + var errors = importedConfig.Validate(); + if (errors.Count > 0) { - var (isValidDefault, _) = validator(defaultValue); - if (!isValidDefault) + logger.LogError("Imported configuration is invalid:"); + foreach (var err in errors) { - defaultValue = null; // Clear invalid default, force user to enter valid value + logger.LogError($" {err}"); } + return; } + + // Save to target location + var outputJson = JsonSerializer.Serialize(importedConfig, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(configPath, outputJson); - // Section divider - Console.WriteLine("----------------------------------------------"); - Console.WriteLine($" {prompt}"); - Console.WriteLine("----------------------------------------------"); - - // Multi-line description - Console.WriteLine($"Description : {help}"); - Console.WriteLine(); - - // Current value display - if (defaultValue != null) + // Also save to global if saving locally + if (!useGlobal) { - Console.WriteLine($"Current Value: [{defaultValue}]"); + var globalConfigPath = Path.Combine(configDir, "a365.config.json"); + Directory.CreateDirectory(configDir); + await File.WriteAllTextAsync(globalConfigPath, outputJson); } - Console.WriteLine(); - string input; - do - { - Console.Write("> "); - input = Console.ReadLine()?.Trim() ?? ""; - - if (string.IsNullOrWhiteSpace(input) && defaultValue != null) - { - input = defaultValue; - } - - if (string.IsNullOrWhiteSpace(input)) - { - Console.WriteLine("This field is required. Please provide a value."); - Console.Write("> "); - continue; - } - - if (validator != null) - { - var (isValid, error) = validator(input); - if (!isValid) - { - Console.WriteLine(error); - Console.Write("> "); - continue; - } - } - - break; - } while (true); - - return input; + logger.LogInformation($"\nConfiguration imported to: {configPath}"); + return; + } + catch (Exception ex) + { + logger.LogError($"Failed to import config file: {ex.Message}"); + return; } + } - // Generate sensible defaults based on user environment or existing config - var userName = Environment.UserName.ToLowerInvariant(); - var timestamp = DateTime.Now.ToString("MMdd"); - - Console.WriteLine(); - Console.WriteLine("----------------------------------------------"); - Console.WriteLine(" Agent 365 CLI - Configuration Setup"); - Console.WriteLine("----------------------------------------------"); - Console.WriteLine(); - - if (hasExistingConfig) + // Load existing config if it exists + Agent365Config? existingConfig = null; + if (File.Exists(configPath)) + { + try { - Console.WriteLine("A configuration file already exists in this directory."); - Console.WriteLine("Press **Enter** to keep a current value, or type a new one to update it."); + var existingJson = await File.ReadAllTextAsync(configPath); + existingConfig = JsonSerializer.Deserialize(existingJson); + logger.LogDebug($"Loaded existing configuration from: {configPath}"); } - else + catch (Exception ex) { - Console.WriteLine("Setting up your Agent 365 CLI configuration."); - Console.WriteLine("Please provide the required configuration details below."); + logger.LogWarning($"Could not load existing config from {configPath}: {ex.Message}"); } - Console.WriteLine(); + } - config = new Models.Agent365Config + try + { + // Run the wizard with existing config + var config = await wizardService.RunWizardAsync(existingConfig); + + if (config != null) { - TenantId = PromptWithHelp( - "Azure Tenant ID", - "Your Azure Active Directory tenant identifier (GUID format).\n You can find this in the Azure Portal under:\n Azure Active Directory > Overview > Tenant ID", - existingConfig?.TenantId, - input => Guid.TryParse(input, out _) ? (true, "") : (false, "Must be a valid GUID format (e.g., 12345678-1234-1234-1234-123456789abc)") - ), - - SubscriptionId = PromptWithHelp( - "Azure Subscription ID", - "The Azure subscription where resources will be created.\n You can find this in the Azure Portal under:\n Subscriptions > [Your Subscription] > Overview > Subscription ID", - existingConfig?.SubscriptionId, - input => Guid.TryParse(input, out _) ? (true, "") : (false, "Must be a valid GUID format") - ), + // Save the configuration + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - ResourceGroup = PromptWithHelp( - "Resource Group Name", - "Azure resource group name for organizing related resources.\n Must be 1-90 characters, alphanumeric, periods, underscores, hyphens and parenthesis.", - existingConfig?.ResourceGroup ?? $"{userName}-agent365-rg" - ), + // Save to primary location (local or global based on flag) + await File.WriteAllTextAsync(configPath, json); - Location = PromptWithHelp( - "Azure Location", - "Azure region where resources will be deployed.\n Common options: eastus, westus2, centralus, westeurope, eastasia\n You can find all regions in the Azure Portal under:\n Create a resource > [Any service] > Basics > Region dropdown", - existingConfig?.Location ?? "eastus", - input => !string.IsNullOrWhiteSpace(input) ? (true, "") : (false, "Location cannot be empty") - ), - - AppServicePlanName = PromptWithHelp( - "App Service Plan Name", - "Name for the Azure App Service Plan that will host your agent web app.\n This defines the compute resources (CPU, memory) for your application.\n A new plan will be created if it doesn't exist.", - existingConfig?.AppServicePlanName ?? $"{userName}-agent365-plan" - ), - - WebAppName = PromptWithHelp( - "Web App Name", - "Globally unique name for your Azure Web App.\n This will be part of your agent's URL: https://.azurewebsites.net\n Must be unique across all Azure Web Apps worldwide.\n Only alphanumeric characters and hyphens allowed (no underscores).\n Cannot start or end with a hyphen. Maximum 60 characters.", - existingConfig?.WebAppName ?? $"{userName}-agent365-{timestamp}", - input => { - // Azure Web App naming rules: - // - 2-60 characters - // - Only alphanumeric and hyphens (NO underscores) - // - Cannot start or end with hyphen - // - Must be globally unique - - if (input.Length < 2 || input.Length > 60) - return (false, "Must be between 2-60 characters"); - - if (!System.Text.RegularExpressions.Regex.IsMatch(input, @"^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$")) - return (false, "Only alphanumeric characters and hyphens allowed (no underscores). Cannot start or end with a hyphen."); - - if (input.Contains("_")) - return (false, "Underscores are not allowed in Azure Web App names. Use hyphens (-) instead."); - - return (true, ""); - } - ), - - AgentIdentityDisplayName = PromptWithHelp( - "Agent Identity Display Name", - "Human-readable name for your agent identity.\n This will appear in Azure Active Directory and admin interfaces.\n Use a descriptive name to easily identify this agent.", - existingConfig?.AgentIdentityDisplayName ?? $"{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(userName)}'s Agent 365 Instance {timestamp}" - ), - - AgentUserPrincipalName = PromptWithHelp( - "Agent User Principal Name (UPN)", - "Email-like identifier for the agentic user in Azure AD.\n Format: @.onmicrosoft.com or @\n Example: demo.agent@contoso.onmicrosoft.com\n This must be unique in your tenant.", - existingConfig?.AgentUserPrincipalName ?? $"agent.{userName}@yourdomain.onmicrosoft.com", - input => { - // Basic email format validation - if (!input.Contains("@") || !input.Contains(".")) - return (false, "Must be a valid email-like format (e.g., user@domain.onmicrosoft.com)"); - - var parts = input.Split('@'); - if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) - return (false, "Invalid UPN format. Use: username@domain"); - - return (true, ""); - } - ), - - AgentUserDisplayName = PromptWithHelp( - "Agent User Display Name", - "Human-readable name for the agentic user.\n This will appear in Teams, Outlook, and other Microsoft 365 apps.\n Example: 'Demo Agent' or 'Support Bot'", - existingConfig?.AgentUserDisplayName ?? $"{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(userName)}'s Agent User" - ), + // Also save to global config directory for reuse + if (!useGlobal) + { + var globalConfigPath = Path.Combine(configDir, "a365.config.json"); + Directory.CreateDirectory(configDir); + await File.WriteAllTextAsync(globalConfigPath, json); + } - DeploymentProjectPath = PromptWithHelp( - "Deployment Project Path", - "Path to your agent project directory for deployment.\n This should contain your agent's source code and configuration files.\n The directory must exist and be accessible.\n You can use relative paths (e.g., ./my-agent) or absolute paths.", - existingConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, - input => { - try - { - var fullPath = Path.GetFullPath(input); - if (!Directory.Exists(fullPath)) - return (false, $"Directory does not exist: {fullPath}"); - return (true, ""); - } - catch (Exception ex) - { - return (false, $"Invalid path: {ex.Message}"); - } - } - ) - // AgentIdentityScopes and AgentApplicationScopes are read-only properties that return hardcoded defaults - }; - - Console.WriteLine(); - Console.WriteLine("Configuration setup completed successfully!"); - } - - // Validate config - var errors = config.Validate(); - if (errors.Count > 0) - { - logger.LogError("Configuration is invalid:"); - Console.WriteLine("Configuration is invalid:"); - foreach (var err in errors) - { - logger.LogError(" " + err); - Console.WriteLine(" " + err); + logger.LogInformation($"\nConfiguration saved to: {configPath}"); + logger.LogInformation("\nYou can now run:"); + logger.LogInformation(" a365 setup - Create Azure resources"); + logger.LogInformation(" a365 deploy - Deploy your agent"); } - logger.LogError("Aborted. Please fix the above errors and try again."); - Console.WriteLine("Aborted. Please fix the above errors and try again."); - return; - } - - // Re-validate before writing as a defensive check - var finalErrors = config.Validate(); - if (finalErrors.Count > 0) - { - logger.LogError("Configuration validation failed before writing. Aborting write."); - return; - } - - if (File.Exists(configPath)) - { - Console.Write($"Config file already exists at {configPath}. Overwrite? (y/N): "); - var answer = Console.ReadLine(); - if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase)) + else { - logger.LogInformation("Aborted by user. Config not overwritten."); - return; + logger.LogWarning("Configuration wizard cancelled."); } } - - // Serialize only static properties (init-only) to a365.config.json - var staticConfig = new - { - tenantId = config.TenantId, - subscriptionId = config.SubscriptionId, - resourceGroup = config.ResourceGroup, - location = config.Location, - appServicePlanName = config.AppServicePlanName, - appServicePlanSku = config.AppServicePlanSku, - webAppName = config.WebAppName, - agentIdentityDisplayName = config.AgentIdentityDisplayName, - agentBlueprintDisplayName = config.AgentBlueprintDisplayName, - agentUserPrincipalName = config.AgentUserPrincipalName, - agentUserDisplayName = config.AgentUserDisplayName, - managerEmail = config.ManagerEmail, - agentUserUsageLocation = config.AgentUserUsageLocation, - // agentIdentityScopes and agentApplicationScopes are hardcoded - not persisted to config file - deploymentProjectPath = config.DeploymentProjectPath, - agentDescription = config.AgentDescription, - // enableTeamsChannel, enableEmailChannel, enableGraphApiRegistration are hardcoded - not persisted to config file - mcpDefaultServers = config.McpDefaultServers - }; - - var options = new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - var configJson = JsonSerializer.Serialize(staticConfig, options); - await File.WriteAllTextAsync(configPath, configJson); - logger.LogInformation($"Config written to {configPath}"); - - // If imported from file, display the config - if (!string.IsNullOrEmpty(configFile)) + catch (Exception ex) { - var displayCmd = CreateDisplaySubcommand(logger, configDir); - await displayCmd.InvokeAsync(""); + logger.LogError(ex, "Failed to complete configuration: {Message}", ex.Message); } }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs index a9392cfa..b9b44b51 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs @@ -178,6 +178,7 @@ static JsonObject RequireObj(JsonObject parent, string prop) if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) { svcSettings["ClientId"] = pkgConfig.AgentBlueprintId; + svcSettings["AgentId"] = pkgConfig.AgentBlueprintId; } svcSettings["Scopes"] = new JsonArray(DEFAULT_SERVICE_CONNECTION_SCOPE); @@ -212,11 +213,14 @@ void Set(string key, string? value) var safe = $"{key}={EscapeEnv(value)}"; if (idx >= 0) lines[idx] = safe; else lines.Add(safe); + } + + // --- Service Connection --- + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) + { + Set("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", pkgConfig.AgentBlueprintId); + Set("AGENT_ID", pkgConfig.AgentBlueprintId); } - - // --- Service Connection --- - if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) - Set("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", pkgConfig.AgentBlueprintId); if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) Set("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", pkgConfig.AgentBlueprintClientSecret); if (!string.IsNullOrWhiteSpace(pkgConfig.TenantId)) @@ -256,8 +260,11 @@ void Set(string key, string? value) } // --- Service Connection --- - if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) - Set("connections__service_connection__settings__clientId", pkgConfig.AgentBlueprintId); + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) + { + Set("connections__service_connection__settings__clientId", pkgConfig.AgentBlueprintId); + Set("agent_id", pkgConfig.AgentBlueprintId); + } if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) Set("connections__service_connection__settings__clientSecret", pkgConfig.AgentBlueprintClientSecret); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs new file mode 100644 index 00000000..eef5951f --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Azure account information from Azure CLI +/// +public class AzureAccountInfo +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public AzureUser User { get; set; } = new(); + public string State { get; set; } = string.Empty; + public bool IsDefault { get; set; } +} + +/// +/// Azure user information +/// +public class AzureUser +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; +} + +/// +/// Azure resource group information +/// +public class AzureResourceGroup +{ + public string Name { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; +} + +/// +/// Azure app service plan information +/// +public class AzureAppServicePlan +{ + public string Name { get; set; } = string.Empty; + public string ResourceGroup { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string Sku { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; +} + +/// +/// Azure location information +/// +public class AzureLocation +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string RegionalDisplayName { get; set; } = string.Empty; +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs new file mode 100644 index 00000000..d2dc1cd0 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Contains derived names generated from an agent name during configuration initialization +/// +public class ConfigDerivedNames +{ + public string WebAppName { get; set; } = string.Empty; + public string AgentIdentityDisplayName { get; set; } = string.Empty; + public string AgentBlueprintDisplayName { get; set; } = string.Empty; + public string AgentUserPrincipalName { get; set; } = string.Empty; + public string AgentUserDisplayName { get; set; } = string.Empty; +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 6e98879d..405564b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -90,7 +90,8 @@ static async Task Main(string[] args) // Register ConfigCommand var configLoggerFactory = serviceProvider.GetRequiredService(); var configLogger = configLoggerFactory.CreateLogger("ConfigCommand"); - rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger)); + var wizardService = serviceProvider.GetRequiredService(); + rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService)); rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, executor)); rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, graphApiService)); @@ -219,6 +220,10 @@ private static void ConfigureServices(IServiceCollection services) // Register AzureWebAppCreator for SDK-based web app creation services.AddSingleton(); + + // Register Azure CLI service and Configuration Wizard + services.AddSingleton(); + services.AddSingleton(); } public static string GetDisplayVersion() diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs new file mode 100644 index 00000000..cf7a9e96 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +public class AzureCliService : IAzureCliService +{ + private readonly CommandExecutor _commandExecutor; + private readonly ILogger _logger; + + public AzureCliService(CommandExecutor commandExecutor, ILogger logger) + { + _commandExecutor = commandExecutor; + _logger = logger; + } + + public async Task IsLoggedInAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "account show", + suppressErrorLogging: true + ); + return result.Success; + } + catch (Exception ex) + { + _logger.LogDebug("Error checking Azure CLI login status: {Error}", ex.Message); + return false; + } + } + + public async Task GetCurrentAccountAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "account show --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to get Azure account information. Ensure you are logged in with 'az login'"); + return null; + } + + var accountJson = JsonSerializer.Deserialize(result.StandardOutput); + + return new AzureAccountInfo + { + Id = accountJson.GetProperty("id").GetString() ?? string.Empty, + Name = accountJson.GetProperty("name").GetString() ?? string.Empty, + TenantId = accountJson.GetProperty("tenantId").GetString() ?? string.Empty, + User = new AzureUser + { + Name = accountJson.GetProperty("user").GetProperty("name").GetString() ?? string.Empty, + Type = accountJson.GetProperty("user").GetProperty("type").GetString() ?? string.Empty + }, + State = accountJson.GetProperty("state").GetString() ?? string.Empty, + IsDefault = accountJson.GetProperty("isDefault").GetBoolean() + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Azure account information"); + return null; + } + } + + public async Task> ListResourceGroupsAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "group list --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to list resource groups"); + return new List(); + } + + var resourceGroupsJson = JsonSerializer.Deserialize(result.StandardOutput); + + return resourceGroupsJson?.Select(rg => new AzureResourceGroup + { + Name = rg.GetProperty("name").GetString() ?? string.Empty, + Location = rg.GetProperty("location").GetString() ?? string.Empty, + Id = rg.GetProperty("id").GetString() ?? string.Empty + }).OrderBy(rg => rg.Name).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing resource groups"); + return new List(); + } + } + + public async Task> ListAppServicePlansAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "appservice plan list --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to list app service plans"); + return new List(); + } + + var plansJson = JsonSerializer.Deserialize(result.StandardOutput); + + return plansJson?.Select(plan => new AzureAppServicePlan + { + Name = plan.GetProperty("name").GetString() ?? string.Empty, + ResourceGroup = plan.GetProperty("resourceGroup").GetString() ?? string.Empty, + Location = plan.GetProperty("location").GetString() ?? string.Empty, + Sku = plan.GetProperty("sku").GetProperty("name").GetString() ?? string.Empty, + Id = plan.GetProperty("id").GetString() ?? string.Empty + }).OrderBy(plan => plan.Name).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing app service plans"); + return new List(); + } + } + + public async Task> ListLocationsAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "account list-locations --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to list Azure locations"); + return new List(); + } + + var locationsJson = JsonSerializer.Deserialize(result.StandardOutput); + + return locationsJson?.Select(loc => new AzureLocation + { + Name = loc.GetProperty("name").GetString() ?? string.Empty, + DisplayName = loc.GetProperty("displayName").GetString() ?? string.Empty, + RegionalDisplayName = loc.TryGetProperty("regionalDisplayName", out var regional) + ? regional.GetString() ?? string.Empty + : string.Empty + }).OrderBy(loc => loc.DisplayName).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing Azure locations"); + return new List(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs new file mode 100644 index 00000000..7b70624a --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -0,0 +1,533 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for simplifying Agent 365 configuration initialization with smart defaults and Azure CLI integration +/// +public interface IConfigurationWizardService +{ + /// + /// Runs an interactive configuration wizard that minimizes user input by leveraging Azure CLI and smart defaults + /// + /// Existing configuration to use for defaults, if any + /// Configured Agent365Config instance + Task RunWizardAsync(Agent365Config? existingConfig = null); +} + +public class ConfigurationWizardService : IConfigurationWizardService +{ + private readonly IAzureCliService _azureCliService; + private readonly PlatformDetector _platformDetector; + private readonly ILogger _logger; + + public ConfigurationWizardService( + IAzureCliService azureCliService, + PlatformDetector platformDetector, + ILogger logger) + { + _azureCliService = azureCliService; + _platformDetector = platformDetector; + _logger = logger; + } + + public async Task RunWizardAsync(Agent365Config? existingConfig = null) + { + try + { + if (existingConfig != null) + { + _logger.LogDebug("Using existing configuration with deploymentProjectPath: {Path}", existingConfig.DeploymentProjectPath ?? "(null)"); + Console.WriteLine("Found existing configuration. Default values will be used where available."); + Console.WriteLine("Press **Enter** to keep a current value, or type a new one to update it."); + Console.WriteLine(); + } + + // Step 1: Verify Azure CLI login + if (!await VerifyAzureLoginAsync()) + { + return null; + } + + // Step 2: Get Azure account info + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + if (accountInfo == null) + { + Console.WriteLine("ERROR: Could not retrieve Azure account information. Please run 'az login' first."); + return null; + } + + Console.WriteLine($"Subscription ID: {accountInfo.Id} ({accountInfo.Name})"); + Console.WriteLine($"Tenant ID: {accountInfo.TenantId}"); + Console.WriteLine(); + Console.WriteLine("NOTE: Defaulted from current Azure account. To use a different Azure subscription, run 'az login' and then 'az account set --subscription ' before running this command."); + Console.WriteLine(); + + // Step 3: Get unique agent name + var agentName = PromptForAgentName(existingConfig); + if (string.IsNullOrWhiteSpace(agentName)) + { + Console.WriteLine("ERROR: Agent name is required. Configuration cancelled."); + return null; + } + + var derivedNames = GenerateDerivedNames(agentName); + + // Step 4: Validate deployment project path + var deploymentPath = await PromptForDeploymentPathAsync(existingConfig); + if (string.IsNullOrWhiteSpace(deploymentPath)) + { + return null; + } + + // Step 5: Select Resource Group + var resourceGroup = await PromptForResourceGroupAsync(existingConfig); + if (string.IsNullOrWhiteSpace(resourceGroup)) + { + return null; + } + + // Step 6: Select App Service Plan + var appServicePlan = await PromptForAppServicePlanAsync(existingConfig, resourceGroup); + if (string.IsNullOrWhiteSpace(appServicePlan)) + { + return null; + } + + // Step 7: Get manager email (required for agent creation) + var managerEmail = PromptForManagerEmail(existingConfig); + if (string.IsNullOrWhiteSpace(managerEmail)) + { + return null; + } + + // Step 8: Get location (with smart default from account or existing config) + var location = await PromptForLocationAsync(existingConfig, accountInfo); + + // Step 9: Show configuration summary and allow override + Console.WriteLine(); + Console.WriteLine("================================================================="); + Console.WriteLine(" Configuration Summary"); + Console.WriteLine("================================================================="); + Console.WriteLine($"Agent Name : {agentName}"); + Console.WriteLine($"Web App Name : {derivedNames.WebAppName}"); + Console.WriteLine($"Agent Identity Name : {derivedNames.AgentIdentityDisplayName}"); + Console.WriteLine($"Agent Blueprint Name : {derivedNames.AgentBlueprintDisplayName}"); + Console.WriteLine($"Agent UPN : {derivedNames.AgentUserPrincipalName}"); + Console.WriteLine($"Agent Display Name : {derivedNames.AgentUserDisplayName}"); + Console.WriteLine($"Manager Email : {managerEmail}"); + Console.WriteLine($"Deployment Path : {deploymentPath}"); + Console.WriteLine($"Resource Group : {resourceGroup}"); + Console.WriteLine($"App Service Plan : {appServicePlan}"); + Console.WriteLine($"Location : {location}"); + Console.WriteLine($"Subscription : {accountInfo.Name} ({accountInfo.Id})"); + Console.WriteLine($"Tenant : {accountInfo.TenantId}"); + Console.WriteLine(); + + // Step 10: Allow customization of derived names + var customizedNames = PromptForNameCustomization(derivedNames); + + // Step 11: Final confirmation to save configuration + Console.Write("Save this configuration? (Y/n): "); + var saveResponse = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (saveResponse == "n" || saveResponse == "no") + { + Console.WriteLine("Configuration cancelled."); + return null; + } + + // Step 12: Build final configuration + var config = new Agent365Config + { + TenantId = accountInfo.TenantId, + SubscriptionId = accountInfo.Id, + ResourceGroup = resourceGroup, + Location = location, + Environment = existingConfig?.Environment ?? "prod", // Default to prod, not asking for this + AppServicePlanName = appServicePlan, + AppServicePlanSku = existingConfig?.AppServicePlanSku ?? "B1", // Default to B1, not asking + WebAppName = customizedNames.WebAppName, + AgentIdentityDisplayName = customizedNames.AgentIdentityDisplayName, + AgentBlueprintDisplayName = customizedNames.AgentBlueprintDisplayName, + AgentUserPrincipalName = customizedNames.AgentUserPrincipalName, + AgentUserDisplayName = customizedNames.AgentUserDisplayName, + ManagerEmail = managerEmail, + AgentUserUsageLocation = GetUsageLocationFromAccount(accountInfo), + DeploymentProjectPath = deploymentPath, + AgentDescription = $"{agentName} - Agent 365 Demo Agent" + }; + + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during configuration wizard"); + Console.WriteLine($"ERROR: Configuration wizard failed: {ex.Message}"); + return null; + } + } + + private async Task VerifyAzureLoginAsync() + { + if (!await _azureCliService.IsLoggedInAsync()) + { + Console.WriteLine("ERROR: You are not logged in to Azure CLI."); + Console.WriteLine("Please run 'az login' and select your subscription, then try again."); + return false; + } + + return true; + } + + private string PromptForAgentName(Agent365Config? existingConfig) + { + string defaultName; + if (existingConfig != null) + { + defaultName = ExtractAgentNameFromConfig(existingConfig); + } + else + { + // Generate alphanumeric-only default + var username = System.Text.RegularExpressions.Regex.Replace(Environment.UserName, @"[^a-zA-Z0-9]", ""); + defaultName = $"{username}agent{DateTime.Now:MMdd}"; + } + + return PromptWithDefault( + "Agent name", + defaultName, + ValidateAgentName + ); + } + + private string ExtractAgentNameFromConfig(Agent365Config config) + { + // Try to extract a reasonable agent name from existing config + if (!string.IsNullOrEmpty(config.WebAppName)) + { + // Remove common suffixes and clean up + var name = config.WebAppName; + name = System.Text.RegularExpressions.Regex.Replace(name, @"(webapp|app|web|agent|bot)$", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + name = System.Text.RegularExpressions.Regex.Replace(name, @"[-_]", ""); // Remove all hyphens and underscores + name = System.Text.RegularExpressions.Regex.Replace(name, @"[^a-zA-Z0-9]", ""); // Remove any remaining non-alphanumeric + if (!string.IsNullOrWhiteSpace(name) && name.Length > 2 && char.IsLetter(name[0])) + { + return name; + } + } + + return $"agent{DateTime.Now:MMdd}"; + } + + private async Task PromptForDeploymentPathAsync(Agent365Config? existingConfig) + { + var defaultPath = existingConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory; + + await Task.CompletedTask; // Satisfy async requirement + var path = PromptWithDefault( + "Deployment project path", + defaultPath, + ValidateDeploymentPath + ); + + // Additional validation using PlatformDetector + if (!string.IsNullOrWhiteSpace(path)) + { + var platform = _platformDetector.Detect(path); + if (platform == ProjectPlatform.Unknown) + { + Console.WriteLine("WARNING: Could not detect a supported project type (.NET, Node.js, or Python) in the specified directory."); + Console.Write("Continue anyway? (y/N): "); + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (response != "y" && response != "yes") + { + return string.Empty; + } + } + else + { + Console.WriteLine($"Detected {platform} project"); + } + } + + return path; + } + + private async Task PromptForResourceGroupAsync(Agent365Config? existingConfig) + { + Console.WriteLine(); + Console.WriteLine("Loading resource groups from Azure..."); + + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + if (!resourceGroups.Any()) + { + Console.WriteLine("WARNING: No resource groups found. You may need to create one first."); + return PromptWithDefault( + "Resource group name", + existingConfig?.ResourceGroup ?? $"{Environment.UserName}-agent365-rg", + input => !string.IsNullOrWhiteSpace(input) ? (true, "") : (false, "Resource group name cannot be empty") + ); + } + + Console.WriteLine(); + Console.WriteLine("Available Resource Groups:"); + for (int i = 0; i < resourceGroups.Count; i++) + { + Console.WriteLine($"{i + 1:D2}. {resourceGroups[i].Name} ({resourceGroups[i].Location})"); + } + Console.WriteLine(); + + var defaultIndex = existingConfig?.ResourceGroup != null ? + resourceGroups.FindIndex(rg => rg.Name.Equals(existingConfig.ResourceGroup, StringComparison.OrdinalIgnoreCase)) + 1 : + 1; + + while (true) + { + Console.Write($"Select resource group [1-{resourceGroups.Count}] (default: {Math.Max(1, defaultIndex)}): "); + var input = Console.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + input = Math.Max(1, defaultIndex).ToString(); + } + + if (int.TryParse(input, out int index) && index >= 1 && index <= resourceGroups.Count) + { + return resourceGroups[index - 1].Name; + } + + Console.WriteLine($"Please enter a number between 1 and {resourceGroups.Count}"); + } + } + + private async Task PromptForAppServicePlanAsync(Agent365Config? existingConfig, string resourceGroup) + { + Console.WriteLine(); + Console.WriteLine("Loading app service plans from Azure..."); + + var allPlans = await _azureCliService.ListAppServicePlansAsync(); + var plansInRg = allPlans.Where(p => p.ResourceGroup.Equals(resourceGroup, StringComparison.OrdinalIgnoreCase)).ToList(); + + Console.WriteLine(); + if (plansInRg.Any()) + { + Console.WriteLine($"App Service Plans in {resourceGroup}:"); + for (int i = 0; i < plansInRg.Count; i++) + { + Console.WriteLine($"{i + 1:D2}. {plansInRg[i].Name} ({plansInRg[i].Sku}, {plansInRg[i].Location})"); + } + Console.WriteLine($"{plansInRg.Count + 1:D2}. Create new app service plan"); + Console.WriteLine(); + + var defaultIndex = existingConfig?.AppServicePlanName != null ? + plansInRg.FindIndex(p => p.Name.Equals(existingConfig.AppServicePlanName, StringComparison.OrdinalIgnoreCase)) + 1 : + plansInRg.Count + 1; // Default to creating new + + while (true) + { + Console.Write($"Select option [1-{plansInRg.Count + 1}] (default: {Math.Max(1, defaultIndex)}): "); + var input = Console.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + input = Math.Max(1, defaultIndex).ToString(); + } + + if (int.TryParse(input, out int index)) + { + if (index >= 1 && index <= plansInRg.Count) + { + return plansInRg[index - 1].Name; + } + else if (index == plansInRg.Count + 1) + { + // Create new plan name + return $"{Environment.UserName}-agent365-plan"; + } + } + + Console.WriteLine($"Please enter a number between 1 and {plansInRg.Count + 1}"); + } + } + else + { + Console.WriteLine($"No existing app service plans found in {resourceGroup}. A new plan will be created."); + return existingConfig?.AppServicePlanName ?? $"{Environment.UserName}-agent365-plan"; + } + } + + private string PromptForManagerEmail(Agent365Config? existingConfig) + { + return PromptWithDefault( + "Manager email", + existingConfig?.ManagerEmail ?? "", + ValidateEmail + ); + } + + private async Task PromptForLocationAsync(Agent365Config? existingConfig, AzureAccountInfo accountInfo) + { + // Try to get a smart default location + var defaultLocation = existingConfig?.Location; + + if (string.IsNullOrEmpty(defaultLocation)) + { + // Try to get from resource group or common defaults + defaultLocation = "eastus"; // Conservative default + } + + await Task.CompletedTask; // Satisfy async requirement + return PromptWithDefault( + "Azure location", + defaultLocation, + input => !string.IsNullOrWhiteSpace(input) ? (true, "") : (false, "Location cannot be empty") + ); + } + + private ConfigDerivedNames GenerateDerivedNames(string agentName) + { + var cleanName = System.Text.RegularExpressions.Regex.Replace(agentName, @"[^a-zA-Z0-9]", "").ToLowerInvariant(); + var timestamp = DateTime.Now.ToString("MMddHHmm"); + var userName = Environment.UserName.ToLowerInvariant(); + + return new ConfigDerivedNames + { + WebAppName = $"{cleanName}-webapp-{timestamp}", + AgentIdentityDisplayName = $"{agentName} Identity", + AgentBlueprintDisplayName = $"{agentName} Blueprint", + AgentUserPrincipalName = $"agent.{cleanName}.{timestamp}@yourdomain.onmicrosoft.com", + AgentUserDisplayName = $"{agentName} Agent User" + }; + } + + private ConfigDerivedNames PromptForNameCustomization(ConfigDerivedNames defaultNames) + { + Console.Write("Would you like to customize the generated names? (y/N): "); + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (response != "y" && response != "yes") + { + return defaultNames; + } + + Console.WriteLine(); + Console.WriteLine("Customizing generated names (press Enter to keep default):"); + + return new ConfigDerivedNames + { + WebAppName = PromptWithDefault("Web app name", defaultNames.WebAppName, ValidateWebAppName), + AgentIdentityDisplayName = PromptWithDefault("Agent identity name", defaultNames.AgentIdentityDisplayName), + AgentBlueprintDisplayName = PromptWithDefault("Agent blueprint name", defaultNames.AgentBlueprintDisplayName), + AgentUserPrincipalName = PromptWithDefault("Agent UPN", defaultNames.AgentUserPrincipalName, ValidateEmail), + AgentUserDisplayName = PromptWithDefault("Agent display name", defaultNames.AgentUserDisplayName) + }; + } + + private string PromptWithDefault( + string prompt, + string defaultValue = "", + Func? validator = null) + { + // Azure CLI style: "Prompt [default]: " + while (true) + { + if (!string.IsNullOrEmpty(defaultValue)) + { + Console.Write($"{prompt} [{defaultValue}]: "); + } + else + { + Console.Write($"{prompt}: "); + } + + var input = Console.ReadLine()?.Trim() ?? ""; + + if (string.IsNullOrWhiteSpace(input) && !string.IsNullOrEmpty(defaultValue)) + { + input = defaultValue; + } + + if (string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine("ERROR: This field is required."); + continue; + } + + if (validator != null) + { + var (isValid, error) = validator(input); + if (!isValid) + { + Console.WriteLine($"ERROR: {error}"); + continue; + } + } + + return input; + } + } + + private static (bool isValid, string error) ValidateAgentName(string input) + { + if (input.Length < 2 || input.Length > 50) + return (false, "Agent name must be between 2-50 characters"); + + if (!System.Text.RegularExpressions.Regex.IsMatch(input, @"^[a-zA-Z][a-zA-Z0-9]*$")) + return (false, "Agent name must start with a letter and contain only letters and numbers (no special characters for cross-platform compatibility)"); + + return (true, ""); + } + + private (bool isValid, string error) ValidateDeploymentPath(string input) + { + try + { + var fullPath = Path.GetFullPath(input); + if (!Directory.Exists(fullPath)) + return (false, $"Directory does not exist: {fullPath}"); + return (true, ""); + } + catch (Exception ex) + { + return (false, $"Invalid path: {ex.Message}"); + } + } + + private static (bool isValid, string error) ValidateWebAppName(string input) + { + if (input.Length < 2 || input.Length > 60) + return (false, "Must be between 2-60 characters"); + + if (!System.Text.RegularExpressions.Regex.IsMatch(input, @"^[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9]$")) + return (false, "Only alphanumeric characters and hyphens allowed. Cannot start or end with a hyphen."); + + if (input.Contains("_")) + return (false, "Underscores are not allowed in Azure Web App names. Use hyphens (-) instead."); + + return (true, ""); + } + + private static (bool isValid, string error) ValidateEmail(string input) + { + if (!input.Contains("@") || !input.Contains(".")) + return (false, "Must be a valid email format"); + + var parts = input.Split('@'); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) + return (false, "Invalid email format. Use: username@domain"); + + return (true, ""); + } + + private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo) + { + // Default to US for now - could be enhanced to detect from account location + return "US"; + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs new file mode 100644 index 00000000..ebda1b0f --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for interacting with Azure CLI to fetch account information, resource groups, and other Azure data +/// +public interface IAzureCliService +{ + /// + /// Gets the current Azure account information from Azure CLI + /// + /// Current Azure account info or null if not logged in + Task GetCurrentAccountAsync(); + + /// + /// Lists all resource groups in the current subscription + /// + /// List of resource groups + Task> ListResourceGroupsAsync(); + + /// + /// Lists all app service plans in the current subscription + /// + /// List of app service plans + Task> ListAppServicePlansAsync(); + + /// + /// Lists all available Azure locations + /// + /// List of available locations + Task> ListLocationsAsync(); + + /// + /// Checks if Azure CLI is available and user is logged in + /// + /// True if Azure CLI is available and logged in + Task IsLoggedInAsync(); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs index 226aef60..76cc9e51 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs @@ -1,487 +1,545 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.CommandLine; -using System.IO; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.Agents.A365.DevTools.Cli.Commands; -using Microsoft.Agents.A365.DevTools.Cli.Models; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; - -/// -/// Tests for ConfigCommand. -/// These tests are run sequentially (not in parallel) because they interact with shared global state -/// in %LocalAppData% (Windows) or ~/.config (Linux/Mac). -/// -[Collection("ConfigTests")] -public class ConfigCommandTests -{ - // Use NullLoggerFactory instead of console logger to avoid I/O bottleneck during test runs - private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; - - private string GetTestConfigDir() - { - var dir = Path.Combine(Path.GetTempPath(), "a365_cli_tests", Guid.NewGuid().ToString()); - return dir; - } - - - - [Fact(Skip = "Disabled due to System.CommandLine invocation overhead when running full test suite")] - public async Task Init_ValidConfigFile_IsAcceptedAndSaved() - { - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - var configPath = Path.Combine(configDir, "a365.config.json"); - - var validConfig = new Agent365Config - { - TenantId = "12345678-1234-1234-1234-123456789012", - SubscriptionId = "87654321-4321-4321-4321-210987654321", - ResourceGroup = "rg-test", - Location = "eastus", - AppServicePlanName = "asp-test", - WebAppName = "webapp-test", - AgentIdentityDisplayName = "Test Agent" - // AgentIdentityScopes and AgentApplicationScopes are now hardcoded - }; - var importPath = Path.Combine(configDir, "import.json"); - await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(validConfig)); - - var originalOut = Console.Out; - using var outputWriter = new StringWriter(); - try - { - Console.SetOut(outputWriter); - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync($"config init -c \"{importPath}\""); - Assert.Equal(0, result); - Assert.True(File.Exists(configPath)); - var json = File.ReadAllText(configPath); - Assert.Contains("12345678-1234-1234-1234-123456789012", json); - } - finally - { - Console.SetOut(originalOut); - if (Directory.Exists(configDir)) Directory.Delete(configDir, true); - } - } - - [Fact] - public async Task Init_InvalidConfigFile_IsRejectedAndShowsError() - { - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - var configPath = Path.Combine(configDir, "a365.config.json"); - - // Missing required fields - var invalidConfig = new Agent365Config(); - var importPath = Path.Combine(configDir, "import_invalid.json"); - await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(invalidConfig)); - - var originalOut = Console.Out; - using var outputWriter = new StringWriter(); - try - { - Console.SetOut(outputWriter); - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync($"config init -c \"{importPath}\""); - Assert.Equal(0, result); - Assert.False(File.Exists(configPath)); - var output = outputWriter.ToString(); - Assert.Contains("Configuration is invalid", output); - Assert.Contains("tenantId is required", output, StringComparison.OrdinalIgnoreCase); - } - finally - { - Console.SetOut(originalOut); - if (Directory.Exists(configDir)) Directory.Delete(configDir, true); - } - } - - [Fact] - public void GetDefaultConfigDirectory_Windows_ReturnsAppData() - { - // This test validates the Windows path is correct - // Actual path will vary by machine, so we just check structure - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - - // Should contain LocalAppData path or fall back to current directory - Assert.True(result.Contains("Microsoft.Agents.A365.DevTools.Cli") || - result == Environment.CurrentDirectory); - } - } - - [Fact] - public void GetDefaultConfigDirectory_Linux_ReturnsXdgPath() - { - // This test validates XDG compliance on Linux - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - - // Should be XDG_CONFIG_HOME/a365 or ~/.config/a365 or current directory - Assert.True(result.EndsWith("a365") || result == Environment.CurrentDirectory); - } - } - - [Fact] - public async Task Display_WithGeneratedFlag_ShowsGeneratedConfig() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - - // Create minimal static config (required by LoadAsync) - var staticConfigPath = Path.Combine(configDir, "a365.config.json"); - var minimalStaticConfig = new - { - tenantId = "12345678-1234-1234-1234-123456789012", - subscriptionId = "87654321-4321-4321-4321-210987654321", - resourceGroup = "test-rg", - location = "eastus", - appServicePlanName = "test-plan", - webAppName = "test-app", - agentIdentityDisplayName = "Test Agent", - deploymentProjectPath = configDir - }; - await File.WriteAllTextAsync(staticConfigPath, JsonSerializer.Serialize(minimalStaticConfig)); - - // Create generated config - var generatedConfigPath = Path.Combine(configDir, "a365.generated.config.json"); - var generatedContent = "{\"agentBlueprintId\":\"generated-123\",\"AgenticUserId\":\"user-456\",\"completed\":true}"; - await File.WriteAllTextAsync(generatedConfigPath, generatedContent); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Console.SetOut(outputWriter); - Environment.CurrentDirectory = configDir; // Set working directory to test dir - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display --generated"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("generated-123", output); - Assert.Contains("user-456", output); - Assert.Contains("true", output); - } - finally - { - Console.SetOut(originalOut); - Environment.CurrentDirectory = originalDir; - - // Cleanup with retry to avoid file locking issues - await CleanupTestDirectoryAsync(configDir); - } - } - - [Fact] - public async Task Display_PrefersLocalConfigOverGlobal() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); // Global config dir - var localDir = GetTestConfigDir(); // Local config dir - Directory.CreateDirectory(configDir); - Directory.CreateDirectory(localDir); - - // Create global config - var globalConfigPath = Path.Combine(configDir, "a365.config.json"); - var globalConfig = new - { - tenantId = "11111111-1111-1111-1111-111111111111", - subscriptionId = "22222222-2222-2222-2222-222222222222", - resourceGroup = "global-rg", - location = "eastus", - appServicePlanName = "global-plan", - webAppName = "global-app", - agentIdentityDisplayName = "Global Agent" - }; - await File.WriteAllTextAsync(globalConfigPath, JsonSerializer.Serialize(globalConfig)); - - // Create local config (should take precedence) - var localConfigPath = Path.Combine(localDir, "a365.config.json"); - var localConfig = new - { - tenantId = "33333333-3333-3333-3333-333333333333", - subscriptionId = "44444444-4444-4444-4444-444444444444", - resourceGroup = "local-rg", - location = "eastus", - appServicePlanName = "local-plan", - webAppName = "local-app", - agentIdentityDisplayName = "Local Agent" - }; - await File.WriteAllTextAsync(localConfigPath, JsonSerializer.Serialize(localConfig)); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Environment.CurrentDirectory = localDir; - Console.SetOut(outputWriter); - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("33333333-3333-3333-3333-333333333333", output); - Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", output); - } - finally - { - Environment.CurrentDirectory = originalDir; - Console.SetOut(originalOut); - - // Cleanup with retry - await CleanupTestDirectoryAsync(configDir); - await CleanupTestDirectoryAsync(localDir); - } - } - - [Fact] - public async Task Display_WithGeneratedFlag_ShowsOnlyGeneratedConfig() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - - // Create static config (required by LoadAsync) - var configPath = Path.Combine(configDir, "a365.config.json"); - var minimalStaticConfig = new - { - tenantId = "12345678-1234-1234-1234-123456789012", - subscriptionId = "87654321-4321-4321-4321-210987654321", - resourceGroup = "test-rg", - location = "eastus", - appServicePlanName = "test-plan", - webAppName = "test-app", - agentIdentityDisplayName = "Test Agent", - deploymentProjectPath = configDir - }; - await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); - - // Create generated config - var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); - await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-123\"}"); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Environment.CurrentDirectory = configDir; - Console.SetOut(outputWriter); - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display --generated"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("generated-id-123", output); - Assert.DoesNotContain("12345678-1234-1234-1234-123456789012", output); - } - finally - { - Environment.CurrentDirectory = originalDir; - Console.SetOut(originalOut); - - // Cleanup with retry - await CleanupTestDirectoryAsync(configDir); - } - } - - [Fact] - public async Task Display_WithAllFlag_ShowsBothConfigs() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - - // Create static config with required fields - var configPath = Path.Combine(configDir, "a365.config.json"); - var minimalStaticConfig = new - { - tenantId = "12345678-1234-1234-1234-123456789012", - subscriptionId = "87654321-4321-4321-4321-210987654321", - resourceGroup = "test-rg", - location = "eastus", - appServicePlanName = "test-plan", - webAppName = "test-app", - agentIdentityDisplayName = "Test Agent", - deploymentProjectPath = configDir - }; - await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); - - // Create generated config - var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); - await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-456\"}"); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Environment.CurrentDirectory = configDir; - Console.SetOut(outputWriter); - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display --all"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("Static Configuration", output); - Assert.Contains("Generated Configuration", output); - Assert.Contains("12345678-1234-1234-1234-123456789012", output); - Assert.Contains("generated-id-456", output); - } - finally - { - Environment.CurrentDirectory = originalDir; - Console.SetOut(originalOut); - - // Cleanup with retry - await CleanupTestDirectoryAsync(configDir); - } - } - - /// - /// Helper method to clean up test directories with retry logic to handle file locking. - /// Prevents flaky test failures in CI pipelines. - /// - private static async Task CleanupTestDirectoryAsync(string directory) - { - if (!Directory.Exists(directory)) - return; - - const int maxRetries = 5; - const int delayMs = 200; - - for (int i = 0; i < maxRetries; i++) - { - try - { - // Force garbage collection and finalization to release file handles - if (i > 0) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - await Task.Delay(delayMs); - } - - Directory.Delete(directory, true); - return; // Success - } - catch (IOException) when (i < maxRetries - 1) - { - // Retry on IOException (file locked) - continue; - } - catch (UnauthorizedAccessException) when (i < maxRetries - 1) - { - // Retry on access denied (file in use) - continue; - } - } - - // If still failing after retries, log but don't fail the test - // The temp directory will be cleaned up by the OS eventually - Console.WriteLine($"Warning: Could not delete test directory {directory} after {maxRetries} attempts. Directory may be cleaned up later."); - } - - [Fact] - public void GetDefaultConfigDirectory_Windows_ReturnsLocalAppData() - { - // Arrange - only run on Windows - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; // Skip on non-Windows - } - - // Act - var configDir = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - - // Assert - Assert.NotNull(configDir); - Assert.Contains("Microsoft.Agents.A365.DevTools.Cli", configDir); - } - - [Fact] - public void GetDefaultConfigDirectory_Linux_UsesXdgPath() - { - // Arrange - only run on Linux/Mac - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; // Skip on Windows - } - - // Save original environment - var originalXdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - var originalHome = Environment.GetEnvironmentVariable("HOME"); - - try - { - // Test 1: XDG_CONFIG_HOME is set - Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", "/custom/config"); - var configDir1 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - Assert.Equal("/custom/config/a365", configDir1); - - // Test 2: XDG_CONFIG_HOME not set, HOME is set (default ~/.config/a365) - Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", null); - Environment.SetEnvironmentVariable("HOME", "/home/testuser"); - var configDir2 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - Assert.Equal("/home/testuser/.config/a365", configDir2); - } - finally - { - // Restore original environment - Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", originalXdg); - Environment.SetEnvironmentVariable("HOME", originalHome); - } - } -} - -/// -/// Test collection definition that disables parallel execution for config tests. -/// Config tests must run sequentially because they sync files to a shared global directory -/// (%LocalAppData%\Microsoft.Agents.A365.DevTools.Cli on Windows or ~/.config/a365 on Linux/Mac). -/// Running in parallel would cause race conditions and file locking issues. -/// -[CollectionDefinition("ConfigTests", DisableParallelization = true)] -public class ConfigTestCollection -{ - // This class is never instantiated. It exists only to define the collection. -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for ConfigCommand. +/// These tests are run sequentially (not in parallel) because they interact with shared global state +/// in %LocalAppData% (Windows) or ~/.config (Linux/Mac). +/// +[Collection("ConfigTests")] +public class ConfigCommandTests +{ + // Use NullLoggerFactory instead of console logger to avoid I/O bottleneck during test runs + private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; + private readonly IConfigurationWizardService _mockWizardService; + + public ConfigCommandTests() + { + // Create a mock wizard service that never actually runs (for import-only tests) + _mockWizardService = Substitute.For(); + } + + private string GetTestConfigDir() + { + var dir = Path.Combine(Path.GetTempPath(), "a365_cli_tests", Guid.NewGuid().ToString()); + return dir; + } + + + + [Fact(Skip = "Disabled due to System.CommandLine invocation overhead when running full test suite")] + public async Task Init_ValidConfigFile_IsAcceptedAndSaved() + { + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "a365.config.json"); + + var validConfig = new Agent365Config + { + TenantId = "12345678-1234-1234-1234-123456789012", + SubscriptionId = "87654321-4321-4321-4321-210987654321", + ResourceGroup = "rg-test", + Location = "eastus", + AppServicePlanName = "asp-test", + WebAppName = "webapp-test", + AgentIdentityDisplayName = "Test Agent" + // AgentIdentityScopes and AgentApplicationScopes are now hardcoded + }; + var importPath = Path.Combine(configDir, "import.json"); + await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(validConfig)); + + var originalOut = Console.Out; + using var outputWriter = new StringWriter(); + try + { + Console.SetOut(outputWriter); + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync($"config init -c \"{importPath}\""); + Assert.Equal(0, result); + Assert.True(File.Exists(configPath)); + var json = File.ReadAllText(configPath); + Assert.Contains("12345678-1234-1234-1234-123456789012", json); + } + finally + { + Console.SetOut(originalOut); + if (Directory.Exists(configDir)) Directory.Delete(configDir, true); + } + } + + [Fact] + public async Task Init_InvalidConfigFile_IsRejectedAndShowsError() + { + // Create a logger that captures output to a string + var logMessages = new List(); + var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddProvider(new TestLoggerProvider(logMessages)); + builder.SetMinimumLevel(LogLevel.Debug); + }); + var logger = loggerFactory.CreateLogger("Test"); + + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "a365.config.json"); + + // Missing required fields + var invalidConfig = new Agent365Config(); + var importPath = Path.Combine(configDir, "import_invalid.json"); + await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(invalidConfig)); + + try + { + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync($"config init -c \"{importPath}\""); + Assert.Equal(0, result); + Assert.False(File.Exists(configPath)); + + // Check log messages instead of console output + var allLogs = string.Join("\n", logMessages); + Assert.Contains("Imported configuration is invalid", allLogs); + Assert.Contains("tenantId is required", allLogs, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(configDir)) Directory.Delete(configDir, true); + } + } + + [Fact] + public void GetDefaultConfigDirectory_Windows_ReturnsAppData() + { + // This test validates the Windows path is correct + // Actual path will vary by machine, so we just check structure + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + + // Should contain LocalAppData path or fall back to current directory + Assert.True(result.Contains("Microsoft.Agents.A365.DevTools.Cli") || + result == Environment.CurrentDirectory); + } + } + + [Fact] + public void GetDefaultConfigDirectory_Linux_ReturnsXdgPath() + { + // This test validates XDG compliance on Linux + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + + // Should be XDG_CONFIG_HOME/a365 or ~/.config/a365 or current directory + Assert.True(result.EndsWith("a365") || result == Environment.CurrentDirectory); + } + } + + [Fact] + public async Task Display_WithGeneratedFlag_ShowsGeneratedConfig() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + + // Create minimal static config (required by LoadAsync) + var staticConfigPath = Path.Combine(configDir, "a365.config.json"); + var minimalStaticConfig = new + { + tenantId = "12345678-1234-1234-1234-123456789012", + subscriptionId = "87654321-4321-4321-4321-210987654321", + resourceGroup = "test-rg", + location = "eastus", + appServicePlanName = "test-plan", + webAppName = "test-app", + agentIdentityDisplayName = "Test Agent", + deploymentProjectPath = configDir + }; + await File.WriteAllTextAsync(staticConfigPath, JsonSerializer.Serialize(minimalStaticConfig)); + + // Create generated config + var generatedConfigPath = Path.Combine(configDir, "a365.generated.config.json"); + var generatedContent = "{\"agentBlueprintId\":\"generated-123\",\"AgenticUserId\":\"user-456\",\"completed\":true}"; + await File.WriteAllTextAsync(generatedConfigPath, generatedContent); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Console.SetOut(outputWriter); + Environment.CurrentDirectory = configDir; // Set working directory to test dir + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display --generated"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("generated-123", output); + Assert.Contains("user-456", output); + Assert.Contains("true", output); + } + finally + { + Console.SetOut(originalOut); + Environment.CurrentDirectory = originalDir; + + // Cleanup with retry to avoid file locking issues + await CleanupTestDirectoryAsync(configDir); + } + } + + [Fact] + public async Task Display_PrefersLocalConfigOverGlobal() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); // Global config dir + var localDir = GetTestConfigDir(); // Local config dir + Directory.CreateDirectory(configDir); + Directory.CreateDirectory(localDir); + + // Create global config + var globalConfigPath = Path.Combine(configDir, "a365.config.json"); + var globalConfig = new + { + tenantId = "11111111-1111-1111-1111-111111111111", + subscriptionId = "22222222-2222-2222-2222-222222222222", + resourceGroup = "global-rg", + location = "eastus", + appServicePlanName = "global-plan", + webAppName = "global-app", + agentIdentityDisplayName = "Global Agent" + }; + await File.WriteAllTextAsync(globalConfigPath, JsonSerializer.Serialize(globalConfig)); + + // Create local config (should take precedence) + var localConfigPath = Path.Combine(localDir, "a365.config.json"); + var localConfig = new + { + tenantId = "33333333-3333-3333-3333-333333333333", + subscriptionId = "44444444-4444-4444-4444-444444444444", + resourceGroup = "local-rg", + location = "eastus", + appServicePlanName = "local-plan", + webAppName = "local-app", + agentIdentityDisplayName = "Local Agent" + }; + await File.WriteAllTextAsync(localConfigPath, JsonSerializer.Serialize(localConfig)); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Environment.CurrentDirectory = localDir; + Console.SetOut(outputWriter); + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("33333333-3333-3333-3333-333333333333", output); + Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", output); + } + finally + { + Environment.CurrentDirectory = originalDir; + Console.SetOut(originalOut); + + // Cleanup with retry + await CleanupTestDirectoryAsync(configDir); + await CleanupTestDirectoryAsync(localDir); + } + } + + [Fact] + public async Task Display_WithGeneratedFlag_ShowsOnlyGeneratedConfig() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + + // Create static config (required by LoadAsync) + var configPath = Path.Combine(configDir, "a365.config.json"); + var minimalStaticConfig = new + { + tenantId = "12345678-1234-1234-1234-123456789012", + subscriptionId = "87654321-4321-4321-4321-210987654321", + resourceGroup = "test-rg", + location = "eastus", + appServicePlanName = "test-plan", + webAppName = "test-app", + agentIdentityDisplayName = "Test Agent", + deploymentProjectPath = configDir + }; + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); + + // Create generated config + var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); + await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-123\"}"); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Environment.CurrentDirectory = configDir; + Console.SetOut(outputWriter); + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display --generated"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("generated-id-123", output); + Assert.DoesNotContain("12345678-1234-1234-1234-123456789012", output); + } + finally + { + Environment.CurrentDirectory = originalDir; + Console.SetOut(originalOut); + + // Cleanup with retry + await CleanupTestDirectoryAsync(configDir); + } + } + + [Fact] + public async Task Display_WithAllFlag_ShowsBothConfigs() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + + // Create static config with required fields + var configPath = Path.Combine(configDir, "a365.config.json"); + var minimalStaticConfig = new + { + tenantId = "12345678-1234-1234-1234-123456789012", + subscriptionId = "87654321-4321-4321-4321-210987654321", + resourceGroup = "test-rg", + location = "eastus", + appServicePlanName = "test-plan", + webAppName = "test-app", + agentIdentityDisplayName = "Test Agent", + deploymentProjectPath = configDir + }; + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); + + // Create generated config + var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); + await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-456\"}"); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Environment.CurrentDirectory = configDir; + Console.SetOut(outputWriter); + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display --all"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("Static Configuration", output); + Assert.Contains("Generated Configuration", output); + Assert.Contains("12345678-1234-1234-1234-123456789012", output); + Assert.Contains("generated-id-456", output); + } + finally + { + Environment.CurrentDirectory = originalDir; + Console.SetOut(originalOut); + + // Cleanup with retry + await CleanupTestDirectoryAsync(configDir); + } + } + + /// + /// Helper method to clean up test directories with retry logic to handle file locking. + /// Prevents flaky test failures in CI pipelines. + /// + private static async Task CleanupTestDirectoryAsync(string directory) + { + if (!Directory.Exists(directory)) + return; + + const int maxRetries = 5; + const int delayMs = 200; + + for (int i = 0; i < maxRetries; i++) + { + try + { + // Force garbage collection and finalization to release file handles + if (i > 0) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + await Task.Delay(delayMs); + } + + Directory.Delete(directory, true); + return; // Success + } + catch (IOException) when (i < maxRetries - 1) + { + // Retry on IOException (file locked) + continue; + } + catch (UnauthorizedAccessException) when (i < maxRetries - 1) + { + // Retry on access denied (file in use) + continue; + } + } + + // If still failing after retries, log but don't fail the test + // The temp directory will be cleaned up by the OS eventually + Console.WriteLine($"Warning: Could not delete test directory {directory} after {maxRetries} attempts. Directory may be cleaned up later."); + } + + [Fact] + public void GetDefaultConfigDirectory_Windows_ReturnsLocalAppData() + { + // Arrange - only run on Windows + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; // Skip on non-Windows + } + + // Act + var configDir = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + + // Assert + Assert.NotNull(configDir); + Assert.Contains("Microsoft.Agents.A365.DevTools.Cli", configDir); + } + + [Fact] + public void GetDefaultConfigDirectory_Linux_UsesXdgPath() + { + // Arrange - only run on Linux/Mac + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; // Skip on Windows + } + + // Save original environment + var originalXdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try + { + // Test 1: XDG_CONFIG_HOME is set + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", "/custom/config"); + var configDir1 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + Assert.Equal("/custom/config/a365", configDir1); + + // Test 2: XDG_CONFIG_HOME not set, HOME is set (default ~/.config/a365) + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", null); + Environment.SetEnvironmentVariable("HOME", "/home/testuser"); + var configDir2 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + Assert.Equal("/home/testuser/.config/a365", configDir2); + } + finally + { + // Restore original environment + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", originalXdg); + Environment.SetEnvironmentVariable("HOME", originalHome); + } + } +} + +/// +/// Test collection definition that disables parallel execution for config tests. +/// Config tests must run sequentially because they sync files to a shared global directory +/// (%LocalAppData%\Microsoft.Agents.A365.DevTools.Cli on Windows or ~/.config/a365 on Linux/Mac). +/// Running in parallel would cause race conditions and file locking issues. +/// +[CollectionDefinition("ConfigTests", DisableParallelization = true)] +public class ConfigTestCollection +{ + // This class is never instantiated. It exists only to define the collection. +} + +/// +/// Test logger provider that captures log messages to a list +/// +internal class TestLoggerProvider : ILoggerProvider +{ + private readonly List _logMessages; + + public TestLoggerProvider(List logMessages) + { + _logMessages = logMessages; + } + + public ILogger CreateLogger(string categoryName) + { + return new TestLogger(_logMessages); + } + + public void Dispose() { } +} + +/// +/// Test logger that captures messages to a list +/// +internal class TestLogger : ILogger +{ + private readonly List _logMessages; + + public TestLogger(List logMessages) + { + _logMessages = logMessages; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + _logMessages.Add($"[{logLevel}] {message}"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs new file mode 100644 index 00000000..2b84dd4e --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +public class AzureCliServiceTests +{ + private readonly ILogger _logger; + private readonly CommandExecutor _commandExecutor; + private readonly AzureCliService _azureCliService; + + public AzureCliServiceTests() + { + _logger = Substitute.For>(); + _commandExecutor = Substitute.For(Substitute.For>()); + _azureCliService = new AzureCliService(_commandExecutor, _logger); + } + + [Fact] + public async Task IsLoggedInAsync_WhenAzureCliReturnsSuccess_ReturnsTrue() + { + // Arrange + var result = new CommandResult { ExitCode = 0, StandardOutput = "" }; + _commandExecutor.ExecuteAsync("az", "account show", suppressErrorLogging: true) + .Returns(Task.FromResult(result)); + + // Act + var isLoggedIn = await _azureCliService.IsLoggedInAsync(); + + // Assert + isLoggedIn.Should().BeTrue(); + } + + [Fact] + public async Task IsLoggedInAsync_WhenAzureCliFails_ReturnsFalse() + { + // Arrange + var result = new CommandResult { ExitCode = 1, StandardError = "ERROR: Please run 'az login'" }; + _commandExecutor.ExecuteAsync("az", "account show", suppressErrorLogging: true) + .Returns(Task.FromResult(result)); + + // Act + var isLoggedIn = await _azureCliService.IsLoggedInAsync(); + + // Assert + isLoggedIn.Should().BeFalse(); + } + + [Fact] + public async Task IsLoggedInAsync_WhenExceptionThrown_ReturnsFalse() + { + // Arrange + _commandExecutor.ExecuteAsync("az", "account show", suppressErrorLogging: true) + .Returns(Task.FromException(new Exception("Azure CLI not found"))); + + // Act + var isLoggedIn = await _azureCliService.IsLoggedInAsync(); + + // Assert + isLoggedIn.Should().BeFalse(); + } + + [Fact] + public async Task GetCurrentAccountAsync_WhenSuccessful_ReturnsAccountInfo() + { + // Arrange + var jsonOutput = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "name": "Test Subscription", + "tenantId": "87654321-4321-4321-4321-cba987654321", + "user": { + "name": "test@example.com", + "type": "user" + }, + "state": "Enabled", + "isDefault": true + } + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "account show --output json") + .Returns(Task.FromResult(result)); + + // Act + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + + // Assert + accountInfo.Should().NotBeNull(); + accountInfo!.Id.Should().Be("12345678-1234-1234-1234-123456789abc"); + accountInfo.Name.Should().Be("Test Subscription"); + accountInfo.TenantId.Should().Be("87654321-4321-4321-4321-cba987654321"); + accountInfo.User.Name.Should().Be("test@example.com"); + accountInfo.User.Type.Should().Be("user"); + accountInfo.State.Should().Be("Enabled"); + accountInfo.IsDefault.Should().BeTrue(); + } + + [Fact] + public async Task GetCurrentAccountAsync_WhenAzureCliFails_ReturnsNull() + { + // Arrange + var result = new CommandResult { ExitCode = 1, StandardError = "ERROR: Please run 'az login'" }; + _commandExecutor.ExecuteAsync("az", "account show --output json") + .Returns(Task.FromResult(result)); + + // Act + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + + // Assert + accountInfo.Should().BeNull(); + } + + [Fact] + public async Task GetCurrentAccountAsync_WhenJsonInvalid_ReturnsNull() + { + // Arrange + var result = new CommandResult { ExitCode = 0, StandardOutput = "invalid json" }; + _commandExecutor.ExecuteAsync("az", "account show --output json") + .Returns(Task.FromResult(result)); + + // Act + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + + // Assert + accountInfo.Should().BeNull(); + } + + [Fact] + public async Task ListResourceGroupsAsync_WhenSuccessful_ReturnsResourceGroups() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "rg-test-001", + "location": "eastus", + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/rg-test-001" + }, + { + "name": "rg-test-002", + "location": "westus", + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/rg-test-002" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "group list --output json") + .Returns(Task.FromResult(result)); + + // Act + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + + // Assert + resourceGroups.Should().HaveCount(2); + resourceGroups[0].Name.Should().Be("rg-test-001"); + resourceGroups[0].Location.Should().Be("eastus"); + resourceGroups[1].Name.Should().Be("rg-test-002"); + resourceGroups[1].Location.Should().Be("westus"); + } + + [Fact] + public async Task ListResourceGroupsAsync_WhenNoResourceGroups_ReturnsEmptyList() + { + // Arrange + var result = new CommandResult { ExitCode = 0, StandardOutput = "[]" }; + _commandExecutor.ExecuteAsync("az", "group list --output json") + .Returns(Task.FromResult(result)); + + // Act + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + + // Assert + resourceGroups.Should().BeEmpty(); + } + + [Fact] + public async Task ListResourceGroupsAsync_WhenAzureCliFails_ReturnsEmptyList() + { + // Arrange + var result = new CommandResult { ExitCode = 1, StandardError = "ERROR: Failed to list resource groups" }; + _commandExecutor.ExecuteAsync("az", "group list --output json") + .Returns(Task.FromResult(result)); + + // Act + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + + // Assert + resourceGroups.Should().BeEmpty(); + } + + [Fact] + public async Task ListAppServicePlansAsync_WhenSuccessful_ReturnsAppServicePlans() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "asp-test-001", + "resourceGroup": "rg-test-001", + "location": "eastus", + "sku": { + "name": "B1" + }, + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/rg-test-001/providers/Microsoft.Web/serverfarms/asp-test-001" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "appservice plan list --output json") + .Returns(Task.FromResult(result)); + + // Act + var appServicePlans = await _azureCliService.ListAppServicePlansAsync(); + + // Assert + appServicePlans.Should().HaveCount(1); + appServicePlans[0].Name.Should().Be("asp-test-001"); + appServicePlans[0].ResourceGroup.Should().Be("rg-test-001"); + appServicePlans[0].Sku.Should().Be("B1"); + } + + [Fact] + public async Task ListLocationsAsync_WhenSuccessful_ReturnsLocations() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "eastus", + "displayName": "East US", + "regionalDisplayName": "(US) East US" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "account list-locations --output json") + .Returns(Task.FromResult(result)); + + // Act + var locations = await _azureCliService.ListLocationsAsync(); + + // Assert + locations.Should().HaveCount(1); + locations[0].Name.Should().Be("eastus"); + locations[0].DisplayName.Should().Be("East US"); + locations[0].RegionalDisplayName.Should().Be("(US) East US"); + } + + [Fact] + public async Task ListLocationsAsync_WhenRegionalDisplayNameMissing_HandlesGracefully() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "eastus", + "displayName": "East US" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "account list-locations --output json") + .Returns(Task.FromResult(result)); + + // Act + var locations = await _azureCliService.ListLocationsAsync(); + + // Assert + locations.Should().HaveCount(1); + locations[0].RegionalDisplayName.Should().BeEmpty(); + } +} From aa2d36b3334351e135d0c4af89e6cb03e8208921 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 14 Nov 2025 09:43:39 -0800 Subject: [PATCH 4/7] Refactor namespaces and simplify logic Refactored `AzureAccountInfo` and `ConfigDerivedNames` classes to the `Models` namespace for better organization. Updated `using` directives in `AzureCliService` and `IAzureCliService` to reflect the namespace changes. Removed unused `userName` variable from `GenerateDerivedNames` in `ConfigurationWizardService` to simplify the method logic. --- src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs | 2 +- .../Models/ConfigDerivedNames.cs | 2 +- .../Services/AzureCliService.cs | 1 + .../Services/ConfigurationWizardService.cs | 1 - .../Services/IAzureCliService.cs | 1 + 5 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs index eef5951f..64297c95 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.Agents.A365.DevTools.Cli.Services; +namespace Microsoft.Agents.A365.DevTools.Cli.Models; /// /// Azure account information from Azure CLI diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs index d2dc1cd0..b5d75322 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.Agents.A365.DevTools.Cli.Services; +namespace Microsoft.Agents.A365.DevTools.Cli.Models; /// /// Contains derived names generated from an agent name during configuration initialization diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs index cf7a9e96..fa5e4737 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Models; namespace Microsoft.Agents.A365.DevTools.Cli.Services; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index 7b70624a..959c527d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -394,7 +394,6 @@ private ConfigDerivedNames GenerateDerivedNames(string agentName) { var cleanName = System.Text.RegularExpressions.Regex.Replace(agentName, @"[^a-zA-Z0-9]", "").ToLowerInvariant(); var timestamp = DateTime.Now.ToString("MMddHHmm"); - var userName = Environment.UserName.ToLowerInvariant(); return new ConfigDerivedNames { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs index ebda1b0f..2b1e140e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Models; namespace Microsoft.Agents.A365.DevTools.Cli.Services; From 874ce3ef825ad871cf80bc69860b3c831e3b727d Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 14 Nov 2025 11:57:21 -0800 Subject: [PATCH 5/7] Refactor `a365 config init` for usability and clarity Enhanced the `a365 config init` command with improved documentation, smarter defaults, and better user guidance. Key updates include: - Updated documentation to emphasize interactive wizard features, Azure CLI integration, and smart defaults. - Added detailed validation for inputs like project paths, resource groups, and manager emails. - Introduced a configuration summary and optional name customization step. - Improved logging for success, errors, and user cancellations. - Enhanced Azure CLI integration with better error handling and resource discovery. - Streamlined setup and deployment instructions, removing redundant steps. - Standardized error messages and improved user feedback throughout the wizard. These changes improve the overall user experience and robustness of the configuration process. --- docs/commands/config-init.md | 459 ++++++++++++++---- .../Commands/ConfigCommand.cs | 4 +- .../Services/ConfigurationWizardService.cs | 20 +- 3 files changed, 368 insertions(+), 115 deletions(-) diff --git a/docs/commands/config-init.md b/docs/commands/config-init.md index 2abe1ca2..e886e8cb 100644 --- a/docs/commands/config-init.md +++ b/docs/commands/config-init.md @@ -1,162 +1,409 @@ # Agent 365 CLI - Configuration Initialization Guide > **Command**: `a365 config init` -> **Purpose**: Initialize your Agent 365 configuration with all required settings for deployment +> **Purpose**: Interactive wizard to configure Agent 365 with Azure CLI integration and smart defaults ## Overview -The `a365 config init` command walks you through creating a complete configuration file (`a365.config.json`) for your Agent 365 deployment. This interactive process collects essential information about your Azure subscription, agent identity, and deployment settings. +The `a365 config init` command provides an intelligent, interactive configuration wizard that minimizes manual input by leveraging Azure CLI integration and smart defaults. The wizard automatically detects your Azure subscription, suggests resource names, and validates your inputs to ensure a smooth setup experience. ## Quick Start ```bash -# Initialize configuration with interactive prompts +# Initialize configuration with interactive wizard a365 config init -# Use existing config as starting point -a365 config init --config path/to/existing/a365.config.json +# Import existing config file +a365 config init --configfile path/to/existing/a365.config.json + +# Create config in global directory (AppData) +a365 config init --global ``` -## Configuration Fields +## Key Features -### Azure Infrastructure +- **Azure CLI Integration**: Automatically detects your Azure subscription, tenant, and available resources +- **Smart Defaults**: Generates sensible defaults for resource names, agent identities, and UPNs +- **Resource Discovery**: Lists existing resource groups, app service plans, and locations +- **Platform Detection**: Automatically detects project type (.NET, Node.js, Python) +- **Input Validation**: Validates paths, UPNs, emails, and Azure resources +- **Interactive Prompts**: Press Enter to accept defaults or type to customize -| Field | Description | Example | Required | -|-------|-------------|---------|----------| -| **tenantId** | Azure AD Tenant ID | `12345678-1234-...` | ? Yes | -| **subscriptionId** | Azure Subscription ID | `87654321-4321-...` | ? Yes | -| **resourceGroup** | Azure Resource Group name | `my-agent-rg` | ? Yes | -| **location** | Azure region | `eastus`, `westus2` | ? Yes | -| **appServicePlanName** | App Service Plan name | `my-agent-plan` | ? Yes | -| **appServicePlanSku** | Service Plan SKU | `B1`, `S1`, `P1V2` | ? No (defaults to `B1`) | -| **webAppName** | Web App name (must be globally unique) | `my-agent-webapp` | ? Yes | +## Configuration Flow -### Agent Identity +### Step 1: Azure CLI Verification -| Field | Description | Example | Required | -|-------|-------------|---------|----------| -| **agentIdentityDisplayName** | Name shown in Azure AD for the agent identity | `My Agent Identity` | ? Yes | -| **agentBlueprintDisplayName** | Name for the agent blueprint | `My Agent Blueprint` | ? Yes | -| **agentUserPrincipalName** | UPN for the agentic user | `demo.agent@contoso.onmicrosoft.com` | ? Yes | -| **agentUserDisplayName** | Display name for the agentic user | `Demo Agent` | ? Yes | -| **agentDescription** | Description of your agent | `My helpful support agent` | ? No | -| **managerEmail** | Email of the agent's manager | `manager@contoso.com` | ? No | -| **agentUserUsageLocation** | Country code for license assignment | `US`, `GB`, `DE` | ? No (defaults to `US`) | +The wizard first verifies your Azure CLI authentication: -### Deployment Settings +``` +Checking Azure CLI authentication... +Subscription ID: e09e22f2-9193-4f54-a335-01f59575eefd (My Subscription) +Tenant ID: adfa4542-3e1e-46f5-9c70-3df0b15b3f6c -| Field | Description | Example | Required | -|-------|-------------|---------|----------| -| **deploymentProjectPath** | Path to agent project directory | `C:\projects\my-agent` or `./my-agent` | ? Yes | +NOTE: Defaulted from current Azure account. To use a different Azure subscription, +run 'az login' and then 'az account set --subscription ' before +running this command. +``` -## Interactive Prompts +**If not logged in:** +``` +ERROR: You are not logged in to Azure CLI. +Please run 'az login' and then try again. +``` -When you run `a365 config init`, you'll see detailed prompts for each field: +### Step 2: Agent Name -### Example: Agent User Principal Name +Provide a unique name for your agent. This is used to generate derived names for resources: ``` ----------------------------------------------- - Agent User Principal Name (UPN) ----------------------------------------------- -Description : Email-like identifier for the agentic user in Azure AD. - Format: @.onmicrosoft.com or @ - Example: demo.agent@contoso.onmicrosoft.com - This must be unique in your tenant. +Agent name [agent1114]: myagent +``` + +**Smart Defaults**: If no existing config, defaults to `agent` + current date (e.g., `agent1114`) + +### Step 3: Deployment Project Path + +Specify the path to your agent project: + +``` +Deployment project path [C:\A365-Ignite-Demo\sample_agent]: +Detected DotNet project +``` + +**Features**: +- Defaults to current directory or existing config path +- Validates directory exists +- Detects project platform (.NET, Node.js, Python) +- Warns if no supported project type detected + +### Step 4: Resource Group Selection -Current Value: [agent.john@yourdomain.onmicrosoft.com] +Choose from existing resource groups or create a new one: -> demo.agent@contoso.onmicrosoft.com ``` +Available resource groups: + 1. a365demorg + 2. another-rg + 3. -### Example: Deployment Project Path +Select resource group (1-3) [1]: 1 +``` + +**Smart Behavior**: +- Lists existing resource groups from your subscription +- Option to create new resource group +- Defaults to existing config value if available + +### Step 5: App Service Plan Selection + +Choose from existing app service plans in the selected resource group: ``` ----------------------------------------------- - Deployment Project Path ----------------------------------------------- -Description : Path to your agent project directory for deployment. - This should contain your agent's source code and configuration files. - The directory must exist and be accessible. - You can use relative paths (e.g., ./my-agent) or absolute paths. +Available app service plans in resource group 'a365demorg': + 1. a365agent-app-plan + 2. + +Select app service plan (1-2) [1]: 1 +``` + +**Smart Behavior**: +- Only shows plans in the selected resource group +- Option to create new plan +- Defaults to existing config value + +### Step 6: Manager Email -Current Value: [C:\Users\john\projects\current-directory] +Provide the email address of the agent's manager: -> ./my-agent +``` +Manager email [agent365demo.manager1@a365preview001.onmicrosoft.com]: ``` -## Field Validation +**Validation**: Ensures valid email format -The CLI validates your input to catch errors early: +### Step 7: Azure Location -### Agent User Principal Name (UPN) +Choose the Azure region for deployment: -? **Valid formats**: -- `demo.agent@contoso.onmicrosoft.com` -- `support-bot@verified-domain.com` +``` +Azure location [eastus]: +``` -? **Invalid formats**: -- `invalidupn` (missing @) -- `user@` (missing domain) -- `@domain.com` (missing username) +**Smart Defaults**: Uses location from existing config or Azure account -### Deployment Project Path +### Step 8: Configuration Summary -? **Valid paths**: -- `./my-agent` (relative path) -- `C:\projects\my-agent` (absolute path) -- `../parent-folder/my-agent` (parent directory) +Review all settings before saving: -? **Invalid paths**: -- `Z:\nonexistent\path` (directory doesn't exist) -- `C:\|invalid` (illegal characters) +``` +================================================================= + Configuration Summary +================================================================= +Agent Name : myagent +Web App Name : myagent-webapp-11140916 +Agent Identity Name : myagent Identity +Agent Blueprint Name : myagent Blueprint +Agent UPN : agent.myagent.11140916@yourdomain.onmicrosoft.com +Agent Display Name : myagent Agent User +Manager Email : agent365demo.manager1@a365preview001.onmicrosoft.com +Deployment Path : C:\A365-Ignite-Demo\sample_agent +Resource Group : a365demorg +App Service Plan : a365agent-app-plan +Location : eastus +Subscription : My Subscription (e09e22f2-9193-4f54-a335-01f59575eefd) +Tenant : adfa4542-3e1e-46f5-9c70-3df0b15b3f6c + +Do you want to customize any derived names? (y/N): +``` -### Empty Values +### Step 9: Name Customization (Optional) -All required fields must have values: +Optionally customize generated names: ``` -? This field is required. Please provide a value. +Do you want to customize any derived names? (y/N): y + +Web App Name [myagent-webapp-11140916]: myagent-prod +Agent Identity Display Name [myagent Identity]: +Agent Blueprint Display Name [myagent Blueprint]: +Agent User Principal Name [agent.myagent.11140916@yourdomain.onmicrosoft.com]: +Agent User Display Name [myagent Agent User]: +``` + +### Step 10: Confirmation + +Final confirmation to save: + +``` +Save this configuration? (Y/n): Y + +Configuration saved to: C:\Users\user\a365.config.json + +You can now run: + a365 setup - Create Azure resources + a365 deploy - Deploy your agent +``` + +## Configuration Fields + +The wizard automatically populates these fields: + +### Azure Infrastructure (Auto-detected from Azure CLI) + +| Field | Description | Source | Example | +|-------|-------------|--------|---------| +| **tenantId** | Azure AD Tenant ID | Azure CLI (`az account show`) | `adfa4542-3e1e-46f5-9c70-3df0b15b3f6c` | +| **subscriptionId** | Azure Subscription ID | Azure CLI (`az account show`) | `e09e22f2-9193-4f54-a335-01f59575eefd` | +| **resourceGroup** | Azure Resource Group name | User selection from list | `a365demorg` | +| **location** | Azure region | Azure account or user input | `eastus` | +| **appServicePlanName** | App Service Plan name | User selection from list | `a365agent-app-plan` | +| **appServicePlanSku** | Service Plan SKU | Default value | `B1` | + +### Agent Identity (Auto-generated with customization option) + +| Field | Description | Generation Logic | Example | +|-------|-------------|------------------|---------| +| **webAppName** | Web App name (globally unique) | `{agentName}-webapp-{timestamp}` | `myagent-webapp-11140916` | +| **agentIdentityDisplayName** | Agent identity in Azure AD | `{agentName} Identity` | `myagent Identity` | +| **agentBlueprintDisplayName** | Agent blueprint name | `{agentName} Blueprint` | `myagent Blueprint` | +| **agentUserPrincipalName** | UPN for the agentic user | `agent.{agentName}.{timestamp}@domain` | `agent.myagent.11140916@yourdomain.onmicrosoft.com` | +| **agentUserDisplayName** | Display name for agentic user | `{agentName} Agent User` | `myagent Agent User` | +| **agentDescription** | Description of your agent | `{agentName} - Agent 365 Demo Agent` | `myagent - Agent 365 Demo Agent` | + +### User-Provided Fields + +| Field | Description | Validation | Example | +|-------|-------------|------------|---------| +| **managerEmail** | Email of the agent's manager | Email format | `manager@contoso.com` | +| **deploymentProjectPath** | Path to agent project directory | Directory exists, platform detection | `C:\projects\my-agent` | +| **agentUserUsageLocation** | Country code for license | Auto-detected from Azure account | `US` | + +## Command Options + +```bash +# Display help +a365 config init --help + +# Import existing configuration file +a365 config init --configfile path/to/config.json +a365 config init -c path/to/config.json + +# Create config in global directory (AppData) +a365 config init --global +a365 config init -g ``` ## Generated Configuration File -After completing the prompts, `a365 config init` creates `a365.config.json`: +After completing the wizard, `a365.config.json` is created: ```json { - "tenantId": "12345678-1234-1234-1234-123456789012", - "subscriptionId": "87654321-4321-4321-4321-210987654321", - "resourceGroup": "my-agent-rg", + "tenantId": "adfa4542-3e1e-46f5-9c70-3df0b15b3f6c", + "subscriptionId": "e09e22f2-9193-4f54-a335-01f59575eefd", + "resourceGroup": "a365demorg", "location": "eastus", - "appServicePlanName": "my-agent-plan", + "environment": "prod", + "appServicePlanName": "a365agent-app-plan", "appServicePlanSku": "B1", - "webAppName": "my-agent-webapp", - "agentIdentityDisplayName": "My Agent Identity", - "agentBlueprintDisplayName": "My Agent Blueprint", - "agentUserPrincipalName": "demo.agent@contoso.onmicrosoft.com", - "agentUserDisplayName": "Demo Agent", - "deploymentProjectPath": "C:\\projects\\my-agent", - "agentDescription": "My helpful support agent", + "webAppName": "myagent-webapp-11140916", + "agentIdentityDisplayName": "myagent Identity", + "agentBlueprintDisplayName": "myagent Blueprint", + "agentUserPrincipalName": "agent.myagent.11140916@yourdomain.onmicrosoft.com", + "agentUserDisplayName": "myagent Agent User", "managerEmail": "manager@contoso.com", - "agentUserUsageLocation": "US" + "agentUserUsageLocation": "US", + "deploymentProjectPath": "C:\\projects\\my-agent", + "agentDescription": "myagent - Agent 365 Demo Agent" } ``` -## Smart Defaults +## Smart Default Generation -The CLI provides intelligent defaults based on your environment: +The wizard uses intelligent algorithms to generate defaults: -| Field | Default Value | Logic | -|-------|---------------|-------| -| **agentIdentityDisplayName** | `John's Agent 365 Instance 20241112T153045` | `'s Agent 365 Instance ` | -| **agentBlueprintDisplayName** | `John's Agent 365 Blueprint` | `'s Agent 365 Blueprint` | -| **agentUserPrincipalName** | `agent.john@yourdomain.onmicrosoft.com` | `agent.@yourdomain.onmicrosoft.com` | -| **agentUserDisplayName** | `John's Agent User` | `'s Agent User` | -| **deploymentProjectPath** | `C:\projects\current-directory` | Current working directory | -| **agentUserUsageLocation** | `US` | United States | +### Agent Name Derivation -## Usage with Other Commands +**Input**: `myagent` + +**Generated Names**: +``` +webAppName = myagent-webapp-11140916 +agentIdentityDisplayName = myagent Identity +agentBlueprintDisplayName = myagent Blueprint +agentUserPrincipalName = agent.myagent.11140916@yourdomain.onmicrosoft.com +agentUserDisplayName = myagent Agent User +agentDescription = myagent - Agent 365 Demo Agent +``` + +**Timestamp**: `MMddHHmm` format (e.g., `11140916` = Nov 14, 09:16 AM) + +### Usage Location Detection + +Automatically determined from Azure account home tenant location: +- US-based tenants → `US` +- UK-based tenants → `GB` +- Canada-based tenants → `CA` +- Falls back to `US` if unable to detect + +## Validation Rules + +### Deployment Project Path + +- **Existence**: Directory must exist on the file system +- **Platform Detection**: Warns if no supported project type (.NET, Node.js, Python) is detected +- **Confirmation**: User can choose to continue even without detected platform + +``` +WARNING: Could not detect a supported project type (.NET, Node.js, or Python) +in the specified directory. +Continue anyway? (y/N): +``` + +### Resource Group + +- **Existence**: Must select from existing resource groups or create new +- **Format**: Azure naming conventions (lowercase, alphanumeric, hyphens) + +### App Service Plan + +- **Scope**: Must exist in the selected resource group +- **Fallback**: Option to create new plan if none exist + +### Manager Email + +- **Format**: Valid email address (contains `@` and domain) + +- **Format**: Valid email address (contains `@` and domain) + +## Azure CLI Integration + +The wizard leverages Azure CLI for automatic resource discovery: + +### Prerequisites + +```bash +# Install Azure CLI (if not already installed) +# Windows: https://learn.microsoft.com/cli/azure/install-azure-cli-windows +# macOS: brew install azure-cli +# Linux: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + +# Login to Azure +az login + +# Set active subscription (if you have multiple) +az account set --subscription "My Subscription" + +# Verify current account +az account show +``` + +### What the Wizard Fetches + +1. **Current Azure Account**: + - Subscription ID and Name + - Tenant ID + - User information + - Home tenant location (for usage location) + +2. **Resource Groups**: + - Lists all resource groups in your subscription + - Allows selection or creation of new group + +3. **App Service Plans**: + - Lists plans in the selected resource group + - Filters by location compatibility + - Shows SKU and pricing tier + +4. **Azure Locations**: + - Lists available Azure regions + - Suggests location based on account or existing config + +### Error Handling + +**Not logged in**: +``` +ERROR: You are not logged in to Azure CLI. +Please run 'az login' and then try again. +``` + +**Solution**: Run `az login` and complete browser authentication + +**Multiple subscriptions**: +``` +Subscription ID: e09e22f2-9193-4f54-a335-01f59575eefd (Subscription 1) + +NOTE: To use a different Azure subscription, run 'az login' and then +'az account set --subscription ' before running this command. +``` + +**Solution**: Set desired subscription with `az account set` + +## Updating Existing Configuration + +Re-run the wizard to update your configuration: + +```bash +# Wizard will load existing values as defaults +a365 config init + +# Or import from a different file +a365 config init --configfile production.config.json +``` + +**Workflow**: +1. Wizard detects existing `a365.config.json` +2. Displays message: "Found existing configuration. Default values will be used where available." +3. Each prompt shows current value in brackets: `[current-value]` +4. Press **Enter** to keep current value +5. Type new value to update + +**Example**: +``` +Agent name [myagent]: myagent-v2 +Deployment project path [C:\projects\my-agent]: ← Press Enter to keep +Resource group [a365demorg]: new-rg ← Type to update +``` ### Setup Command @@ -315,7 +562,8 @@ After running `a365 config init`: 1. **Review the generated config**: ```bash - cat a365.config.json + # View static configuration + a365 config display ``` 2. **Run setup** to create Azure resources: @@ -323,19 +571,16 @@ After running `a365 config init`: a365 setup ``` -3. **Create agent instance**: - ```bash - a365 create-instance - ``` - -4. **Deploy your agent**: +3. **Deploy your agent**: ```bash a365 deploy ``` -## Support +## Additional Resources -For issues or questions: +- **Command Reference**: [a365 config display](config-display.md) +- **Setup Guide**: [a365 setup](setup.md) +- **Deployment Guide**: [a365 deploy](deploy.md) - **GitHub Issues**: [Agent 365 Repository](https://github.com/microsoft/Agent365-devTools/issues) - **Documentation**: [Microsoft Learn](https://learn.microsoft.com/agent365) -- **Community**: [Microsoft Tech Community](https://techcommunity.microsoft.com) + diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs index bc522ac0..e5b6748c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs @@ -152,7 +152,9 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir, IC } else { - logger.LogWarning("Configuration wizard cancelled."); + // Wizard returned null - could be user cancellation or error + // Error details already logged by the wizard service + logger.LogDebug("Configuration wizard returned null"); } } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index 959c527d..aa8b68ac 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -44,13 +44,14 @@ public ConfigurationWizardService( { _logger.LogDebug("Using existing configuration with deploymentProjectPath: {Path}", existingConfig.DeploymentProjectPath ?? "(null)"); Console.WriteLine("Found existing configuration. Default values will be used where available."); - Console.WriteLine("Press **Enter** to keep a current value, or type a new one to update it."); + Console.WriteLine("Press Enter to keep a current value, or type a new one to update it."); Console.WriteLine(); } // Step 1: Verify Azure CLI login if (!await VerifyAzureLoginAsync()) { + _logger.LogError("Configuration wizard cancelled: Azure CLI authentication required"); return null; } @@ -58,7 +59,7 @@ public ConfigurationWizardService( var accountInfo = await _azureCliService.GetCurrentAccountAsync(); if (accountInfo == null) { - Console.WriteLine("ERROR: Could not retrieve Azure account information. Please run 'az login' first."); + _logger.LogError("Failed to retrieve Azure account information. Please run 'az login' first"); return null; } @@ -72,7 +73,7 @@ public ConfigurationWizardService( var agentName = PromptForAgentName(existingConfig); if (string.IsNullOrWhiteSpace(agentName)) { - Console.WriteLine("ERROR: Agent name is required. Configuration cancelled."); + _logger.LogError("Agent name is required. Configuration cancelled"); return null; } @@ -82,6 +83,7 @@ public ConfigurationWizardService( var deploymentPath = await PromptForDeploymentPathAsync(existingConfig); if (string.IsNullOrWhiteSpace(deploymentPath)) { + _logger.LogError("Configuration wizard cancelled: Deployment project path not provided or invalid"); return null; } @@ -89,6 +91,7 @@ public ConfigurationWizardService( var resourceGroup = await PromptForResourceGroupAsync(existingConfig); if (string.IsNullOrWhiteSpace(resourceGroup)) { + _logger.LogError("Configuration wizard cancelled: Resource group not selected"); return null; } @@ -96,6 +99,7 @@ public ConfigurationWizardService( var appServicePlan = await PromptForAppServicePlanAsync(existingConfig, resourceGroup); if (string.IsNullOrWhiteSpace(appServicePlan)) { + _logger.LogError("Configuration wizard cancelled: App Service Plan not selected"); return null; } @@ -103,6 +107,7 @@ public ConfigurationWizardService( var managerEmail = PromptForManagerEmail(existingConfig); if (string.IsNullOrWhiteSpace(managerEmail)) { + _logger.LogError("Configuration wizard cancelled: Manager email not provided"); return null; } @@ -139,6 +144,7 @@ public ConfigurationWizardService( if (saveResponse == "n" || saveResponse == "no") { Console.WriteLine("Configuration cancelled."); + _logger.LogInformation("Configuration wizard cancelled by user"); return null; } @@ -163,12 +169,12 @@ public ConfigurationWizardService( AgentDescription = $"{agentName} - Agent 365 Demo Agent" }; + _logger.LogInformation("Configuration wizard completed successfully"); return config; } catch (Exception ex) { - _logger.LogError(ex, "Error during configuration wizard"); - Console.WriteLine($"ERROR: Configuration wizard failed: {ex.Message}"); + _logger.LogError(ex, "Configuration wizard failed: {Message}", ex.Message); return null; } } @@ -177,8 +183,7 @@ private async Task VerifyAzureLoginAsync() { if (!await _azureCliService.IsLoggedInAsync()) { - Console.WriteLine("ERROR: You are not logged in to Azure CLI."); - Console.WriteLine("Please run 'az login' and select your subscription, then try again."); + _logger.LogError("You are not logged in to Azure CLI. Please run 'az login' and select your subscription, then try again"); return false; } @@ -247,6 +252,7 @@ private async Task PromptForDeploymentPathAsync(Agent365Config? existing var response = Console.ReadLine()?.Trim().ToLowerInvariant(); if (response != "y" && response != "yes") { + _logger.LogError("Deployment path must contain a valid project. Configuration cancelled"); return string.Empty; } } From bb9d080ef27ff13ce7960c629ac52bbce0833e48 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 14 Nov 2025 12:03:15 -0800 Subject: [PATCH 6/7] fix: Add retry logic for federated identity credential creation Resolves Request_ResourceNotFound errors during blueprint setup by implementing exponential backoff retry (5 attempts: 2s, 4s, 8s, 16s, 32s) to handle Azure AD application object propagation delays. Previously, the code attempted FIC creation immediately after a 10s delay, which was insufficient for Azure AD eventual consistency. Now automatically retries with clear user feedback when propagation errors are detected. --- .../Services/A365SetupRunner.cs | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index 3c3a5629..47c6ce19 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -846,6 +846,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, /// /// Create Federated Identity Credential to link managed identity to blueprint /// Equivalent to createFederatedIdentityCredential function in PowerShell + /// Implements retry logic to handle Azure AD propagation delays /// private async Task CreateFederatedIdentityCredentialAsync( string tenantId, @@ -854,6 +855,9 @@ private async Task CreateFederatedIdentityCredentialAsync( string msiPrincipalId, CancellationToken ct) { + const int maxRetries = 5; + const int initialDelayMs = 2000; // Start with 2 seconds + try { var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct); @@ -875,23 +879,44 @@ private async Task CreateFederatedIdentityCredentialAsync( httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); var url = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/federatedIdentityCredentials"; - var response = await httpClient.PostAsync( - url, - new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - if (!response.IsSuccessStatusCode) + // Retry loop to handle propagation delays + for (int attempt = 1; attempt <= maxRetries; attempt++) { + var response = await httpClient.PostAsync( + url, + new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation(" - Credential Name: {Name}", credentialName); + _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); + _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); + return true; + } + var error = await response.Content.ReadAsStringAsync(ct); + + // Check if it's a propagation issue (resource not found) + if (error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) + { + if (attempt < maxRetries) + { + var delayMs = initialDelayMs * (int)Math.Pow(2, attempt - 1); // Exponential backoff + _logger.LogWarning("Application object not yet propagated (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}ms...", + attempt, maxRetries, delayMs); + await Task.Delay(delayMs, ct); + continue; + } + } + + // Other error or max retries reached _logger.LogError("Failed to create federated identity credential: {Error}", error); return false; } - _logger.LogInformation(" - Credential Name: {Name}", credentialName); - _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); - _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); - - return true; + return false; } catch (Exception ex) { From 05cc851cacf1dd2078f9adbf415bc1d8acf41c7f Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 14 Nov 2025 12:10:36 -0800 Subject: [PATCH 7/7] Update AgentDescription to remove "Demo" designation The `AgentDescription` property in the `ConfigurationWizardService` class was updated to remove the word "Demo" from the description. The new value is `"Agent 365 Agent"`, reflecting a shift in naming convention or branding to make the description more general or production-ready. --- .../Services/ConfigurationWizardService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index aa8b68ac..a93c8940 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -166,7 +166,7 @@ public ConfigurationWizardService( ManagerEmail = managerEmail, AgentUserUsageLocation = GetUsageLocationFromAccount(accountInfo), DeploymentProjectPath = deploymentPath, - AgentDescription = $"{agentName} - Agent 365 Demo Agent" + AgentDescription = $"{agentName} - Agent 365 Agent" }; _logger.LogInformation("Configuration wizard completed successfully");