From 24a2d481bd5c531c93f3f7b547aaa54f7ab84de0 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Thu, 13 Nov 2025 19:19:12 -0800 Subject: [PATCH 1/2] 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/2] 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]