diff --git a/README.md b/README.md index f3eac9b2..250d18a2 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,34 @@ a365 query-entra instance-scopes 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 + +# 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 (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 +``` + --- ## Multiplatform Deployment Support @@ -283,6 +311,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..e455739c 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,81 @@ 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. 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 + +**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 (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 +- **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 +# Default usage (production environment, no config needed) +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 (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: @@ -495,7 +571,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..91b2e09d --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -0,0 +1,884 @@ +// 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; +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 minimal options - config is optional and not advertised (for internal developers only) + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Enable verbose logging"); + + 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); + + 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) + { + 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 (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"); + 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); + } + + // 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, verboseOption); + + 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); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Enable verbose logging" + ); + command.AddOption(verboseOption); + + command.SetHandler(async (envId, configPath, dryRun, verbose) => + { + if (verbose) + { + logger.LogInformation("Verbose mode enabled - showing detailed information"); + } + + try + { + // Validate and prompt for missing required argument with security checks + if (string.IsNullOrWhiteSpace(envId)) + { + 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); + + 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, verboseOption); + + 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) => + { + try + { + // Validate and prompt for missing required arguments with security checks + if (string.IsNullOrWhiteSpace(envId)) + { + 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)) + { + 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); + + 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; + } + + // Validate and prompt for missing optional values with security checks + if (string.IsNullOrWhiteSpace(alias)) + { + 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)) + { + 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 + { + 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) => + { + try + { + // Validate and prompt for missing required arguments with security checks + if (string.IsNullOrWhiteSpace(envId)) + { + 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)) + { + 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); + + 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 + /// + 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) => + { + try + { + // Validate and prompt for missing required arguments with security checks + if (string.IsNullOrWhiteSpace(serverName)) + { + 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); + + 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 + /// + 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) => + { + try + { + // Validate and prompt for missing required arguments with security checks + if (string.IsNullOrWhiteSpace(serverName)) + { + 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); + + 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; + } + + /// + /// 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/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..4a508f51 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseEnvironment.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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..6f672809 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/DataverseMcpServer.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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..966def0b --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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..1b874682 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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..6e98879d 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; @@ -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(); @@ -66,7 +68,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 +79,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 +170,37 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); 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(); 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..c268d213 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -0,0 +1,606 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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; + private readonly string _environment; + + public Agent365ToolingService( + IConfigService configService, + AuthenticationService authService, + 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"; + } + + /// + /// 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.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 + 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) && 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 + { + // Build URL using environment from constructor + var endpointUrl = BuildListEnvironmentsUrl(_environment); + + _logger.LogInformation("Listing Dataverse environments"); + _logger.LogInformation("Environment: {Env}", _environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_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 + { + // Build URL using environment from constructor + var endpointUrl = BuildListMcpServersUrl(_environment, environmentId); + + _logger.LogInformation("Listing MCP servers for environment {EnvId}", environmentId); + _logger.LogInformation("Environment: {Env}", _environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_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 + // Use environment from constructor + + // Build URL using private helper method + var endpointUrl = BuildPublishMcpServerUrl(_environment, environmentId, serverName); + + _logger.LogInformation("Publishing MCP server {ServerName} to environment {EnvId}", serverName, environmentId); + _logger.LogInformation("Environment: {Env}", _environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_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 + // Use environment from constructor + + // Build URL using private helper method + var endpointUrl = BuildUnpublishMcpServerUrl(_environment, environmentId, serverName); + + _logger.LogInformation("Unpublishing MCP server {ServerName} from environment {EnvId}", serverName, environmentId); + _logger.LogInformation("Environment: {Env}", _environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_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, _) = 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 + // Use environment from constructor + + // Build URL using private helper method + var endpointUrl = BuildApproveMcpServerUrl(_environment, serverName); + + _logger.LogInformation("Approving MCP server {ServerName}", serverName); + _logger.LogInformation("Environment: {Env}", _environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_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 + // Use environment from constructor + + // Build URL using private helper method + var endpointUrl = BuildBlockMcpServerUrl(_environment, serverName); + + _logger.LogInformation("Blocking MCP server {ServerName}", serverName); + _logger.LogInformation("Environment: {Env}", _environment); + _logger.LogInformation("Endpoint URL: {Url}", endpointUrl); + + // Get authentication token + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_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..e6857995 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/JsonDeserializationHelper.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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) + { + // 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 (JsonException) + { + // Fall through to final error logging + } + } + + // 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; + } + } +} 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..de946af0 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +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..c17a5738 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -0,0 +1,288 @@ +// 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(3); // config, dry-run, verbose (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"); + + // 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] + 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(4); // environment-id, config, dry-run, verbose + + // 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"); + + // 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] + 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"); + } + } +}