From 8ce6af3b2b0b992893b689bc917dd23fc418aa19 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 27 Jan 2026 15:44:19 -0800 Subject: [PATCH 01/23] initial changes --- Readme-Usage.md | 13 +- .../setup/setup-permissions-copilotstudio.md | 109 ++++++++++++ .../CopilotStudioSubcommand.cs | 160 ++++++++++++++++++ .../SetupSubcommands/PermissionsSubcommand.cs | 54 +----- .../Constants/MosConstants.cs | 23 ++- .../Helpers/ValidationHelper.cs | 58 +++++++ .../Commands/PermissionsSubcommandTests.cs | 13 +- .../Commands/SubcommandValidationTests.cs | 11 +- 8 files changed, 372 insertions(+), 69 deletions(-) create mode 100644 docs/commands/setup/setup-permissions-copilotstudio.md create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ValidationHelper.cs diff --git a/Readme-Usage.md b/Readme-Usage.md index 042341fe..761c8aa1 100644 --- a/Readme-Usage.md +++ b/Readme-Usage.md @@ -1,10 +1,10 @@ # Microsoft Agent 365 CLI -A command-line tool for deploying and managing Microsoft Agent 365 applications on Azure. +A command-line tool for deploying and managing Microsoft Agent 365 applications on Azure. ## Supported Platforms - ✅ .NET Applications -- ✅ Node.js Applications +- ✅ Node.js Applications - ✅ **Python Applications** (Auto-detects via `pyproject.toml`, handles Microsoft Agent 365 dependencies, converts .env to Azure App Settings) ## Quick Start @@ -122,6 +122,7 @@ a365 setup infrastructure a365 setup blueprint a365 setup permissions mcp a365 setup permissions bot +a365 setup permissions copilotstudio # Configure Copilot Studio permissions ``` ### Publish & Deploy @@ -174,7 +175,7 @@ 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 +# 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) @@ -200,14 +201,14 @@ The Agent 365 CLI automatically detects and deploys applications built with: - **Deployment:** Creates Oryx manifest with `dotnet YourApp.dll` command - **Requirements:** .NET SDK installed -### Node.js Applications +### Node.js Applications - **Detection:** Looks for `package.json` file - **Build Process:** `npm ci` → `npm run build` (if build script exists) - **Deployment:** Creates Oryx manifest with start script from `package.json` - **Requirements:** Node.js and npm installed ### Python Applications -- **Detection:** Looks for `requirements.txt`, `setup.py`, `pyproject.toml`, or `*.py` files +- **Detection:** Looks for `requirements.txt`, `setup.py`, `pyproject.toml`, or `*.py` files - **Build Process:** Copies project files, handles local wheel packages in `dist/`, creates deployment configuration - **Deployment:** Creates Oryx manifest with appropriate start command (gunicorn, uvicorn, or python) - **Requirements:** Python 3.11+ and pip installed @@ -232,7 +233,7 @@ a365 deploy --dry-run The CLI automatically: 1. Detects your project platform -2. Validates required tools are installed +2. Validates required tools are installed 3. Cleans previous build artifacts 4. Builds your application using platform-specific tools 5. Creates an appropriate Oryx manifest for Azure App Service diff --git a/docs/commands/setup/setup-permissions-copilotstudio.md b/docs/commands/setup/setup-permissions-copilotstudio.md new file mode 100644 index 00000000..e9e99ade --- /dev/null +++ b/docs/commands/setup/setup-permissions-copilotstudio.md @@ -0,0 +1,109 @@ +# a365 setup permissions copilotstudio Command + +## Overview + +The `a365 setup permissions copilotstudio` command configures OAuth2 permission grants and inheritable permissions for the agent blueprint to invoke Copilot Studio copilots via the Power Platform API. + +## Usage + +```bash +a365 setup permissions copilotstudio [options] +``` + +## Options + +| Option | Alias | Description | Default | +|--------|-------|-------------|---------| +| `--config` | `-c` | Configuration file path | `a365.config.json` | +| `--verbose` | `-v` | Show detailed output | `false` | +| `--dry-run` | | Show what would be done without making changes | `false` | + +## Prerequisites + +1. **Blueprint Created**: Run `a365 setup blueprint` first +2. **Global Administrator**: Required for admin consent operations +3. **Azure CLI Authentication**: `az login` with appropriate permissions + +## What This Command Does + +1. **Ensures Power Platform API Service Principal** exists in your tenant +2. **Creates OAuth2 Permission Grant** from blueprint to Power Platform API +3. **Sets Inheritable Permissions** so agent instances can invoke Copilot Studio + +## Permission Configured + +| Resource | Scope | Type | +|----------|-------|------| +| Power Platform API (`8578e004-a5c6-46e7-913e-12f58912df43`) | `CopilotStudio.Copilots.Invoke` | Delegated | + +## Examples + +### Configure CopilotStudio permissions +```bash +a365 setup permissions copilotstudio +``` + +### Preview changes without executing +```bash +a365 setup permissions copilotstudio --dry-run +``` + +### Use custom configuration file +```bash +a365 setup permissions copilotstudio --config my-agent.config.json +``` + +### Show detailed output +```bash +a365 setup permissions copilotstudio --verbose +``` + +## When to Use This Command + +Use this command when your agent needs to: +- Invoke Copilot Studio copilots at runtime +- Call Power Platform APIs that require CopilotStudio permissions + +## Related Commands + +- `a365 setup blueprint` - Create the agent blueprint (prerequisite) +- `a365 setup permissions mcp` - Configure MCP server permissions +- `a365 setup permissions bot` - Configure Messaging Bot API permissions + +## Common Issues + +### "Blueprint ID not found" Error +``` +ERROR: Blueprint ID not found. Run 'a365 setup blueprint' first. +``` +**Solution**: Run `a365 setup blueprint` to create the agent blueprint first. + +### Permission Grant Failures +If the command fails during permission grant creation, verify: +- You have Global Administrator permissions +- You're authenticated with `az login` +- The Power Platform API service principal exists in your tenant + +## Output Examples + +### Successful Execution +``` +Configuring CopilotStudio permissions... + +✓ Power Platform API service principal found +✓ OAuth2 permission grant created: CopilotStudio.Copilots.Invoke +✓ Inheritable permissions configured + +CopilotStudio permissions configured successfully + +Your agent blueprint can now invoke Copilot Studio copilots. +``` + +### Dry Run Output +``` +DRY RUN: Configure CopilotStudio Permissions +Would configure Power Platform API permissions: + - Blueprint: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + - Resource: Power Platform API (8578e004-a5c6-46e7-913e-12f58912df43) + - Scopes: CopilotStudio.Copilots.Invoke +``` \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs new file mode 100644 index 00000000..10ab1248 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using System.CommandLine; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// CopilotStudio permissions subcommand - Configures Power Platform CopilotStudio.Copilots.Invoke permission +/// Required Permissions: Global Administrator (for admin consent) +/// +internal static class CopilotStudioSubcommand +{ + /// + /// Validates CopilotStudio permissions prerequisites without performing any actions. + /// + public static Task> ValidateAsync( + Agent365Config config, + CancellationToken cancellationToken = default) + { + // Reuse the blueprint validation logic + return ValidationHelper.ValidateBlueprintAsync(config, cancellationToken); + } + + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + GraphApiService graphApiService, + AgentBlueprintService blueprintService) + { + var command = new Command("copilotstudio", + "Configure Power Platform CopilotStudio.Copilots.Invoke permission\n" + + "Minimum required permissions: Global Administrator\n\n" + + "Prerequisites: Blueprint (run 'a365 setup blueprint' first)"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + + command.SetHandler(async (config, verbose, dryRun) => + { + var setupConfig = await configService.LoadAsync(config.FullName); + + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first."); + Environment.Exit(1); + } + + // Configure GraphApiService with custom client app ID if available + if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) + { + graphApiService.CustomClientAppId = setupConfig.ClientAppId; + } + + if (dryRun) + { + logger.LogInformation("DRY RUN: Configure CopilotStudio Permissions"); + logger.LogInformation("Would configure Power Platform API permissions:"); + logger.LogInformation(" - Blueprint: {BlueprintId}", setupConfig.AgentBlueprintId); + logger.LogInformation(" - Resource: Power Platform API ({ResourceAppId})", MosConstants.PowerPlatformApiResourceAppId); + logger.LogInformation(" - Scopes: CopilotStudio.Copilots.Invoke"); + return; + } + + await ConfigureAsync( + config.FullName, + logger, + configService, + executor, + setupConfig, + graphApiService, + blueprintService, + false); + + }, configOption, verboseOption, dryRunOption); + + return command; + } + + /// + /// Configures CopilotStudio permissions (OAuth2 grants and inheritable permissions). + /// Public method that can be called by AllSubcommand. + /// + public static async Task ConfigureAsync( + string configPath, + ILogger logger, + IConfigService configService, + CommandExecutor executor, + Models.Agent365Config setupConfig, + GraphApiService graphService, + AgentBlueprintService blueprintService, + bool iSetupAll, + SetupResults? setupResults = null, + CancellationToken cancellationToken = default) + { + logger.LogInformation(""); + logger.LogInformation("Configuring CopilotStudio permissions..."); + logger.LogInformation(""); + + try + { + // Configure Power Platform API permissions for CopilotStudio + // Note: Power Platform API is a first-party Microsoft service + // We skip addToRequiredResourceAccess because the scopes may not be published there. + await SetupHelpers.EnsureResourcePermissionsAsync( + graphService, + blueprintService, + setupConfig, + MosConstants.PowerPlatformApiResourceAppId, + "Power Platform API (CopilotStudio)", + new[] { MosConstants.PermissionNames.PowerPlatformCopilotStudioInvoke }, + logger, + addToRequiredResourceAccess: false, + setInheritablePermissions: true, + setupResults, + cancellationToken); + + // write changes to generated config + await configService.SaveStateAsync(setupConfig); + + logger.LogInformation(""); + logger.LogInformation("CopilotStudio permissions configured successfully"); + logger.LogInformation(""); + if (!iSetupAll) + { + logger.LogInformation("Your agent blueprint can now invoke Copilot Studio copilots."); + } + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to configure CopilotStudio permissions: {Message}", ex.Message); + if (iSetupAll) + { + throw; + } + return false; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index 8ba1f7d4..8e1804a2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -18,51 +18,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class PermissionsSubcommand { - /// - /// Validates MCP permissions prerequisites without performing any actions. - /// - public static Task> ValidateMcpAsync( - Agent365Config config, - CancellationToken cancellationToken = default) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - { - errors.Add("Blueprint ID not found. Run 'a365 setup blueprint' first"); - } - - if (string.IsNullOrWhiteSpace(config.DeploymentProjectPath)) - { - errors.Add("deploymentProjectPath is required to read toolingManifest.json"); - return Task.FromResult(errors); - } - - var manifestPath = Path.Combine(config.DeploymentProjectPath, "toolingManifest.json"); - if (!File.Exists(manifestPath)) - { - errors.Add($"toolingManifest.json not found at {manifestPath}"); - } - - return Task.FromResult(errors); - } - - /// - /// Validates Bot permissions prerequisites without performing any actions. - /// - public static Task> ValidateBotAsync( - Agent365Config config, - CancellationToken cancellationToken = default) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - { - errors.Add("Blueprint ID not found. Run 'a365 setup blueprint' first"); - } - - return Task.FromResult(errors); - } public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -70,13 +25,14 @@ public static Command CreateCommand( GraphApiService graphApiService, AgentBlueprintService blueprintService) { - var permissionsCommand = new Command("permissions", + var permissionsCommand = new Command("permissions", "Configure OAuth2 permission grants and inheritable permissions\n" + "Minimum required permissions: Global Administrator\n"); // Add subcommands permissionsCommand.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService, blueprintService)); permissionsCommand.AddCommand(CreateBotSubcommand(logger, configService, executor, graphApiService, blueprintService)); + permissionsCommand.AddCommand(CopilotStudioSubcommand.CreateCommand(logger, configService, executor, graphApiService, blueprintService)); return permissionsCommand; } @@ -91,7 +47,7 @@ private static Command CreateMcpSubcommand( GraphApiService graphApiService, AgentBlueprintService blueprintService) { - var command = new Command("mcp", + var command = new Command("mcp", "Configure MCP server OAuth2 grants and inheritable permissions\n" + "Minimum required permissions: Global Administrator\n\n"); @@ -167,7 +123,7 @@ private static Command CreateBotSubcommand( GraphApiService graphApiService, AgentBlueprintService blueprintService) { - var command = new Command("bot", + var command = new Command("bot", "Configure Messaging Bot API OAuth2 grants and inheritable permissions\n" + "Minimum required permissions: Global Administrator\n\n" + "Prerequisites: Blueprint and MCP permissions (run 'a365 setup permissions mcp' first)\n" + @@ -257,7 +213,7 @@ public static async Task ConfigureMcpPermissionsAsync( var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); - + // Configure all permissions using unified method await SetupHelpers.EnsureResourcePermissionsAsync( graphApiService, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/MosConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/MosConstants.cs index 48680bd5..88746503 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/MosConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/MosConstants.cs @@ -79,6 +79,11 @@ public static class PermissionIds /// MOS Titles API - Title.ReadWrite.All permission scope ID /// public const string MosTitlesTitleReadWriteAll = "ecb8a615-f488-4c95-9efe-cb0142fc07dd"; + + /// + /// Power Platform API - CopilotStudio.Copilots.Invoke permission scope ID + /// + public const string PowerPlatformCopilotStudioInvoke = "204440d3-c1d0-4826-b570-99eb6f5e2aeb"; } /// @@ -100,6 +105,11 @@ public static class PermissionNames /// MOS Titles API - Title.ReadWrite.All permission scope name /// public const string MosTitlesTitleReadWriteAll = "Title.ReadWrite.All"; + + /// + /// Power Platform API - CopilotStudio.Copilots.Invoke permission scope name + /// + public const string PowerPlatformCopilotStudioInvoke = "CopilotStudio.Copilots.Invoke"; } /// @@ -113,23 +123,30 @@ public static class ResourcePermissions /// Permission configuration for TPS AppServices resource app. /// Required for test environment MOS operations. /// - public static readonly (string ResourceAppId, string ScopeName, string ScopeId) TpsAppServices = + public static readonly (string ResourceAppId, string ScopeName, string ScopeId) TpsAppServices = (TpsAppServicesResourceAppId, PermissionNames.TpsAppServicesAuthConfigRead, PermissionIds.TpsAppServicesAuthConfigRead); /// /// Permission configuration for Power Platform API resource app. /// Required for environment management operations. /// - public static readonly (string ResourceAppId, string ScopeName, string ScopeId) PowerPlatformApi = + public static readonly (string ResourceAppId, string ScopeName, string ScopeId) PowerPlatformApi = (PowerPlatformApiResourceAppId, PermissionNames.PowerPlatformEnvironmentsRead, PermissionIds.PowerPlatformEnvironmentsRead); /// /// Permission configuration for MOS Titles API resource app. /// Uses the primary Title.ReadWrite.All scope that corresponds to the specified ScopeId. /// - public static readonly (string ResourceAppId, string ScopeName, string ScopeId) MosTitlesApi = + public static readonly (string ResourceAppId, string ScopeName, string ScopeId) MosTitlesApi = (MosTitlesApiResourceAppId, PermissionNames.MosTitlesTitleReadWriteAll, PermissionIds.MosTitlesTitleReadWriteAll); + /// + /// Permission configuration for Power Platform API - CopilotStudio. + /// Required for agent blueprints to invoke Copilot Studio copilots. + /// + public static readonly (string ResourceAppId, string ScopeName, string ScopeId) CopilotStudioApi = + (PowerPlatformApiResourceAppId, PermissionNames.PowerPlatformCopilotStudioInvoke, PermissionIds.PowerPlatformCopilotStudioInvoke); + /// /// Gets all resource permission configurations. /// Use this to iterate over all MOS resource apps during setup. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ValidationHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ValidationHelper.cs new file mode 100644 index 00000000..fc4edbed --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ValidationHelper.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Helpers; + +/// +/// Shared validation helper methods for setup subcommands +/// +public static class ValidationHelper +{ + /// + /// Validates that a blueprint exists (shared by Bot and CopilotStudio permissions). + /// + public static Task> ValidateBlueprintAsync( + Agent365Config config, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + { + errors.Add("Blueprint ID not found. Run 'a365 setup blueprint' first"); + } + + return Task.FromResult(errors); + } + + /// + /// Validates MCP permissions prerequisites without performing any actions. + /// + public static Task> ValidateMcpAsync( + Agent365Config config, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + { + errors.Add("Blueprint ID not found. Run 'a365 setup blueprint' first"); + } + + if (string.IsNullOrWhiteSpace(config.DeploymentProjectPath)) + { + errors.Add("deploymentProjectPath is required to read toolingManifest.json"); + return Task.FromResult(errors); + } + + var manifestPath = Path.Combine(config.DeploymentProjectPath, "toolingManifest.json"); + if (!File.Exists(manifestPath)) + { + errors.Add($"toolingManifest.json not found at {manifestPath}"); + } + + return Task.FromResult(errors); + } +} \ No newline at end of file diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs index 0de0788f..3cd00889 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs @@ -91,9 +91,10 @@ public void CreateCommand_ShouldHaveBothSubcommands() _mockGraphApiService, _mockBlueprintService); // Assert - command.Subcommands.Should().HaveCount(2); + command.Subcommands.Should().HaveCount(3); command.Subcommands.Should().Contain(s => s.Name == "mcp"); command.Subcommands.Should().Contain(s => s.Name == "bot"); + command.Subcommands.Should().Contain(s => s.Name == "copilotstudio"); } [Fact] @@ -109,7 +110,7 @@ public void CreateCommand_ShouldBeUsableInCommandPipeline() // Assert command.Should().NotBeNull(); command.Name.Should().Be("permissions"); - command.Subcommands.Should().HaveCount(2); + command.Subcommands.Should().HaveCount(3); } #endregion @@ -507,9 +508,9 @@ public void BotSubcommand_Description_ShouldNotReferenceNonExistentEndpointComma // Assert botSubcommand.Should().NotBeNull(); - botSubcommand!.Description.Should().NotContain("a365 setup endpoint", + botSubcommand!.Description.Should().NotContain("a365 setup endpoint", "the 'a365 setup endpoint' command does not exist - endpoint is registered as part of blueprint setup"); - botSubcommand.Description.Should().Contain("a365 deploy", + botSubcommand.Description.Should().Contain("a365 deploy", "after permissions setup, users should deploy their agent code"); } @@ -527,9 +528,9 @@ public void BotSubcommand_Description_ShouldMentionPrerequisites() // Assert botSubcommand.Should().NotBeNull(); - botSubcommand!.Description.Should().Contain("Blueprint", + botSubcommand!.Description.Should().Contain("Blueprint", "blueprint is a prerequisite for bot permissions"); - botSubcommand.Description.Should().Contain("MCP permissions", + botSubcommand.Description.Should().Contain("MCP permissions", "MCP permissions should be configured before bot permissions"); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs index 50e41d59..e7945084 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; @@ -277,7 +278,7 @@ public async Task PermissionsSubcommand_ValidateMcp_WithValidConfig_PassesValida }; // Act - var errors = await PermissionsSubcommand.ValidateMcpAsync(config); + var errors = await ValidationHelper.ValidateMcpAsync(config); // Assert errors.Should().BeEmpty(); @@ -307,7 +308,7 @@ public async Task PermissionsSubcommand_ValidateMcp_WithMissingBlueprintId_Fails }; // Act - var errors = await PermissionsSubcommand.ValidateMcpAsync(config); + var errors = await ValidationHelper.ValidateMcpAsync(config); // Assert errors.Should().ContainSingle() @@ -336,7 +337,7 @@ public async Task PermissionsSubcommand_ValidateMcp_WithMissingManifest_FailsVal }; // Act - var errors = await PermissionsSubcommand.ValidateMcpAsync(config); + var errors = await ValidationHelper.ValidateMcpAsync(config); // Assert errors.Should().ContainSingle() @@ -359,7 +360,7 @@ public async Task PermissionsSubcommand_ValidateBot_WithValidConfig_PassesValida }; // Act - var errors = await PermissionsSubcommand.ValidateBotAsync(config); + var errors = await ValidationHelper.ValidateBlueprintAsync(config); // Assert errors.Should().BeEmpty(); @@ -375,7 +376,7 @@ public async Task PermissionsSubcommand_ValidateBot_WithMissingBlueprintId_Fails }; // Act - var errors = await PermissionsSubcommand.ValidateBotAsync(config); + var errors = await ValidationHelper.ValidateBlueprintAsync(config); // Assert errors.Should().ContainSingle() From 6cca5d9b73e8c79b3769a25a9cc25b69f93cc60f Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 27 Jan 2026 16:01:26 -0800 Subject: [PATCH 02/23] adds tests --- .../Commands/CopilotStudioSubcommandTests.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs new file mode 100644 index 00000000..7e37a903 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.CommandLine; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for CopilotStudio subcommand +/// +[Collection("Sequential")] +public class CopilotStudioSubcommandTests +{ + private readonly ILogger _mockLogger; + private readonly IConfigService _mockConfigService; + private readonly CommandExecutor _mockExecutor; + private readonly GraphApiService _mockGraphApiService; + private readonly AgentBlueprintService _mockBlueprintService; + + public CopilotStudioSubcommandTests() + { + _mockLogger = Substitute.For(); + _mockConfigService = Substitute.For(); + var mockExecutorLogger = Substitute.For>(); + _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + _mockGraphApiService = Substitute.ForPartsOf(); + _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); + } + + #region Command Structure Tests + + [Fact] + public void CreateCommand_ShouldHaveCorrectName() + { + // Act + var command = CopilotStudioSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService); + + // Assert + command.Should().NotBeNull(); + command.Name.Should().Be("copilotstudio"); + } + + [Fact] + public void CreateCommand_ShouldHaveConfigOption() + { + // Act + var command = CopilotStudioSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService); + + // Assert + var configOption = command.Options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("--config"); + configOption.Aliases.Should().Contain("-c"); + } + + [Fact] + public void CreateCommand_ShouldHaveVerboseOption() + { + // Act + var command = CopilotStudioSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService); + + // Assert + var verboseOption = command.Options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("--verbose"); + verboseOption.Aliases.Should().Contain("-v"); + } + + [Fact] + public void CreateCommand_ShouldHaveDryRunOption() + { + // Act + var command = CopilotStudioSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService); + + // Assert + var dryRunOption = command.Options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + } + + [Fact] + public void CreateCommand_Description_ShouldMentionPowerPlatformApi() + { + // Act + var command = CopilotStudioSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService); + + // Assert + command.Should().NotBeNull(); + command.Description.Should().Contain("Power Platform", + "description should mention the Power Platform API"); + command.Description.Should().Contain("CopilotStudio.Copilots.Invoke", + "description should mention the specific permission scope"); + } + + [Fact] + public void CreateCommand_Description_ShouldMentionPrerequisites() + { + // Act + var command = CopilotStudioSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService); + + // Assert + command.Should().NotBeNull(); + command.Description.Should().Contain("Blueprint", + "blueprint is a prerequisite for CopilotStudio permissions"); + command.Description.Should().Contain("Global Administrator", + "Global Administrator permission should be mentioned as a requirement"); + } + + #endregion + + #region Validation Tests + + [Fact] + public async Task ValidateAsync_WithValidConfig_PassesValidation() + { + // Arrange + var config = new Agent365Config + { + AgentBlueprintId = "test-blueprint-id" + }; + + // Act + var errors = await CopilotStudioSubcommand.ValidateAsync(config); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public async Task ValidateAsync_WithMissingBlueprintId_FailsValidation() + { + // Arrange + var config = new Agent365Config + { + AgentBlueprintId = "" + }; + + // Act + var errors = await CopilotStudioSubcommand.ValidateAsync(config); + + // Assert + errors.Should().ContainSingle() + .Which.Should().Contain("Blueprint ID"); + } + + #endregion +} From 511163556c8fddcde011183f5e79ce21398c844d Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Mon, 2 Feb 2026 13:28:33 -0800 Subject: [PATCH 03/23] add more than mcp permissions in add-permissions subcommand --- .../develop/develop-addpermissions.md | 41 +++++- .../AddPermissionsSubcommand.cs | 123 ++++++++++++------ 2 files changed, 117 insertions(+), 47 deletions(-) diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index dc88df32..d55d3463 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -2,7 +2,12 @@ ## Overview -The `a365 develop add-permissions` command adds MCP (Model Context Protocol) server API permissions to Azure AD applications. This command is designed for **development scenarios** where you need to configure custom applications (not agent blueprints) to access MCP servers. +The `a365 develop add-permissions` command adds API permissions to Azure AD applications. This command supports: + +- **MCP (Model Context Protocol) server permissions** - Default resource for Agent365 Tools +- **Power Platform API permissions** - For CopilotStudio and other Power Platform services + +This command is designed for **development scenarios** where you need to configure custom applications (not agent blueprints) to access various APIs. ## Usage @@ -15,9 +20,10 @@ a365 develop add-permissions [options] | Option | Alias | Description | Default | |--------|-------|-------------|---------| | `--config` | `-c` | Configuration file path | `a365.config.json` | -| `--manifest` | `-m` | Path to ToolingManifest.json | `/ToolingManifest.json` | +| `--manifest` | `-m` | Path to ToolingManifest.json (for mcp resource) | `/ToolingManifest.json` | | `--app-id` | | Application (client) ID to add permissions to | `clientAppId` from config | -| `--scopes` | | Specific scopes to add (space-separated) | All scopes from ToolingManifest.json | +| `--resource` | `-r` | Target resource API: 'mcp' (default), 'powerplatform' | `mcp` | +| `--scopes` | | Specific scopes to add (space-separated) | All scopes from ToolingManifest.json (mcp) or required (powerplatform) | | `--verbose` | `-v` | Show detailed output | `false` | | `--dry-run` | | Show what would be done without making changes | `false` | @@ -29,7 +35,8 @@ a365 develop add-permissions [options] - Third-party integrations calling MCP servers ### NOT for Agent Blueprints -- Use `a365 setup permissions mcp` for agent blueprint setup +- Use `a365 setup permissions mcp` for agent blueprint MCP setup +- Use `a365 setup permissions copilotstudio` for agent blueprint Power Platform setup ## Understanding the Application ID @@ -50,7 +57,7 @@ The application you're adding permissions to can be the **same application** you ## Prerequisites 1. **Azure CLI Authentication**: `az login` with appropriate permissions -2. **Client Application**: +2. **Client Application**: - Must exist in Azure AD - Must have `Application.ReadWrite.All` permission (to modify app registrations) - Can be configured in `a365.config.json` as `clientAppId` OR provided via `--app-id` @@ -96,6 +103,12 @@ a365 develop add-permissions --app-id 87654321-4321-4321-4321-210987654321 a365 develop add-permissions --scopes McpServers.Mail.All McpServers.Calendar.All ``` +### Add permissions from different resources +```bash +# Add CopilotStudio.Copilots.Invoke permission to the app in config +a365 develop add-permissions --resource powerplatform --scopes CopilotStudio.Copilots.Invoke +``` + ### Combine options with dry-run ```bash # Preview changes to a specific app with specific scopes @@ -106,4 +119,20 @@ a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --sco ```bash # When no config exists, you must provide --app-id a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --scopes McpServers.Mail.All -``` \ No newline at end of file +``` + +## Resource Types + +### MCP Resource (Default) +- **Resource Key**: `mcp` (default) +- **Target**: Agent 365 Tools API +- **Scope Source**: ToolingManifest.json or explicit `--scopes` +- **Example Scopes**: `McpServers.Mail.All`, `McpServers.Calendar.All` + +### Power Platform Resource +- **Resource Key**: `powerplatform` +- **Target**: Power Platform API (`8578e004-a5c6-46e7-913e-12f58912df43`) +- **Scope Source**: **Required** explicit `--scopes` (no defaults) +- **Example Scopes**: `CopilotStudio.Copilots.Invoke` + +**Note**: When using `--resource powerplatform`, the `--scopes` option is required. The command will not default to any scope and will show an error if scopes are not explicitly provided. \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 628e3953..37020f09 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands; /// -/// AddPermissions subcommand - Adds MCP server API permissions to a custom application +/// AddPermissions subcommand - Adds API permissions to a custom application /// internal static class AddPermissionsSubcommand { @@ -22,7 +22,7 @@ public static Command CreateCommand( { var command = new Command( "add-permissions", - "Add MCP server API permissions to a custom application"); + "Add API permissions to a custom application"); var configOption = new Option( ["--config", "-c"], @@ -51,6 +51,11 @@ public static Command CreateCommand( ["--verbose", "-v"], description: "Show detailed output"); + var resourceOption = new Option( + ["--resource", "-r"], + getDefaultValue: () => "mcp", + description: "Target resource API: 'mcp' (default), 'powerplatform'"); + var dryRunOption = new Option( ["--dry-run"], description: "Show what would be done without executing"); @@ -59,30 +64,33 @@ public static Command CreateCommand( command.AddOption(manifestOption); command.AddOption(appIdOption); command.AddOption(scopesOption); + command.AddOption(resourceOption); command.AddOption(verboseOption); command.AddOption(dryRunOption); - command.SetHandler(async (config, manifest, appId, scopes, verbose, dryRun) => + command.SetHandler(async (config, manifest, appId, scopes, resource, verbose, dryRun) => { try { - logger.LogInformation("Adding MCP server permissions to application..."); + logger.LogInformation("Adding API permissions to application..."); logger.LogInformation(""); // Check if config file exists or if --app-id was provided - var setupConfig = File.Exists(config.FullName) - ? await configService.LoadAsync(config.FullName) + var setupConfig = File.Exists(config.FullName) + ? await configService.LoadAsync(config.FullName) : null; if (setupConfig == null && string.IsNullOrWhiteSpace(appId)) { logger.LogError("Configuration file not found: {ConfigPath}", config.FullName); logger.LogInformation(""); - logger.LogInformation("To add MCP server permissions, you must either:"); + logger.LogInformation("To add API permissions, you must either:"); logger.LogInformation(" 1. Create a config file using: a365 config init"); - logger.LogInformation(" 2. Specify the application ID using: a365 develop addpermissions --app-id "); + logger.LogInformation(" 2. Specify the application ID using: a365 develop add-permissions --app-id "); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop addpermissions --app-id 12345678-1234-1234-1234-123456789abc --scopes McpServers.Mail.All"); + logger.LogInformation("Examples:"); + logger.LogInformation(" a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --scopes McpServers.Mail.All"); + logger.LogInformation(" a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --resource powerplatform --scopes CopilotStudio.Copilots.Invoke"); Environment.Exit(1); return; } @@ -103,18 +111,18 @@ public static Command CreateCommand( { logger.LogError("No application ID specified. Use --app-id or ensure ClientAppId is set in config."); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop addpermissions --app-id "); + logger.LogInformation("Example: a365 develop add-permissions --app-id "); Environment.Exit(1); return; } // Determine manifest path - var manifestPath = manifest?.FullName + var manifestPath = manifest?.FullName ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, "ToolingManifest.json"); // Determine which scopes to add string[] requestedScopes; - + if (scopes != null && scopes.Length > 0) { // User provided explicit scopes @@ -124,49 +132,64 @@ public static Command CreateCommand( } else { - // Read scopes from ToolingManifest.json - if (!File.Exists(manifestPath)) + // Only read scopes from ToolingManifest.json for mcp resource + if (resource.ToLowerInvariant() is "mcp") { - logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath); - logger.LogInformation(""); - logger.LogInformation("Please ensure ToolingManifest.json exists in your project directory"); - logger.LogInformation("or specify scopes explicitly with --scopes option."); - logger.LogInformation(""); - logger.LogInformation("Example: a365 develop addpermissions --scopes McpServers.Mail.All McpServers.Calendar.All"); - Environment.Exit(1); - return; - } + // Read scopes from ToolingManifest.json + if (!File.Exists(manifestPath)) + { + logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath); + logger.LogInformation(""); + logger.LogInformation("Please ensure ToolingManifest.json exists in your project directory"); + logger.LogInformation("or specify scopes explicitly with --scopes option."); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop add-permissions --scopes McpServers.Mail.All McpServers.Calendar.All"); + Environment.Exit(1); + return; + } + + logger.LogInformation("Reading MCP server configuration from: {Path}", manifestPath); - logger.LogInformation("Reading MCP server configuration from: {Path}", manifestPath); + // Use ManifestHelper to extract scopes (includes fallback to mappings and McpServersMetadata.Read.All) + requestedScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - // Use ManifestHelper to extract scopes (includes fallback to mappings and McpServersMetadata.Read.All) - requestedScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + if (requestedScopes.Length == 0) + { + logger.LogError("No scopes found in ToolingManifest.json"); + logger.LogInformation("You can specify scopes explicitly with --scopes option."); + Environment.Exit(1); + return; + } - if (requestedScopes.Length == 0) + logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", + requestedScopes.Length, string.Join(", ", requestedScopes)); + } + else { - logger.LogError("No scopes found in ToolingManifest.json"); - logger.LogInformation("You can specify scopes explicitly with --scopes option."); + // For other resources (like powerplatform), scopes are required + logger.LogError("--scopes is required when --resource {resource} is specified.", resource); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop add-permissions --resource {resource} --scopes ExampleScope.ReadWrite.All", resource); Environment.Exit(1); return; } - - logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", - requestedScopes.Length, string.Join(", ", requestedScopes)); } var environment = setupConfig?.Environment ?? "prod"; - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); - - logger.LogInformation("Target resource: Agent 365 Tools ({ResourceAppId})", resourceAppId); + + // Resolve resource configuration based on --resource option + var (resourceAppId, resourceName) = GetResourceConfig(resource, environment); + + logger.LogInformation("Target resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); logger.LogInformation(""); // Dry run mode if (dryRun) { - logger.LogInformation("DRY RUN: Add MCP Server Permissions"); + logger.LogInformation("DRY RUN: Add API Permissions"); logger.LogInformation("Would add the following permissions to application {AppId}:", targetAppId); logger.LogInformation(""); - logger.LogInformation("Resource: {ResourceAppId}", resourceAppId); + logger.LogInformation("Resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); logger.LogInformation(" Scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); logger.LogInformation("No changes made (dry run mode)"); @@ -181,7 +204,7 @@ public static Command CreateCommand( string tenantId = await TenantDetectionHelper.DetectTenantIdAsync(setupConfig, logger) ?? string.Empty; logger.LogInformation("Processing resource: {ResourceAppId}", resourceAppId); - + bool success; try { @@ -207,7 +230,7 @@ public static Command CreateCommand( logger.LogDebug(" {StackTrace}", ex.StackTrace); success = false; } - + logger.LogInformation(""); // Summary @@ -227,11 +250,29 @@ public static Command CreateCommand( } catch (Exception ex) { - logger.LogError(ex, "Failed to add MCP server permissions: {Message}", ex.Message); + logger.LogError(ex, "Failed to add API permissions: {Message}", ex.Message); Environment.Exit(1); } - }, configOption, manifestOption, appIdOption, scopesOption, verboseOption, dryRunOption); + }, configOption, manifestOption, appIdOption, scopesOption, resourceOption, verboseOption, dryRunOption); return command; } + + /// + /// Resolves resource configuration based on the resource key and environment + /// + /// Resource identifier (e.g., "agent365tools", "powerplatform") + /// Environment (e.g., "prod", "test") + /// Tuple containing the resource app ID and display name + private static (string AppId, string Name) GetResourceConfig(string resourceKey, string environment) + { + return resourceKey.ToLowerInvariant() switch + { + "mcp" => + (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools"), + "powerplatform" => + (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API"), + _ => throw new ArgumentException($"Unknown resource: {resourceKey}. Valid options are: mcp, powerplatform") + }; + } } From 386f550a42be25234a0d770538fbb5de92ddae9a Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Mon, 2 Feb 2026 15:28:47 -0800 Subject: [PATCH 04/23] remove unused setupall flag --- .../SetupSubcommands/CopilotStudioSubcommand.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs index 10ab1248..882eca85 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -89,8 +89,7 @@ await ConfigureAsync( executor, setupConfig, graphApiService, - blueprintService, - false); + blueprintService); }, configOption, verboseOption, dryRunOption); @@ -109,7 +108,6 @@ public static async Task ConfigureAsync( Models.Agent365Config setupConfig, GraphApiService graphService, AgentBlueprintService blueprintService, - bool iSetupAll, SetupResults? setupResults = null, CancellationToken cancellationToken = default) { @@ -141,19 +139,12 @@ await SetupHelpers.EnsureResourcePermissionsAsync( logger.LogInformation(""); logger.LogInformation("CopilotStudio permissions configured successfully"); logger.LogInformation(""); - if (!iSetupAll) - { - logger.LogInformation("Your agent blueprint can now invoke Copilot Studio copilots."); - } + logger.LogInformation("Your agent blueprint can now invoke Copilot Studio copilots."); return true; } catch (Exception ex) { logger.LogError(ex, "Failed to configure CopilotStudio permissions: {Message}", ex.Message); - if (iSetupAll) - { - throw; - } return false; } } From 84816a12d178eda801c1f4fbc924ff97d8d53915 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Thu, 5 Feb 2026 16:15:18 -0800 Subject: [PATCH 05/23] initial code --- docs/commands/develop/develop-gettoken.md | 35 +- .../DevelopSubcommands/GetTokenSubcommand.cs | 348 ++++++++++++------ .../Commands/GetTokenSubcommandTests.cs | 260 +++++++++++-- 3 files changed, 500 insertions(+), 143 deletions(-) diff --git a/docs/commands/develop/develop-gettoken.md b/docs/commands/develop/develop-gettoken.md index f219ff03..cb637901 100644 --- a/docs/commands/develop/develop-gettoken.md +++ b/docs/commands/develop/develop-gettoken.md @@ -23,6 +23,22 @@ a365 develop get-token [options] | `--output` | `-o` | Output format: table, json, or raw | `table` | | `--verbose` | `-v` | Show detailed output including full token | `false` | | `--force-refresh` | | Force token refresh bypassing cache | `false` | +| `--resource` | | Resource keyword to get token for (mcp, powerplatform) | `mcp` | +| `--resource-id` | | Resource application ID (GUID) for custom resources | Agent 365 Tools App ID | + +### Resource Options + +The `--resource` and `--resource-id` options allow you to acquire tokens for different Azure resources: + +- **`--resource`**: Use a keyword to select a predefined resource + - `mcp` (default): Agent 365 Tools for MCP servers + - `powerplatform`: Power Platform API + +- **`--resource-id`**: Specify a custom resource application ID (GUID) for resources not covered by keywords + +> **Note**: `--resource` and `--resource-id` are mutually exclusive. Use one or the other, not both. + +> **Important**: When using `--resource` or `--resource-id`, the `--scopes` option is **required**. Manifest-based scope resolution is only supported for the default MCP flow. ## When to Use This Command @@ -101,12 +117,23 @@ TOKEN=$(a365 develop get-token --output raw) curl -H "Authorization: Bearer $TOKEN" https://agent365.svc.cloud.microsoft/agents/discoverToolServers ``` +### Get token for Power Platform API +```bash +a365 develop get-token --resource powerplatform --scopes https://api.powerplatform.com/.default +``` + +### Get token for a custom resource +```bash +a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default +``` + ## Authentication Flow -1. **Application Selection**: Uses `--app-id` or `clientAppId` from config -2. **Scope Resolution**: Uses `--scopes` or reads from `ToolingManifest.json` -3. **Token Acquisition**: Opens browser for interactive OAuth2 authentication -4. **Token Caching**: Cached in local storage for reuse (until expiration or `--force-refresh`) +1. **Resource Selection**: Uses `--resource-id`, `--resource` keyword, or defaults to Agent 365 Tools (MCP) +2. **Application Selection**: Uses `--app-id` or `clientAppId` from config +3. **Scope Resolution**: Uses `--scopes` or reads from `ToolingManifest.json` (manifest only for default MCP flow) +4. **Token Acquisition**: Opens browser for interactive OAuth2 authentication +5. **Token Caching**: Cached in local storage for reuse (until expiration or `--force-refresh`) ## Token Storage for Development diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 4398ced5..768ceb5c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -63,6 +63,22 @@ public static Command CreateCommand( ["--force-refresh"], description: "Force token refresh even if cached token is valid"); + var resourceOption = new Option( + ["--resource"], + description: "Resource keyword to get token for. Available: mcp (default), powerplatform. " + + "When specified, --scopes is required.") + { + IsRequired = false + }; + + var resourceIdOption = new Option( + ["--resource-id"], + description: "Resource application ID (GUID) to get token for. " + + "When specified, --scopes is required.") + { + IsRequired = false + }; + command.AddOption(configOption); command.AddOption(appIdOption); command.AddOption(manifestOption); @@ -70,12 +86,36 @@ public static Command CreateCommand( command.AddOption(outputFormatOption); command.AddOption(verboseOption); command.AddOption(forceRefreshOption); + command.AddOption(resourceOption); + command.AddOption(resourceIdOption); - command.SetHandler(async (config, appId, manifest, scopes, outputFormat, verbose, forceRefresh) => + command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { + // Extract option values from context + var config = context.ParseResult.GetValueForOption(configOption)!; + var appId = context.ParseResult.GetValueForOption(appIdOption); + var manifest = context.ParseResult.GetValueForOption(manifestOption); + var scopes = context.ParseResult.GetValueForOption(scopesOption); + var outputFormat = context.ParseResult.GetValueForOption(outputFormatOption)!; + var verbose = context.ParseResult.GetValueForOption(verboseOption); + var forceRefresh = context.ParseResult.GetValueForOption(forceRefreshOption); + var resource = context.ParseResult.GetValueForOption(resourceOption); + var resourceId = context.ParseResult.GetValueForOption(resourceIdOption); + try { - logger.LogInformation("Retrieving bearer token for MCP servers..."); + // Validate mutual exclusivity of --resource and --resource-id + if (!string.IsNullOrWhiteSpace(resource) && !string.IsNullOrWhiteSpace(resourceId)) + { + logger.LogError("Cannot specify both --resource and --resource-id. Use one or the other."); + Environment.Exit(1); + return; + } + + // Determine if custom resource is being used + bool isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); + + logger.LogInformation("Retrieving bearer token..."); logger.LogInformation(""); // Check if config file exists or if --app-id was provided @@ -99,21 +139,61 @@ public static Command CreateCommand( return; } - // Determine manifest path - var manifestPath = manifest?.FullName - ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, "ToolingManifest.json"); + // Determine environment + var environment = setupConfig?.Environment ?? "prod"; + + // Resolve resource app ID + string resourceAppId; + string resourceDisplayName; + if (!string.IsNullOrWhiteSpace(resourceId)) + { + // User provided explicit resource ID + resourceAppId = resourceId; + resourceDisplayName = $"Custom Resource ({resourceId})"; + logger.LogInformation("Using custom resource ID: {ResourceId}", resourceId); + } + else + { + // Resolve resource keyword to GUID (default to "mcp" if null) + var resolved = ResolveResourceAppId(resource, environment); + if (resolved == null) + { + logger.LogError("Unknown resource keyword '{Resource}'. Valid options: mcp, powerplatform", resource); + Environment.Exit(1); + return; + } + + resourceAppId = resolved.Value.resourceAppId; + resourceDisplayName = resolved.Value.displayName; + logger.LogInformation("Using resource: {DisplayName}", resourceDisplayName); + } // Determine which scopes to request string[] requestedScopes; - + + // Default MCP flow: manifest or explicit scopes if (scopes != null && scopes.Length > 0) { // User provided explicit scopes requestedScopes = scopes; logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", requestedScopes)); } + else if (isCustomResource) { + logger.LogError("The --scopes option is required when using --resource or --resource-id."); + logger.LogInformation(""); + logger.LogInformation("Manifest-based scopes are only supported for the default flow."); + logger.LogInformation("Please omit the --resource and --resource-id options if you'd like to use manifest-based scopes."); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop get-token --resource powerplatform --scopes https://api.powerplatform.com/.default"); + Environment.Exit(1); + return; + } else { + // Determine manifest path + var manifestPath = manifest?.FullName + ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, "ToolingManifest.json"); + // Read scopes from ToolingManifest.json if (!File.Exists(manifestPath)) { @@ -122,7 +202,7 @@ public static Command CreateCommand( logger.LogInformation("Please ensure ToolingManifest.json exists in your project directory"); logger.LogInformation("or specify scopes explicitly with --scopes option."); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop gettoken --scopes McpServers.Mail.All McpServers.Calendar.All"); + logger.LogInformation("Example: a365 develop get-token --scopes McpServers.Mail.All McpServers.Calendar.All"); Environment.Exit(1); return; } @@ -140,121 +220,171 @@ public static Command CreateCommand( return; } - logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", + logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", requestedScopes.Length, string.Join(", ", requestedScopes)); } logger.LogInformation(""); - - // Get the Agent 365 Tools resource App ID for the environment - var environment = setupConfig?.Environment ?? "prod"; - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); logger.LogInformation("Agent 365 Tools Resource App ID: {AppId}", resourceAppId); logger.LogInformation("Requesting scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); - // Acquire token with explicit scopes - logger.LogInformation("Acquiring access token with explicit scopes..."); - - // Determine tenant ID (from config or detect from Azure CLI) - string? tenantId = await TenantDetectionHelper.DetectTenantIdAsync(setupConfig, logger); - - try - { - // Determine which client app to use for authentication - string? clientAppId = null; - if (!string.IsNullOrWhiteSpace(appId)) - { - // User specified --app-id: use it as the client (caller) application - clientAppId = appId; - logger.LogInformation("Using custom client application: {ClientAppId}", clientAppId); - } - else if (setupConfig != null && !string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - // Use client app from config - clientAppId = setupConfig.ClientAppId; - logger.LogInformation("Using client application from config: {ClientAppId}", clientAppId); - } - else - { - throw new InvalidOperationException("No client application ID specified. Use --app-id or ensure ClientAppId is set in config."); - } - - logger.LogInformation(""); - - // Use GetAccessTokenWithScopesAsync for explicit scope control - var token = await authService.GetAccessTokenWithScopesAsync( - resourceAppId, - requestedScopes, - tenantId, - forceRefresh, - clientAppId, - useInteractiveBrowser: true); - - if (string.IsNullOrWhiteSpace(token)) - { - logger.LogError("Failed to acquire token"); - Environment.Exit(1); - return; - } + // Acquire and display token + await AcquireAndDisplayTokenAsync( + resourceAppId, + resourceDisplayName, + requestedScopes, + environment, + isCustomResource, + appId, + setupConfig, + outputFormat, + verbose, + forceRefresh, + authService, + logger); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to retrieve bearer token: {Message}", ex.Message); + Environment.Exit(1); + } + }); - logger.LogInformation("[SUCCESS] Token acquired successfully with scopes: {Scopes}", - string.Join(", ", requestedScopes)); - logger.LogInformation(""); + return command; + } - var tokenCachePath = Path.Combine( - ConfigService.GetGlobalConfigDirectory(), - AuthenticationConstants.TokenCacheFileName); + /// + /// Resolves a resource keyword to its corresponding resource app ID. + /// + /// The resource keyword (e.g., "mcp", "powerplatform"). + /// The environment to use for MCP resource resolution. + /// A tuple containing the resource app ID and display name, or null if the keyword is unknown. + private static (string resourceAppId, string displayName)? ResolveResourceAppId(string? keyword, string environment) + { + if (string.IsNullOrWhiteSpace(keyword)) + { + keyword = "mcp"; // Default to MCP if no keyword provided + } - // Create a single result representing the consolidated token - var tokenResult = new McpServerTokenResult - { - ServerName = "Agent 365 Tools (All MCP Servers)", - Url = ConfigConstants.GetDiscoverEndpointUrl(environment), - Scope = string.Join(", ", requestedScopes), - Audience = resourceAppId, - Success = true, - Token = token, - ExpiresOn = DateTime.UtcNow.AddHours(1), // Estimate - CacheFilePath = tokenCachePath - }; - - var tokenResults = new List { tokenResult }; - - // Display results based on output format - DisplayResults(tokenResults, outputFormat, verbose, logger); - - // Save bearer token to project configuration files - if (setupConfig != null) - { - await ProjectSettingsSyncHelper.SaveBearerTokenToPlatformConfigAsync(token, setupConfig, logger); - } - else - { - // No config file: user must manually copy the token - logger.LogInformation(""); - logger.LogInformation("Note: To use this token in your samples, manually add it to:"); - logger.LogInformation(" - .NET projects: Properties/launchSettings.json > profiles > environmentVariables > BEARER_TOKEN"); - logger.LogInformation(" - Python/Node.js projects: .env file as BEARER_TOKEN={Token}", token); - logger.LogInformation(""); - } + return keyword.ToLowerInvariant() switch + { + "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)"), + "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API"), + _ => null + }; + } - logger.LogInformation("Token acquired successfully!"); - } - catch (Exception ex) - { - logger.LogError("Failed to acquire token: {Message}", ex.Message); - Environment.Exit(1); - } + /// + /// Acquires an access token and displays the results. + /// + private static async Task AcquireAndDisplayTokenAsync( + string resourceAppId, + string resourceDisplayName, + string[] requestedScopes, + string environment, + bool isCustomResource, + string? appId, + Agent365Config? setupConfig, + string outputFormat, + bool verbose, + bool forceRefresh, + AuthenticationService authService, + ILogger logger) + { + // Acquire token with explicit scopes + logger.LogInformation("Acquiring access token with explicit scopes..."); + + // Determine tenant ID (from config or detect from Azure CLI) + string? tenantId = await TenantDetectionHelper.DetectTenantIdAsync(setupConfig, logger); + + try + { + // Determine which client app to use for authentication + string? clientAppId = null; + if (!string.IsNullOrWhiteSpace(appId)) + { + // User specified --app-id: use it as the client (caller) application + clientAppId = appId; + logger.LogInformation("Using custom client application: {ClientAppId}", clientAppId); } - catch (Exception ex) + else if (setupConfig != null && !string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) { - logger.LogError(ex, "Failed to retrieve bearer token: {Message}", ex.Message); + // Use client app from config + clientAppId = setupConfig.ClientAppId; + logger.LogInformation("Using client application from config: {ClientAppId}", clientAppId); + } + else + { + throw new InvalidOperationException("No client application ID specified. Use --app-id or ensure ClientAppId is set in config."); + } + + logger.LogInformation(""); + + // Use GetAccessTokenWithScopesAsync for explicit scope control + var token = await authService.GetAccessTokenWithScopesAsync( + resourceAppId, + requestedScopes, + tenantId, + forceRefresh, + clientAppId, + useInteractiveBrowser: true); + + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogError("Failed to acquire token"); Environment.Exit(1); + return; } - }, configOption, appIdOption, manifestOption, scopesOption, outputFormatOption, verboseOption, forceRefreshOption); - return command; + logger.LogInformation("[SUCCESS] Token acquired successfully with scopes: {Scopes}", + string.Join(", ", requestedScopes)); + logger.LogInformation(""); + + var tokenCachePath = Path.Combine( + ConfigService.GetGlobalConfigDirectory(), + AuthenticationConstants.TokenCacheFileName); + + // Create a single result representing the consolidated token + var tokenResult = new McpServerTokenResult + { + ServerName = resourceDisplayName, + Url = isCustomResource ? null : ConfigConstants.GetDiscoverEndpointUrl(environment), + Scope = string.Join(", ", requestedScopes), + Audience = resourceAppId, + Success = true, + Token = token, + ExpiresOn = DateTime.UtcNow.AddHours(1), // Estimate + CacheFilePath = tokenCachePath + }; + + var tokenResults = new List { tokenResult }; + + // Display results based on output format + DisplayResults(tokenResults, outputFormat, verbose, logger); + + // Save bearer token to project configuration files + if (setupConfig != null) + { + await ProjectSettingsSyncHelper.SaveBearerTokenToPlatformConfigAsync(token, setupConfig, logger); + } + else + { + // No config file: user must manually copy the token + logger.LogInformation(""); + logger.LogInformation("Note: To use this token in your samples, manually add it to:"); + logger.LogInformation(" - .NET projects: Properties/launchSettings.json > profiles > environmentVariables > BEARER_TOKEN"); + logger.LogInformation(" - Python/Node.js projects: .env file as BEARER_TOKEN={Token}", token); + logger.LogInformation(""); + } + + logger.LogInformation("Token acquired successfully!"); + } + catch (Exception ex) + { + logger.LogError("Failed to acquire token: {Message}", ex.Message); + Environment.Exit(1); + } } private static void DisplayResults( @@ -337,8 +467,8 @@ private static void DisplayJsonResults(List results, bool cacheFilePath = r.CacheFilePath }); - var json = JsonSerializer.Serialize(output, new JsonSerializerOptions - { + var json = JsonSerializer.Serialize(output, new JsonSerializerOptions + { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); @@ -360,10 +490,10 @@ private static void DisplayRawResults(List results, bool v Console.Error.WriteLine($"# Scope: {result.Scope}"); Console.Error.WriteLine($"# Audience: {result.Audience}"); } - + // Write token to stdout for piping to other tools Console.WriteLine(result.Token); - + if (verbose) { Console.Error.WriteLine(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs index 1ee48296..cd1345d0 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs @@ -152,17 +152,19 @@ public void CreateCommand_ShouldHaveAllRequiredOptions() var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); // Assert - command.Options.Should().HaveCount(7); + command.Options.Should().HaveCount(9); var optionNames = command.Options.Select(opt => opt.Name).ToList(); - optionNames.Should().Contain(new[] - { - "config", - "app-id", - "manifest", - "scopes", - "output", - "verbose", - "force-refresh" + optionNames.Should().Contain(new[] + { + "config", + "app-id", + "manifest", + "scopes", + "output", + "verbose", + "force-refresh", + "resource", + "resource-id" }); } @@ -229,11 +231,11 @@ public void ScopeResolution_WithExplicitScopes_ShouldUseProvidedScopes() public void ScopeResolution_WithDuplicateScopes_ShouldDeduplicateCaseInsensitive() { // Arrange - var scopesWithDuplicates = new[] - { - "McpServers.Mail.All", - "mcpservers.mail.all", - "McpServers.Calendar.All" + var scopesWithDuplicates = new[] + { + "McpServers.Mail.All", + "mcpservers.mail.all", + "McpServers.Calendar.All" }; // Act @@ -496,8 +498,8 @@ public void TenantIdDetection_FromConfig_ShouldUseConfigValue() }; // Act - var tenantId = !string.IsNullOrWhiteSpace(config.TenantId) - ? config.TenantId + var tenantId = !string.IsNullOrWhiteSpace(config.TenantId) + ? config.TenantId : null; // Assert @@ -528,7 +530,7 @@ public void LaunchSettingsUpdate_WithBearerTokenInProfile_ShouldUpdateToken() // Act var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out var profiles); var hasBearerToken = false; - + if (hasProfiles) { foreach (var profile in profiles.EnumerateObject()) @@ -571,7 +573,7 @@ public void LaunchSettingsUpdate_WithoutBearerTokenInProfile_ShouldNotAddToken() // Act var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out var profiles); var hasBearerToken = false; - + if (hasProfiles) { foreach (var profile in profiles.EnumerateObject()) @@ -621,10 +623,10 @@ public void LaunchSettingsUpdate_PreservesOtherEnvironmentVariables() // Assert envVars.TryGetProperty("ASPNETCORE_ENVIRONMENT", out var aspnetEnv).Should().BeTrue(); aspnetEnv.GetString().Should().Be("Development"); - + envVars.TryGetProperty("CUSTOM_VAR", out var customVar).Should().BeTrue(); customVar.GetString().Should().Be("custom-value"); - + envVars.TryGetProperty(AuthenticationConstants.BearerTokenEnvironmentVariable, out var bearerToken).Should().BeTrue(); } @@ -657,7 +659,7 @@ public void LaunchSettingsUpdate_MultipleProfiles_OnlyUpdatesProfilesWithBearerT // Act var profilesWithBearerToken = new List(); var profiles = launchSettings.RootElement.GetProperty("profiles"); - + foreach (var profile in profiles.EnumerateObject()) { if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) @@ -712,7 +714,7 @@ public void EnvFileUpdate_ExistingBearerToken_ShouldUpdateLine() // Act var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => + var existingIndex = envLines.FindIndex(l => l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) @@ -739,7 +741,7 @@ public void EnvFileUpdate_NoBearerToken_ShouldAddNewLine() // Act var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => + var existingIndex = envLines.FindIndex(l => l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) @@ -769,7 +771,7 @@ public void EnvFileUpdate_CaseInsensitiveMatch_ShouldUpdateCorrectly() // Act var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => + var existingIndex = envLines.FindIndex(l => l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) @@ -798,7 +800,7 @@ public void EnvFileUpdate_PreservesOtherVariables() // Act var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => + var existingIndex = envLines.FindIndex(l => l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) @@ -824,7 +826,7 @@ public void EnvFileUpdate_EmptyFile_ShouldAddToken() // Act var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => + var existingIndex = envLines.FindIndex(l => l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); if (existingIndex >= 0) @@ -865,7 +867,7 @@ public void PlatformDetection_PythonProject_ShouldDetectCorrectly() var projectFiles = new[] { "pyproject.toml", "requirements.txt", "setup.py" }; // Act - var hasPythonMarkers = projectFiles.Any(f => + var hasPythonMarkers = projectFiles.Any(f => f.Equals("pyproject.toml", StringComparison.OrdinalIgnoreCase) || f.Equals("requirements.txt", StringComparison.OrdinalIgnoreCase)); @@ -880,7 +882,7 @@ public void PlatformDetection_NodeProject_ShouldDetectCorrectly() var projectFiles = new[] { "package.json", "package-lock.json" }; // Act - var hasPackageJson = projectFiles.Any(f => + var hasPackageJson = projectFiles.Any(f => f.Equals("package.json", StringComparison.OrdinalIgnoreCase)); // Assert @@ -935,7 +937,7 @@ public void TokenStorage_ValidateExpectedFilePaths() { // Arrange var projectDir = "/path/to/project"; - + // Act var launchSettingsPath = Path.Combine(projectDir, "Properties", "launchSettings.json"); var envPath = Path.Combine(projectDir, ".env"); @@ -946,4 +948,202 @@ public void TokenStorage_ValidateExpectedFilePaths() } #endregion + + #region Resource Option Tests + + [Fact] + public void CreateCommand_ShouldHaveResourceOption() + { + // Act + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); + + // Assert + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.Aliases.Should().Contain("--resource"); + } + + [Fact] + public void CreateCommand_ShouldHaveResourceIdOption() + { + // Act + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); + + // Assert + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.Aliases.Should().Contain("--resource-id"); + } + + [Fact] + public void CreateCommand_ResourceOptionDescription_ShouldListAvailableKeywords() + { + // Act + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); + + // Assert + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.Description.Should().Contain("mcp"); + resourceOption.Description.Should().Contain("powerplatform"); + } + + [Fact] + public void CreateCommand_ResourceIdOptionDescription_ShouldMentionGuid() + { + // Act + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); + + // Assert + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.Description.Should().Contain("GUID"); + } + + [Fact] + public void CreateCommand_ResourceOption_ShouldNotBeRequired() + { + // Act + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); + + // Assert + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.IsRequired.Should().BeFalse(); + } + + [Fact] + public void CreateCommand_ResourceIdOption_ShouldNotBeRequired() + { + // Act + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); + + // Assert + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.IsRequired.Should().BeFalse(); + } + + [Fact] + public void ResourceKeyword_Mcp_ShouldResolveToAgent365ToolsResourceAppId() + { + // Arrange + var environment = "prod"; + var expectedResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); + + // Act & Assert + // The mcp keyword should resolve to the Agent 365 Tools resource app ID + expectedResourceAppId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void ResourceKeyword_PowerPlatform_ShouldResolveToPowerPlatformApiResourceAppId() + { + // Arrange & Act + var expectedResourceAppId = MosConstants.PowerPlatformApiResourceAppId; + + // Assert + expectedResourceAppId.Should().Be("8578e004-a5c6-46e7-913e-12f58912df43"); + } + + [Fact] + public void ResourceValidation_BothResourceAndResourceId_ShouldBeMutuallyExclusive() + { + // Arrange + var resource = "mcp"; + var resourceId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var bothProvided = !string.IsNullOrWhiteSpace(resource) && !string.IsNullOrWhiteSpace(resourceId); + + // Assert + // Both being provided is an error condition + bothProvided.Should().BeTrue(); + } + + [Fact] + public void ResourceValidation_NeitherProvided_ShouldUseDefaultMcpFlow() + { + // Arrange + string? resource = null; + string? resourceId = null; + + // Act + var isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); + + // Assert + isCustomResource.Should().BeFalse(); + } + + [Fact] + public void ResourceValidation_OnlyResourceProvided_ShouldBeCustomResource() + { + // Arrange + string? resource = "powerplatform"; + string? resourceId = null; + + // Act + var isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); + + // Assert + isCustomResource.Should().BeTrue(); + } + + [Fact] + public void ResourceValidation_OnlyResourceIdProvided_ShouldBeCustomResource() + { + // Arrange + string? resource = null; + string? resourceId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); + + // Assert + isCustomResource.Should().BeTrue(); + } + + [Fact] + public void ScopeValidation_CustomResourceWithoutScopes_ShouldRequireExplicitScopes() + { + // Arrange + var isCustomResource = true; + string[]? scopes = null; + + // Act + var scopesRequired = isCustomResource && (scopes == null || scopes.Length == 0); + + // Assert + scopesRequired.Should().BeTrue(); + } + + [Fact] + public void ScopeValidation_CustomResourceWithScopes_ShouldNotRequireManifest() + { + // Arrange + var isCustomResource = true; + string[] scopes = new[] { ".default" }; + + // Act + var hasExplicitScopes = scopes != null && scopes.Length > 0; + + // Assert + hasExplicitScopes.Should().BeTrue(); + } + + [Fact] + public void ScopeValidation_DefaultFlowWithoutScopes_ShouldUseManifest() + { + // Arrange + var isCustomResource = false; + string[]? scopes = null; + + // Act + var shouldReadManifest = !isCustomResource && (scopes == null || scopes.Length == 0); + + // Assert + shouldReadManifest.Should().BeTrue(); + } + + #endregion } From f77a2fb011b6df0c196bf568c8417dd37d5338db Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Thu, 5 Feb 2026 16:19:39 -0800 Subject: [PATCH 06/23] try cleaning diff --- .../DevelopSubcommands/GetTokenSubcommand.cs | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 768ceb5c..f1240860 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -254,27 +254,6 @@ await AcquireAndDisplayTokenAsync( return command; } - /// - /// Resolves a resource keyword to its corresponding resource app ID. - /// - /// The resource keyword (e.g., "mcp", "powerplatform"). - /// The environment to use for MCP resource resolution. - /// A tuple containing the resource app ID and display name, or null if the keyword is unknown. - private static (string resourceAppId, string displayName)? ResolveResourceAppId(string? keyword, string environment) - { - if (string.IsNullOrWhiteSpace(keyword)) - { - keyword = "mcp"; // Default to MCP if no keyword provided - } - - return keyword.ToLowerInvariant() switch - { - "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)"), - "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API"), - _ => null - }; - } - /// /// Acquires an access token and displays the results. /// @@ -387,6 +366,27 @@ private static async Task AcquireAndDisplayTokenAsync( } } + /// + /// Resolves a resource keyword to its corresponding resource app ID. + /// + /// The resource keyword (e.g., "mcp", "powerplatform"). + /// The environment to use for MCP resource resolution. + /// A tuple containing the resource app ID and display name, or null if the keyword is unknown. + private static (string resourceAppId, string displayName)? ResolveResourceAppId(string? keyword, string environment) + { + if (string.IsNullOrWhiteSpace(keyword)) + { + keyword = "mcp"; // Default to MCP if no keyword provided + } + + return keyword.ToLowerInvariant() switch + { + "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)"), + "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API"), + _ => null + }; + } + private static void DisplayResults( List results, string outputFormat, From 9c4cc44e563dde9007950d9132961e2ca24f423a Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Thu, 5 Feb 2026 16:31:16 -0800 Subject: [PATCH 07/23] remove test cases that don't test any code from source --- .../Commands/GetTokenSubcommandTests.cs | 952 +----------------- 1 file changed, 34 insertions(+), 918 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs index cd1345d0..9675cac9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs @@ -3,8 +3,6 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands; -using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -20,18 +18,12 @@ public class GetTokenSubcommandTests private readonly ILogger _mockLogger; private readonly IConfigService _mockConfigService; private readonly AuthenticationService _mockAuthService; - private readonly string _testManifestPath; - private readonly string _testConfigPath; public GetTokenSubcommandTests() { _mockLogger = Substitute.For(); _mockConfigService = Substitute.For(); _mockAuthService = Substitute.For(Substitute.For>()); - - // Setup test file paths - _testManifestPath = Path.Combine(Path.GetTempPath(), $"TestManifest_{Guid.NewGuid()}.json"); - _testConfigPath = Path.Combine(Path.GetTempPath(), $"TestConfig_{Guid.NewGuid()}.json"); } #region Command Structure Tests @@ -146,835 +138,56 @@ public void CreateCommand_ShouldHaveForceRefreshOption() } [Fact] - public void CreateCommand_ShouldHaveAllRequiredOptions() + public void CreateCommand_ShouldHaveResourceOption() { // Act var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); // Assert - command.Options.Should().HaveCount(9); - var optionNames = command.Options.Select(opt => opt.Name).ToList(); - optionNames.Should().Contain(new[] - { - "config", - "app-id", - "manifest", - "scopes", - "output", - "verbose", - "force-refresh", - "resource", - "resource-id" - }); - } - - #endregion - - #region Configuration Loading Tests - - [Fact] - public void ConfigValidation_WithValidConfig_ShouldHaveClientAppId() - { - // Arrange - var config = new Agent365Config - { - TenantId = "test-tenant", - ClientAppId = "client-app-123", - DeploymentProjectPath = "." - }; - - // Act - var clientAppId = config.ClientAppId; - - // Assert - clientAppId.Should().Be("client-app-123"); - } - - [Fact] - public void ConfigValidation_WithEnvironmentSet_ShouldUseCorrectEnvironment() - { - // Arrange - var config = new Agent365Config - { - TenantId = "test-tenant", - ClientAppId = "client-app-123", - Environment = "preprod" - }; - - // Act - var environment = config.Environment; - - // Assert - environment.Should().Be("preprod"); - } - - #endregion - - #region Scope Resolution Tests - - [Fact] - public void ScopeResolution_WithExplicitScopes_ShouldUseProvidedScopes() - { - // Arrange - var explicitScopes = new[] { "McpServers.Mail.All", "McpServers.Calendar.All" }; - - // Act - var scopeSet = new HashSet(explicitScopes, StringComparer.OrdinalIgnoreCase); - - // Assert - scopeSet.Should().HaveCount(2); - scopeSet.Should().Contain("McpServers.Mail.All"); - scopeSet.Should().Contain("McpServers.Calendar.All"); - } - - [Fact] - public void ScopeResolution_WithDuplicateScopes_ShouldDeduplicateCaseInsensitive() - { - // Arrange - var scopesWithDuplicates = new[] - { - "McpServers.Mail.All", - "mcpservers.mail.all", - "McpServers.Calendar.All" - }; - - // Act - var scopeSet = new HashSet(scopesWithDuplicates, StringComparer.OrdinalIgnoreCase); - - // Assert - scopeSet.Should().HaveCount(2); - } - - [Fact] - public void ScopeResolution_WithEmptyScopes_ShouldBeEmpty() - { - // Arrange - var emptyScopes = Array.Empty(); - - // Act - var scopeSet = new HashSet(emptyScopes); - - // Assert - scopeSet.Should().BeEmpty(); - } - - [Fact] - public void ScopeResolution_FromManifest_ShouldExtractUniqueScopes() - { - // Arrange - var manifest = new ToolingManifest - { - McpServers = new[] - { - new McpServerConfig { McpServerName = "mcp_MailTools", Scope = "McpServers.Mail.All" }, - new McpServerConfig { McpServerName = "mcp_CalendarTools", Scope = "McpServers.Calendar.All" }, - new McpServerConfig { McpServerName = "mcp_DuplicateMail", Scope = "McpServers.Mail.All" } - } - }; - - // Act - var scopeSet = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var server in manifest.McpServers) - { - if (!string.IsNullOrWhiteSpace(server.Scope)) - { - scopeSet.Add(server.Scope); - } - } - - // Assert - scopeSet.Should().HaveCount(2); - scopeSet.Should().Contain("McpServers.Mail.All"); - scopeSet.Should().Contain("McpServers.Calendar.All"); - } - - [Fact] - public void ScopeResolution_WithNullScopes_ShouldSkip() - { - // Arrange - var manifest = new ToolingManifest - { - McpServers = new[] - { - new McpServerConfig { McpServerName = "mcp_MailTools", Scope = "McpServers.Mail.All" }, - new McpServerConfig { McpServerName = "mcp_NoScope", Scope = null }, - new McpServerConfig { McpServerName = "mcp_EmptyScope", Scope = "" } - } - }; - - // Act - var scopeSet = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var server in manifest.McpServers) - { - if (!string.IsNullOrWhiteSpace(server.Scope)) - { - scopeSet.Add(server.Scope); - } - } - - // Assert - scopeSet.Should().HaveCount(1); - scopeSet.Should().Contain("McpServers.Mail.All"); - } - - #endregion - - #region Manifest File Tests - - [Fact] - public void ManifestParsing_WithValidManifest_ShouldParse() - { - // Arrange - var manifestContent = @"{ - ""mcpServers"": [ - { - ""mcpServerName"": ""mcp_MailTools"", - ""scope"": ""McpServers.Mail.All"" - }, - { - ""mcpServerName"": ""mcp_CalendarTools"", - ""scope"": ""McpServers.Calendar.All"" - } - ] - }"; - - // Act - var manifest = System.Text.Json.JsonSerializer.Deserialize(manifestContent); - - // Assert - manifest.Should().NotBeNull(); - manifest!.McpServers.Should().HaveCount(2); - manifest.McpServers[0].Scope.Should().Be("McpServers.Mail.All"); - manifest.McpServers[1].Scope.Should().Be("McpServers.Calendar.All"); - } - - [Fact] - public void ManifestParsing_WithEmptyServers_ShouldReturnEmptyArray() - { - // Arrange - var manifestContent = @"{ ""mcpServers"": [] }"; - - // Act - var manifest = System.Text.Json.JsonSerializer.Deserialize(manifestContent); - - // Assert - manifest.Should().NotBeNull(); - manifest!.McpServers.Should().BeEmpty(); - } - - #endregion - - #region Output Format Tests - - [Fact] - public void OutputFormat_TableFormat_IsDefault() - { - // Arrange - var defaultFormat = "table"; - - // Act & Assert - defaultFormat.Should().Be("table"); - } - - [Fact] - public void OutputFormat_SupportedFormats_ShouldIncludeAllOptions() - { - // Arrange - var supportedFormats = new[] { "table", "json", "raw" }; - - // Act & Assert - supportedFormats.Should().Contain("table"); - supportedFormats.Should().Contain("json"); - supportedFormats.Should().Contain("raw"); - supportedFormats.Should().HaveCount(3); - } - - [Fact] - public void OutputFormat_CaseInsensitive_ShouldMatch() - { - // Arrange - var formats = new[] { "TABLE", "table", "Table", "JSON", "json", "RAW", "raw" }; - - // Act & Assert - foreach (var format in formats) - { - var normalized = format.ToLowerInvariant(); - normalized.Should().BeOneOf("table", "json", "raw"); - } - } - - #endregion - - #region Error Handling Tests - - [Fact] - public void ErrorHandling_MissingConfigAndAppId_ShouldBeDetectable() - { - // Arrange - var configExists = false; - var appId = string.Empty; - - // Act - var hasRequiredInfo = configExists || !string.IsNullOrWhiteSpace(appId); - - // Assert - hasRequiredInfo.Should().BeFalse(); - } - - [Fact] - public void ErrorHandling_ConfigExistsOrAppIdProvided_ShouldBeValid() - { - // Arrange - Test with config - var configExists = true; - var appId = string.Empty; - - // Act - var hasRequiredInfo = configExists || !string.IsNullOrWhiteSpace(appId); - - // Assert - hasRequiredInfo.Should().BeTrue(); - - // Arrange - Test with app ID - configExists = false; - appId = "client-app-123"; - - // Act - hasRequiredInfo = configExists || !string.IsNullOrWhiteSpace(appId); - - // Assert - hasRequiredInfo.Should().BeTrue(); - } - - [Fact] - public void ErrorHandling_MissingManifestAndScopes_ShouldBeDetectable() - { - // Arrange - var manifestExists = false; - string[]? explicitScopes = null; - - // Act - var canProceed = manifestExists || (explicitScopes != null && explicitScopes.Length > 0); - - // Assert - canProceed.Should().BeFalse(); - } - - [Fact] - public void ErrorHandling_ManifestExistsOrScopesProvided_ShouldBeValid() - { - // Arrange - Test with manifest - var manifestExists = true; - string[]? explicitScopes = null; - - // Act - var canProceed = manifestExists || (explicitScopes != null && explicitScopes.Length > 0); - - // Assert - canProceed.Should().BeTrue(); - - // Arrange - Test with explicit scopes - manifestExists = false; - explicitScopes = new[] { "McpServers.Mail.All" }; - - // Act - canProceed = manifestExists || (explicitScopes != null && explicitScopes.Length > 0); - - // Assert - canProceed.Should().BeTrue(); - } - - #endregion - - #region Tenant ID Detection Tests - - [Fact] - public void TenantIdDetection_FromConfig_ShouldUseConfigValue() - { - // Arrange - var config = new Agent365Config - { - TenantId = "config-tenant-id", - ClientAppId = "client-app-123" - }; - - // Act - var tenantId = !string.IsNullOrWhiteSpace(config.TenantId) - ? config.TenantId - : null; - - // Assert - tenantId.Should().Be("config-tenant-id"); - } - - #endregion - - #region Token Storage Tests - launchSettings.json (.NET) - - [Fact] - public void LaunchSettingsUpdate_WithBearerTokenInProfile_ShouldUpdateToken() - { - // Arrange - var launchSettingsJson = @"{ - ""profiles"": { - ""Sample Agent with Bearer Token"": { - ""commandName"": ""Project"", - ""environmentVariables"": { - ""ASPNETCORE_ENVIRONMENT"": ""Development"", - ""BEARER_TOKEN"": """" - } - } - } -}"; - var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); - - // Act - var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out var profiles); - var hasBearerToken = false; - - if (hasProfiles) - { - foreach (var profile in profiles.EnumerateObject()) - { - if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) - { - foreach (var envVar in envVars.EnumerateObject()) - { - if (envVar.Name == AuthenticationConstants.BearerTokenEnvironmentVariable) - { - hasBearerToken = true; - break; - } - } - } - } - } - - // Assert - hasProfiles.Should().BeTrue(); - hasBearerToken.Should().BeTrue("profile should have BEARER_TOKEN defined"); - } - - [Fact] - public void LaunchSettingsUpdate_WithoutBearerTokenInProfile_ShouldNotAddToken() - { - // Arrange - var launchSettingsJson = @"{ - ""profiles"": { - ""Sample Agent"": { - ""commandName"": ""Project"", - ""environmentVariables"": { - ""ASPNETCORE_ENVIRONMENT"": ""Development"" - } - } - } -}"; - var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); - - // Act - var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out var profiles); - var hasBearerToken = false; - - if (hasProfiles) - { - foreach (var profile in profiles.EnumerateObject()) - { - if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) - { - foreach (var envVar in envVars.EnumerateObject()) - { - if (envVar.Name == AuthenticationConstants.BearerTokenEnvironmentVariable) - { - hasBearerToken = true; - break; - } - } - } - } - } - - // Assert - hasProfiles.Should().BeTrue(); - hasBearerToken.Should().BeFalse("profile should not have BEARER_TOKEN"); - } - - [Fact] - public void LaunchSettingsUpdate_PreservesOtherEnvironmentVariables() - { - // Arrange - var launchSettingsJson = @"{ - ""profiles"": { - ""Sample"": { - ""environmentVariables"": { - ""ASPNETCORE_ENVIRONMENT"": ""Development"", - ""CUSTOM_VAR"": ""custom-value"", - ""BEARER_TOKEN"": """" - } - } - } -}"; - var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); - - // Act - var envVars = launchSettings.RootElement - .GetProperty("profiles") - .GetProperty("Sample") - .GetProperty("environmentVariables"); - - // Assert - envVars.TryGetProperty("ASPNETCORE_ENVIRONMENT", out var aspnetEnv).Should().BeTrue(); - aspnetEnv.GetString().Should().Be("Development"); - - envVars.TryGetProperty("CUSTOM_VAR", out var customVar).Should().BeTrue(); - customVar.GetString().Should().Be("custom-value"); - - envVars.TryGetProperty(AuthenticationConstants.BearerTokenEnvironmentVariable, out var bearerToken).Should().BeTrue(); - } - - [Fact] - public void LaunchSettingsUpdate_MultipleProfiles_OnlyUpdatesProfilesWithBearerToken() - { - // Arrange - var launchSettingsJson = @"{ - ""profiles"": { - ""Profile1"": { - ""environmentVariables"": { - ""ASPNETCORE_ENVIRONMENT"": ""Development"" - } - }, - ""Profile2"": { - ""environmentVariables"": { - ""ASPNETCORE_ENVIRONMENT"": ""Development"", - ""BEARER_TOKEN"": """" - } - }, - ""Profile3"": { - ""environmentVariables"": { - ""BEARER_TOKEN"": """" - } - } - } -}"; - var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); - - // Act - var profilesWithBearerToken = new List(); - var profiles = launchSettings.RootElement.GetProperty("profiles"); - - foreach (var profile in profiles.EnumerateObject()) - { - if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) - { - foreach (var envVar in envVars.EnumerateObject()) - { - if (envVar.Name == AuthenticationConstants.BearerTokenEnvironmentVariable) - { - profilesWithBearerToken.Add(profile.Name); - break; - } - } - } - } - - // Assert - profilesWithBearerToken.Should().HaveCount(2); - profilesWithBearerToken.Should().Contain("Profile2"); - profilesWithBearerToken.Should().Contain("Profile3"); - profilesWithBearerToken.Should().NotContain("Profile1"); - } - - [Fact] - public void LaunchSettingsUpdate_NoProfiles_ShouldBeDetectable() - { - // Arrange - var launchSettingsJson = @"{ ""iisSettings"": {} }"; - var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); - - // Act - var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out _); - - // Assert - hasProfiles.Should().BeFalse("launchSettings should not have profiles section"); - } - - #endregion - - #region Token Storage Tests - .env (Python/Node.js) - - [Fact] - public void EnvFileUpdate_ExistingBearerToken_ShouldUpdateLine() - { - // Arrange - var envLines = new List - { - "CUSTOM_VAR=value1", - "BEARER_TOKEN=old-token", - "ANOTHER_VAR=value2" - }; - var newToken = "new-token-123"; - - // Act - var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => - l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); - - if (existingIndex >= 0) - { - envLines[existingIndex] = bearerTokenLine; - } - - // Assert - existingIndex.Should().Be(1); - envLines[1].Should().Be("BEARER_TOKEN=new-token-123"); - envLines.Should().HaveCount(3); - } - - [Fact] - public void EnvFileUpdate_NoBearerToken_ShouldAddNewLine() - { - // Arrange - var envLines = new List - { - "CUSTOM_VAR=value1", - "ANOTHER_VAR=value2" - }; - var newToken = "new-token-123"; - - // Act - var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => - l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); - - if (existingIndex >= 0) - { - envLines[existingIndex] = bearerTokenLine; - } - else - { - envLines.Add(bearerTokenLine); - } - - // Assert - existingIndex.Should().Be(-1); - envLines.Should().HaveCount(3); - envLines[2].Should().Be("BEARER_TOKEN=new-token-123"); + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.Aliases.Should().Contain("--resource"); } [Fact] - public void EnvFileUpdate_CaseInsensitiveMatch_ShouldUpdateCorrectly() + public void CreateCommand_ShouldHaveResourceIdOption() { - // Arrange - var envLines = new List - { - "bearer_token=old-token" - }; - var newToken = "new-token-123"; - // Act - var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => - l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); - - if (existingIndex >= 0) - { - envLines[existingIndex] = bearerTokenLine; - } + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); // Assert - existingIndex.Should().Be(0); - envLines[0].Should().Be("BEARER_TOKEN=new-token-123"); + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.Aliases.Should().Contain("--resource-id"); } [Fact] - public void EnvFileUpdate_PreservesOtherVariables() + public void CreateCommand_ShouldHaveAllRequiredOptions() { - // Arrange - var envLines = new List - { - "VAR1=value1", - "BEARER_TOKEN=old-token", - "VAR2=value2", - "# Comment line", - "VAR3=value3" - }; - var newToken = "new-token-123"; - // Act - var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => - l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); - - if (existingIndex >= 0) - { - envLines[existingIndex] = bearerTokenLine; - } + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); // Assert - envLines.Should().HaveCount(5); - envLines[0].Should().Be("VAR1=value1"); - envLines[1].Should().Be("BEARER_TOKEN=new-token-123"); - envLines[2].Should().Be("VAR2=value2"); - envLines[3].Should().Be("# Comment line"); - envLines[4].Should().Be("VAR3=value3"); - } - - [Fact] - public void EnvFileUpdate_EmptyFile_ShouldAddToken() - { - // Arrange - var envLines = new List(); - var newToken = "new-token-123"; - - // Act - var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; - var existingIndex = envLines.FindIndex(l => - l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); - - if (existingIndex >= 0) - { - envLines[existingIndex] = bearerTokenLine; - } - else + command.Options.Should().HaveCount(9); + var optionNames = command.Options.Select(opt => opt.Name).ToList(); + optionNames.Should().Contain(new[] { - envLines.Add(bearerTokenLine); - } - - // Assert - envLines.Should().HaveCount(1); - envLines[0].Should().Be("BEARER_TOKEN=new-token-123"); - } - - #endregion - - #region Platform Detection Tests - - [Fact] - public void PlatformDetection_DotNetProject_ShouldDetectCorrectly() - { - // Arrange - .NET project markers - var projectFiles = new[] { "MyProject.csproj", "app.config" }; - - // Act - var hasCsproj = projectFiles.Any(f => f.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)); - - // Assert - hasCsproj.Should().BeTrue(); - } - - [Fact] - public void PlatformDetection_PythonProject_ShouldDetectCorrectly() - { - // Arrange - Python project markers - var projectFiles = new[] { "pyproject.toml", "requirements.txt", "setup.py" }; - - // Act - var hasPythonMarkers = projectFiles.Any(f => - f.Equals("pyproject.toml", StringComparison.OrdinalIgnoreCase) || - f.Equals("requirements.txt", StringComparison.OrdinalIgnoreCase)); - - // Assert - hasPythonMarkers.Should().BeTrue(); - } - - [Fact] - public void PlatformDetection_NodeProject_ShouldDetectCorrectly() - { - // Arrange - Node.js project markers - var projectFiles = new[] { "package.json", "package-lock.json" }; - - // Act - var hasPackageJson = projectFiles.Any(f => - f.Equals("package.json", StringComparison.OrdinalIgnoreCase)); - - // Assert - hasPackageJson.Should().BeTrue(); - } - - #endregion - - #region Integration Scenarios Tests - - [Fact] - public void TokenStorage_WithConfigPresent_ShouldAttemptToSaveToken() - { - // Arrange - var configExists = true; - var deploymentProjectPath = "/path/to/project"; - - // Act - var shouldAttemptSave = configExists && !string.IsNullOrWhiteSpace(deploymentProjectPath); - - // Assert - shouldAttemptSave.Should().BeTrue(); - } - - [Fact] - public void TokenStorage_WithoutConfig_ShouldProvideGuidanceOnly() - { - // Arrange - var configExists = false; - var appIdProvided = true; - - // Act - var shouldProvideGuidance = !configExists && appIdProvided; - - // Assert - shouldProvideGuidance.Should().BeTrue(); - } - - [Fact] - public void TokenStorage_FileNotFound_ShouldProvideGuidance() - { - // Arrange - var fileExists = false; - - // Act & Assert - fileExists.Should().BeFalse(); - // In actual implementation, this triggers guidance logging - } - - [Fact] - public void TokenStorage_ValidateExpectedFilePaths() - { - // Arrange - var projectDir = "/path/to/project"; - - // Act - var launchSettingsPath = Path.Combine(projectDir, "Properties", "launchSettings.json"); - var envPath = Path.Combine(projectDir, ".env"); - - // Assert - launchSettingsPath.Should().EndWith("Properties/launchSettings.json".Replace('/', Path.DirectorySeparatorChar)); - envPath.Should().EndWith(".env"); + "config", + "app-id", + "manifest", + "scopes", + "output", + "verbose", + "force-refresh", + "resource", + "resource-id" + }); } #endregion #region Resource Option Tests - [Fact] - public void CreateCommand_ShouldHaveResourceOption() - { - // Act - var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); - - // Assert - var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); - resourceOption.Should().NotBeNull(); - resourceOption!.Aliases.Should().Contain("--resource"); - } - - [Fact] - public void CreateCommand_ShouldHaveResourceIdOption() - { - // Act - var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); - - // Assert - var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); - resourceIdOption.Should().NotBeNull(); - resourceIdOption!.Aliases.Should().Contain("--resource-id"); - } - [Fact] public void CreateCommand_ResourceOptionDescription_ShouldListAvailableKeywords() { @@ -1025,124 +238,27 @@ public void CreateCommand_ResourceIdOption_ShouldNotBeRequired() } [Fact] - public void ResourceKeyword_Mcp_ShouldResolveToAgent365ToolsResourceAppId() + public void CreateCommand_ResourceOptionDescription_ShouldIndicateScopesRequired() { - // Arrange - var environment = "prod"; - var expectedResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); - - // Act & Assert - // The mcp keyword should resolve to the Agent 365 Tools resource app ID - expectedResourceAppId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public void ResourceKeyword_PowerPlatform_ShouldResolveToPowerPlatformApiResourceAppId() - { - // Arrange & Act - var expectedResourceAppId = MosConstants.PowerPlatformApiResourceAppId; - - // Assert - expectedResourceAppId.Should().Be("8578e004-a5c6-46e7-913e-12f58912df43"); - } - - [Fact] - public void ResourceValidation_BothResourceAndResourceId_ShouldBeMutuallyExclusive() - { - // Arrange - var resource = "mcp"; - var resourceId = "12345678-1234-1234-1234-123456789abc"; - - // Act - var bothProvided = !string.IsNullOrWhiteSpace(resource) && !string.IsNullOrWhiteSpace(resourceId); - - // Assert - // Both being provided is an error condition - bothProvided.Should().BeTrue(); - } - - [Fact] - public void ResourceValidation_NeitherProvided_ShouldUseDefaultMcpFlow() - { - // Arrange - string? resource = null; - string? resourceId = null; - - // Act - var isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); - - // Assert - isCustomResource.Should().BeFalse(); - } - - [Fact] - public void ResourceValidation_OnlyResourceProvided_ShouldBeCustomResource() - { - // Arrange - string? resource = "powerplatform"; - string? resourceId = null; - // Act - var isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); - - // Assert - isCustomResource.Should().BeTrue(); - } - - [Fact] - public void ResourceValidation_OnlyResourceIdProvided_ShouldBeCustomResource() - { - // Arrange - string? resource = null; - string? resourceId = "12345678-1234-1234-1234-123456789abc"; - - // Act - var isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); - - // Assert - isCustomResource.Should().BeTrue(); - } - - [Fact] - public void ScopeValidation_CustomResourceWithoutScopes_ShouldRequireExplicitScopes() - { - // Arrange - var isCustomResource = true; - string[]? scopes = null; - - // Act - var scopesRequired = isCustomResource && (scopes == null || scopes.Length == 0); - - // Assert - scopesRequired.Should().BeTrue(); - } - - [Fact] - public void ScopeValidation_CustomResourceWithScopes_ShouldNotRequireManifest() - { - // Arrange - var isCustomResource = true; - string[] scopes = new[] { ".default" }; - - // Act - var hasExplicitScopes = scopes != null && scopes.Length > 0; + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); // Assert - hasExplicitScopes.Should().BeTrue(); + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.Description.Should().Contain("--scopes"); } [Fact] - public void ScopeValidation_DefaultFlowWithoutScopes_ShouldUseManifest() + public void CreateCommand_ResourceIdOptionDescription_ShouldIndicateScopesRequired() { - // Arrange - var isCustomResource = false; - string[]? scopes = null; - // Act - var shouldReadManifest = !isCustomResource && (scopes == null || scopes.Length == 0); + var command = GetTokenSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockAuthService); // Assert - shouldReadManifest.Should().BeTrue(); + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.Description.Should().Contain("--scopes"); } #endregion From 06618a82fceb9b5366c3ad188f9cb8fc494e345e Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Fri, 6 Feb 2026 11:14:41 -0800 Subject: [PATCH 08/23] restore tests --- .../Commands/GetTokenSubcommandTests.cs | 787 ++++++++++++++++++ 1 file changed, 787 insertions(+) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs index 9675cac9..e9749c3a 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetTokenSubcommandTests.cs @@ -3,6 +3,8 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -18,12 +20,18 @@ public class GetTokenSubcommandTests private readonly ILogger _mockLogger; private readonly IConfigService _mockConfigService; private readonly AuthenticationService _mockAuthService; + private readonly string _testManifestPath; + private readonly string _testConfigPath; public GetTokenSubcommandTests() { _mockLogger = Substitute.For(); _mockConfigService = Substitute.For(); _mockAuthService = Substitute.For(Substitute.For>()); + + // Setup test file paths + _testManifestPath = Path.Combine(Path.GetTempPath(), $"TestManifest_{Guid.NewGuid()}.json"); + _testConfigPath = Path.Combine(Path.GetTempPath(), $"TestConfig_{Guid.NewGuid()}.json"); } #region Command Structure Tests @@ -262,4 +270,783 @@ public void CreateCommand_ResourceIdOptionDescription_ShouldIndicateScopesRequir } #endregion + + #region Configuration Loading Tests + + [Fact] + public void ConfigValidation_WithValidConfig_ShouldHaveClientAppId() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + ClientAppId = "client-app-123", + DeploymentProjectPath = "." + }; + + // Act + var clientAppId = config.ClientAppId; + + // Assert + clientAppId.Should().Be("client-app-123"); + } + + [Fact] + public void ConfigValidation_WithEnvironmentSet_ShouldUseCorrectEnvironment() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + ClientAppId = "client-app-123", + Environment = "preprod" + }; + + // Act + var environment = config.Environment; + + // Assert + environment.Should().Be("preprod"); + } + + #endregion + + #region Scope Resolution Tests + + [Fact] + public void ScopeResolution_WithExplicitScopes_ShouldUseProvidedScopes() + { + // Arrange + var explicitScopes = new[] { "McpServers.Mail.All", "McpServers.Calendar.All" }; + + // Act + var scopeSet = new HashSet(explicitScopes, StringComparer.OrdinalIgnoreCase); + + // Assert + scopeSet.Should().HaveCount(2); + scopeSet.Should().Contain("McpServers.Mail.All"); + scopeSet.Should().Contain("McpServers.Calendar.All"); + } + + [Fact] + public void ScopeResolution_WithDuplicateScopes_ShouldDeduplicateCaseInsensitive() + { + // Arrange + var scopesWithDuplicates = new[] + { + "McpServers.Mail.All", + "mcpservers.mail.all", + "McpServers.Calendar.All" + }; + + // Act + var scopeSet = new HashSet(scopesWithDuplicates, StringComparer.OrdinalIgnoreCase); + + // Assert + scopeSet.Should().HaveCount(2); + } + + [Fact] + public void ScopeResolution_WithEmptyScopes_ShouldBeEmpty() + { + // Arrange + var emptyScopes = Array.Empty(); + + // Act + var scopeSet = new HashSet(emptyScopes); + + // Assert + scopeSet.Should().BeEmpty(); + } + + [Fact] + public void ScopeResolution_FromManifest_ShouldExtractUniqueScopes() + { + // Arrange + var manifest = new ToolingManifest + { + McpServers = new[] + { + new McpServerConfig { McpServerName = "mcp_MailTools", Scope = "McpServers.Mail.All" }, + new McpServerConfig { McpServerName = "mcp_CalendarTools", Scope = "McpServers.Calendar.All" }, + new McpServerConfig { McpServerName = "mcp_DuplicateMail", Scope = "McpServers.Mail.All" } + } + }; + + // Act + var scopeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var server in manifest.McpServers) + { + if (!string.IsNullOrWhiteSpace(server.Scope)) + { + scopeSet.Add(server.Scope); + } + } + + // Assert + scopeSet.Should().HaveCount(2); + scopeSet.Should().Contain("McpServers.Mail.All"); + scopeSet.Should().Contain("McpServers.Calendar.All"); + } + + [Fact] + public void ScopeResolution_WithNullScopes_ShouldSkip() + { + // Arrange + var manifest = new ToolingManifest + { + McpServers = new[] + { + new McpServerConfig { McpServerName = "mcp_MailTools", Scope = "McpServers.Mail.All" }, + new McpServerConfig { McpServerName = "mcp_NoScope", Scope = null }, + new McpServerConfig { McpServerName = "mcp_EmptyScope", Scope = "" } + } + }; + + // Act + var scopeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var server in manifest.McpServers) + { + if (!string.IsNullOrWhiteSpace(server.Scope)) + { + scopeSet.Add(server.Scope); + } + } + + // Assert + scopeSet.Should().HaveCount(1); + scopeSet.Should().Contain("McpServers.Mail.All"); + } + + #endregion + + #region Manifest File Tests + + [Fact] + public void ManifestParsing_WithValidManifest_ShouldParse() + { + // Arrange + var manifestContent = @"{ + ""mcpServers"": [ + { + ""mcpServerName"": ""mcp_MailTools"", + ""scope"": ""McpServers.Mail.All"" + }, + { + ""mcpServerName"": ""mcp_CalendarTools"", + ""scope"": ""McpServers.Calendar.All"" + } + ] + }"; + + // Act + var manifest = System.Text.Json.JsonSerializer.Deserialize(manifestContent); + + // Assert + manifest.Should().NotBeNull(); + manifest!.McpServers.Should().HaveCount(2); + manifest.McpServers[0].Scope.Should().Be("McpServers.Mail.All"); + manifest.McpServers[1].Scope.Should().Be("McpServers.Calendar.All"); + } + + [Fact] + public void ManifestParsing_WithEmptyServers_ShouldReturnEmptyArray() + { + // Arrange + var manifestContent = @"{ ""mcpServers"": [] }"; + + // Act + var manifest = System.Text.Json.JsonSerializer.Deserialize(manifestContent); + + // Assert + manifest.Should().NotBeNull(); + manifest!.McpServers.Should().BeEmpty(); + } + + #endregion + + #region Output Format Tests + + [Fact] + public void OutputFormat_TableFormat_IsDefault() + { + // Arrange + var defaultFormat = "table"; + + // Act & Assert + defaultFormat.Should().Be("table"); + } + + [Fact] + public void OutputFormat_SupportedFormats_ShouldIncludeAllOptions() + { + // Arrange + var supportedFormats = new[] { "table", "json", "raw" }; + + // Act & Assert + supportedFormats.Should().Contain("table"); + supportedFormats.Should().Contain("json"); + supportedFormats.Should().Contain("raw"); + supportedFormats.Should().HaveCount(3); + } + + [Fact] + public void OutputFormat_CaseInsensitive_ShouldMatch() + { + // Arrange + var formats = new[] { "TABLE", "table", "Table", "JSON", "json", "RAW", "raw" }; + + // Act & Assert + foreach (var format in formats) + { + var normalized = format.ToLowerInvariant(); + normalized.Should().BeOneOf("table", "json", "raw"); + } + } + + #endregion + + #region Error Handling Tests + + [Fact] + public void ErrorHandling_MissingConfigAndAppId_ShouldBeDetectable() + { + // Arrange + var configExists = false; + var appId = string.Empty; + + // Act + var hasRequiredInfo = configExists || !string.IsNullOrWhiteSpace(appId); + + // Assert + hasRequiredInfo.Should().BeFalse(); + } + + [Fact] + public void ErrorHandling_ConfigExistsOrAppIdProvided_ShouldBeValid() + { + // Arrange - Test with config + var configExists = true; + var appId = string.Empty; + + // Act + var hasRequiredInfo = configExists || !string.IsNullOrWhiteSpace(appId); + + // Assert + hasRequiredInfo.Should().BeTrue(); + + // Arrange - Test with app ID + configExists = false; + appId = "client-app-123"; + + // Act + hasRequiredInfo = configExists || !string.IsNullOrWhiteSpace(appId); + + // Assert + hasRequiredInfo.Should().BeTrue(); + } + + [Fact] + public void ErrorHandling_MissingManifestAndScopes_ShouldBeDetectable() + { + // Arrange + var manifestExists = false; + string[]? explicitScopes = null; + + // Act + var canProceed = manifestExists || (explicitScopes != null && explicitScopes.Length > 0); + + // Assert + canProceed.Should().BeFalse(); + } + + [Fact] + public void ErrorHandling_ManifestExistsOrScopesProvided_ShouldBeValid() + { + // Arrange - Test with manifest + var manifestExists = true; + string[]? explicitScopes = null; + + // Act + var canProceed = manifestExists || (explicitScopes != null && explicitScopes.Length > 0); + + // Assert + canProceed.Should().BeTrue(); + + // Arrange - Test with explicit scopes + manifestExists = false; + explicitScopes = new[] { "McpServers.Mail.All" }; + + // Act + canProceed = manifestExists || (explicitScopes != null && explicitScopes.Length > 0); + + // Assert + canProceed.Should().BeTrue(); + } + + #endregion + + #region Tenant ID Detection Tests + + [Fact] + public void TenantIdDetection_FromConfig_ShouldUseConfigValue() + { + // Arrange + var config = new Agent365Config + { + TenantId = "config-tenant-id", + ClientAppId = "client-app-123" + }; + + // Act + var tenantId = !string.IsNullOrWhiteSpace(config.TenantId) + ? config.TenantId + : null; + + // Assert + tenantId.Should().Be("config-tenant-id"); + } + + #endregion + + #region Token Storage Tests - launchSettings.json (.NET) + + [Fact] + public void LaunchSettingsUpdate_WithBearerTokenInProfile_ShouldUpdateToken() + { + // Arrange + var launchSettingsJson = @"{ + ""profiles"": { + ""Sample Agent with Bearer Token"": { + ""commandName"": ""Project"", + ""environmentVariables"": { + ""ASPNETCORE_ENVIRONMENT"": ""Development"", + ""BEARER_TOKEN"": """" + } + } + } +}"; + var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); + + // Act + var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out var profiles); + var hasBearerToken = false; + + if (hasProfiles) + { + foreach (var profile in profiles.EnumerateObject()) + { + if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) + { + foreach (var envVar in envVars.EnumerateObject()) + { + if (envVar.Name == AuthenticationConstants.BearerTokenEnvironmentVariable) + { + hasBearerToken = true; + break; + } + } + } + } + } + + // Assert + hasProfiles.Should().BeTrue(); + hasBearerToken.Should().BeTrue("profile should have BEARER_TOKEN defined"); + } + + [Fact] + public void LaunchSettingsUpdate_WithoutBearerTokenInProfile_ShouldNotAddToken() + { + // Arrange + var launchSettingsJson = @"{ + ""profiles"": { + ""Sample Agent"": { + ""commandName"": ""Project"", + ""environmentVariables"": { + ""ASPNETCORE_ENVIRONMENT"": ""Development"" + } + } + } +}"; + var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); + + // Act + var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out var profiles); + var hasBearerToken = false; + + if (hasProfiles) + { + foreach (var profile in profiles.EnumerateObject()) + { + if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) + { + foreach (var envVar in envVars.EnumerateObject()) + { + if (envVar.Name == AuthenticationConstants.BearerTokenEnvironmentVariable) + { + hasBearerToken = true; + break; + } + } + } + } + } + + // Assert + hasProfiles.Should().BeTrue(); + hasBearerToken.Should().BeFalse("profile should not have BEARER_TOKEN"); + } + + [Fact] + public void LaunchSettingsUpdate_PreservesOtherEnvironmentVariables() + { + // Arrange + var launchSettingsJson = @"{ + ""profiles"": { + ""Sample"": { + ""environmentVariables"": { + ""ASPNETCORE_ENVIRONMENT"": ""Development"", + ""CUSTOM_VAR"": ""custom-value"", + ""BEARER_TOKEN"": """" + } + } + } +}"; + var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); + + // Act + var envVars = launchSettings.RootElement + .GetProperty("profiles") + .GetProperty("Sample") + .GetProperty("environmentVariables"); + + // Assert + envVars.TryGetProperty("ASPNETCORE_ENVIRONMENT", out var aspnetEnv).Should().BeTrue(); + aspnetEnv.GetString().Should().Be("Development"); + + envVars.TryGetProperty("CUSTOM_VAR", out var customVar).Should().BeTrue(); + customVar.GetString().Should().Be("custom-value"); + + envVars.TryGetProperty(AuthenticationConstants.BearerTokenEnvironmentVariable, out var bearerToken).Should().BeTrue(); + } + + [Fact] + public void LaunchSettingsUpdate_MultipleProfiles_OnlyUpdatesProfilesWithBearerToken() + { + // Arrange + var launchSettingsJson = @"{ + ""profiles"": { + ""Profile1"": { + ""environmentVariables"": { + ""ASPNETCORE_ENVIRONMENT"": ""Development"" + } + }, + ""Profile2"": { + ""environmentVariables"": { + ""ASPNETCORE_ENVIRONMENT"": ""Development"", + ""BEARER_TOKEN"": """" + } + }, + ""Profile3"": { + ""environmentVariables"": { + ""BEARER_TOKEN"": """" + } + } + } +}"; + var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); + + // Act + var profilesWithBearerToken = new List(); + var profiles = launchSettings.RootElement.GetProperty("profiles"); + + foreach (var profile in profiles.EnumerateObject()) + { + if (profile.Value.TryGetProperty("environmentVariables", out var envVars)) + { + foreach (var envVar in envVars.EnumerateObject()) + { + if (envVar.Name == AuthenticationConstants.BearerTokenEnvironmentVariable) + { + profilesWithBearerToken.Add(profile.Name); + break; + } + } + } + } + + // Assert + profilesWithBearerToken.Should().HaveCount(2); + profilesWithBearerToken.Should().Contain("Profile2"); + profilesWithBearerToken.Should().Contain("Profile3"); + profilesWithBearerToken.Should().NotContain("Profile1"); + } + + [Fact] + public void LaunchSettingsUpdate_NoProfiles_ShouldBeDetectable() + { + // Arrange + var launchSettingsJson = @"{ ""iisSettings"": {} }"; + var launchSettings = System.Text.Json.JsonDocument.Parse(launchSettingsJson); + + // Act + var hasProfiles = launchSettings.RootElement.TryGetProperty("profiles", out _); + + // Assert + hasProfiles.Should().BeFalse("launchSettings should not have profiles section"); + } + + #endregion + + #region Token Storage Tests - .env (Python/Node.js) + + [Fact] + public void EnvFileUpdate_ExistingBearerToken_ShouldUpdateLine() + { + // Arrange + var envLines = new List + { + "CUSTOM_VAR=value1", + "BEARER_TOKEN=old-token", + "ANOTHER_VAR=value2" + }; + var newToken = "new-token-123"; + + // Act + var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; + var existingIndex = envLines.FindIndex(l => + l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + envLines[existingIndex] = bearerTokenLine; + } + + // Assert + existingIndex.Should().Be(1); + envLines[1].Should().Be("BEARER_TOKEN=new-token-123"); + envLines.Should().HaveCount(3); + } + + [Fact] + public void EnvFileUpdate_NoBearerToken_ShouldAddNewLine() + { + // Arrange + var envLines = new List + { + "CUSTOM_VAR=value1", + "ANOTHER_VAR=value2" + }; + var newToken = "new-token-123"; + + // Act + var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; + var existingIndex = envLines.FindIndex(l => + l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + envLines[existingIndex] = bearerTokenLine; + } + else + { + envLines.Add(bearerTokenLine); + } + + // Assert + existingIndex.Should().Be(-1); + envLines.Should().HaveCount(3); + envLines[2].Should().Be("BEARER_TOKEN=new-token-123"); + } + + [Fact] + public void EnvFileUpdate_CaseInsensitiveMatch_ShouldUpdateCorrectly() + { + // Arrange + var envLines = new List + { + "bearer_token=old-token" + }; + var newToken = "new-token-123"; + + // Act + var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; + var existingIndex = envLines.FindIndex(l => + l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + envLines[existingIndex] = bearerTokenLine; + } + + // Assert + existingIndex.Should().Be(0); + envLines[0].Should().Be("BEARER_TOKEN=new-token-123"); + } + + [Fact] + public void EnvFileUpdate_PreservesOtherVariables() + { + // Arrange + var envLines = new List + { + "VAR1=value1", + "BEARER_TOKEN=old-token", + "VAR2=value2", + "# Comment line", + "VAR3=value3" + }; + var newToken = "new-token-123"; + + // Act + var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; + var existingIndex = envLines.FindIndex(l => + l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + envLines[existingIndex] = bearerTokenLine; + } + + // Assert + envLines.Should().HaveCount(5); + envLines[0].Should().Be("VAR1=value1"); + envLines[1].Should().Be("BEARER_TOKEN=new-token-123"); + envLines[2].Should().Be("VAR2=value2"); + envLines[3].Should().Be("# Comment line"); + envLines[4].Should().Be("VAR3=value3"); + } + + [Fact] + public void EnvFileUpdate_EmptyFile_ShouldAddToken() + { + // Arrange + var envLines = new List(); + var newToken = "new-token-123"; + + // Act + var bearerTokenLine = $"{AuthenticationConstants.BearerTokenEnvironmentVariable}={newToken}"; + var existingIndex = envLines.FindIndex(l => + l.StartsWith($"{AuthenticationConstants.BearerTokenEnvironmentVariable}=", StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + envLines[existingIndex] = bearerTokenLine; + } + else + { + envLines.Add(bearerTokenLine); + } + + // Assert + envLines.Should().HaveCount(1); + envLines[0].Should().Be("BEARER_TOKEN=new-token-123"); + } + + #endregion + + #region Platform Detection Tests + + [Fact] + public void PlatformDetection_DotNetProject_ShouldDetectCorrectly() + { + // Arrange - .NET project markers + var projectFiles = new[] { "MyProject.csproj", "app.config" }; + + // Act + var hasCsproj = projectFiles.Any(f => f.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)); + + // Assert + hasCsproj.Should().BeTrue(); + } + + [Fact] + public void PlatformDetection_PythonProject_ShouldDetectCorrectly() + { + // Arrange - Python project markers + var projectFiles = new[] { "pyproject.toml", "requirements.txt", "setup.py" }; + + // Act + var hasPythonMarkers = projectFiles.Any(f => + f.Equals("pyproject.toml", StringComparison.OrdinalIgnoreCase) || + f.Equals("requirements.txt", StringComparison.OrdinalIgnoreCase)); + + // Assert + hasPythonMarkers.Should().BeTrue(); + } + + [Fact] + public void PlatformDetection_NodeProject_ShouldDetectCorrectly() + { + // Arrange - Node.js project markers + var projectFiles = new[] { "package.json", "package-lock.json" }; + + // Act + var hasPackageJson = projectFiles.Any(f => + f.Equals("package.json", StringComparison.OrdinalIgnoreCase)); + + // Assert + hasPackageJson.Should().BeTrue(); + } + + #endregion + + #region Integration Scenarios Tests + + [Fact] + public void TokenStorage_WithConfigPresent_ShouldAttemptToSaveToken() + { + // Arrange + var configExists = true; + var deploymentProjectPath = "/path/to/project"; + + // Act + var shouldAttemptSave = configExists && !string.IsNullOrWhiteSpace(deploymentProjectPath); + + // Assert + shouldAttemptSave.Should().BeTrue(); + } + + [Fact] + public void TokenStorage_WithoutConfig_ShouldProvideGuidanceOnly() + { + // Arrange + var configExists = false; + var appIdProvided = true; + + // Act + var shouldProvideGuidance = !configExists && appIdProvided; + + // Assert + shouldProvideGuidance.Should().BeTrue(); + } + + [Fact] + public void TokenStorage_FileNotFound_ShouldProvideGuidance() + { + // Arrange + var fileExists = false; + + // Act & Assert + fileExists.Should().BeFalse(); + // In actual implementation, this triggers guidance logging + } + + [Fact] + public void TokenStorage_ValidateExpectedFilePaths() + { + // Arrange + var projectDir = "/path/to/project"; + + // Act + var launchSettingsPath = Path.Combine(projectDir, "Properties", "launchSettings.json"); + var envPath = Path.Combine(projectDir, ".env"); + + // Assert + launchSettingsPath.Should().EndWith("Properties/launchSettings.json".Replace('/', Path.DirectorySeparatorChar)); + envPath.Should().EndWith(".env"); + } + + #endregion } From 55865636a8fda15113e13fa665dd19121e6d5260 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:45:02 -0800 Subject: [PATCH 09/23] Address code review feedback for resource token acquisition (#226) * Initial plan * Address code review feedback: logging, validation, and documentation fixes Co-authored-by: JesuTerraz <96103167+JesuTerraz@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JesuTerraz <96103167+JesuTerraz@users.noreply.github.com> --- docs/commands/develop/develop-gettoken.md | 4 ++-- .../DevelopSubcommands/GetTokenSubcommand.cs | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/commands/develop/develop-gettoken.md b/docs/commands/develop/develop-gettoken.md index cb637901..caee526a 100644 --- a/docs/commands/develop/develop-gettoken.md +++ b/docs/commands/develop/develop-gettoken.md @@ -24,7 +24,7 @@ a365 develop get-token [options] | `--verbose` | `-v` | Show detailed output including full token | `false` | | `--force-refresh` | | Force token refresh bypassing cache | `false` | | `--resource` | | Resource keyword to get token for (mcp, powerplatform) | `mcp` | -| `--resource-id` | | Resource application ID (GUID) for custom resources | Agent 365 Tools App ID | +| `--resource-id` | | Resource application ID (GUID) for custom resources | None | ### Resource Options @@ -119,7 +119,7 @@ curl -H "Authorization: Bearer $TOKEN" https://agent365.svc.cloud.microsoft/agen ### Get token for Power Platform API ```bash -a365 develop get-token --resource powerplatform --scopes https://api.powerplatform.com/.default +a365 develop get-token --resource powerplatform --scopes .default ``` ### Get token for a custom resource diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index f1240860..7162f20f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -147,6 +147,16 @@ public static Command CreateCommand( string resourceDisplayName; if (!string.IsNullOrWhiteSpace(resourceId)) { + // Validate that resource ID is a valid GUID + if (!Guid.TryParse(resourceId, out _)) + { + logger.LogError("Invalid resource application ID: {ResourceId}. Expected a valid GUID.", resourceId); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + Environment.Exit(1); + return; + } + // User provided explicit resource ID resourceAppId = resourceId; resourceDisplayName = $"Custom Resource ({resourceId})"; @@ -184,7 +194,7 @@ public static Command CreateCommand( logger.LogInformation("Manifest-based scopes are only supported for the default flow."); logger.LogInformation("Please omit the --resource and --resource-id options if you'd like to use manifest-based scopes."); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop get-token --resource powerplatform --scopes https://api.powerplatform.com/.default"); + logger.LogInformation("Example: a365 develop get-token --resource powerplatform --scopes .default"); Environment.Exit(1); return; } @@ -225,7 +235,7 @@ public static Command CreateCommand( } logger.LogInformation(""); - logger.LogInformation("Agent 365 Tools Resource App ID: {AppId}", resourceAppId); + logger.LogInformation("Resource App ID: {AppId}", resourceAppId); logger.LogInformation("Requesting scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); @@ -361,7 +371,7 @@ private static async Task AcquireAndDisplayTokenAsync( } catch (Exception ex) { - logger.LogError("Failed to acquire token: {Message}", ex.Message); + logger.LogError(ex, "Failed to acquire token: {Message}", ex.Message); Environment.Exit(1); } } From 4739fc5efe441b0fe37c7808c62dec46c32a89f0 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Fri, 6 Feb 2026 12:40:49 -0800 Subject: [PATCH 10/23] update endpoint resolution --- docs/commands/develop/develop-gettoken.md | 2 +- .../DevelopSubcommands/GetTokenSubcommand.cs | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/commands/develop/develop-gettoken.md b/docs/commands/develop/develop-gettoken.md index cb637901..2d685f1e 100644 --- a/docs/commands/develop/develop-gettoken.md +++ b/docs/commands/develop/develop-gettoken.md @@ -66,7 +66,7 @@ The application you're getting a token for should be your **custom client app** ## Prerequisites 1. **Azure CLI**: Run `az login` before using this command -2. **Client Application**: +2. **Client Application**: - Must exist in Azure AD - Must have the required MCP scopes configured - Can be configured in `a365.config.json` as `clientAppId` OR provided via `--app-id` diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index f1240860..0ccfba48 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -145,6 +145,7 @@ public static Command CreateCommand( // Resolve resource app ID string resourceAppId; string resourceDisplayName; + string? resourceUrl = null; if (!string.IsNullOrWhiteSpace(resourceId)) { // User provided explicit resource ID @@ -155,7 +156,7 @@ public static Command CreateCommand( else { // Resolve resource keyword to GUID (default to "mcp" if null) - var resolved = ResolveResourceAppId(resource, environment); + var resolved = ResolveResourceApp(resource, environment); if (resolved == null) { logger.LogError("Unknown resource keyword '{Resource}'. Valid options: mcp, powerplatform", resource); @@ -165,6 +166,7 @@ public static Command CreateCommand( resourceAppId = resolved.Value.resourceAppId; resourceDisplayName = resolved.Value.displayName; + resourceUrl = resolved.Value.url; logger.LogInformation("Using resource: {DisplayName}", resourceDisplayName); } @@ -233,9 +235,8 @@ public static Command CreateCommand( await AcquireAndDisplayTokenAsync( resourceAppId, resourceDisplayName, + resourceUrl, requestedScopes, - environment, - isCustomResource, appId, setupConfig, outputFormat, @@ -260,9 +261,8 @@ await AcquireAndDisplayTokenAsync( private static async Task AcquireAndDisplayTokenAsync( string resourceAppId, string resourceDisplayName, + string? resourceUrl, string[] requestedScopes, - string environment, - bool isCustomResource, string? appId, Agent365Config? setupConfig, string outputFormat, @@ -328,7 +328,7 @@ private static async Task AcquireAndDisplayTokenAsync( var tokenResult = new McpServerTokenResult { ServerName = resourceDisplayName, - Url = isCustomResource ? null : ConfigConstants.GetDiscoverEndpointUrl(environment), + Url = resourceUrl, Scope = string.Join(", ", requestedScopes), Audience = resourceAppId, Success = true, @@ -367,12 +367,12 @@ private static async Task AcquireAndDisplayTokenAsync( } /// - /// Resolves a resource keyword to its corresponding resource app ID. + /// Resolves a resource keyword to its corresponding resource app info. /// /// The resource keyword (e.g., "mcp", "powerplatform"). /// The environment to use for MCP resource resolution. - /// A tuple containing the resource app ID and display name, or null if the keyword is unknown. - private static (string resourceAppId, string displayName)? ResolveResourceAppId(string? keyword, string environment) + /// A tuple containing the resource app ID, display name, and URL, or null if the keyword is unknown. + private static (string resourceAppId, string displayName, string? url)? ResolveResourceApp(string? keyword, string environment) { if (string.IsNullOrWhiteSpace(keyword)) { @@ -381,8 +381,8 @@ private static (string resourceAppId, string displayName)? ResolveResourceAppId( return keyword.ToLowerInvariant() switch { - "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)"), - "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API"), + "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)", ConfigConstants.GetDiscoverEndpointUrl(environment)), + "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API", null), _ => null }; } From cdfe3913990a72570d9e52f75e66008a0653c54a Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Fri, 6 Feb 2026 16:46:48 -0800 Subject: [PATCH 11/23] merge get-token code for shared functionality --- .../AddPermissionsSubcommand.cs | 28 +- .../DevelopSubcommands/GetTokenSubcommand.cs | 38 +-- .../Constants/ErrorMessages.cs | 7 + .../Helpers/ResourceResolutionHelper.cs | 61 ++++ .../Commands/AddPermissionsSubcommandTests.cs | 20 +- .../Helpers/ResourceResolutionHelperTests.cs | 272 ++++++++++++++++++ 6 files changed, 369 insertions(+), 57 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 37020f09..996f64a1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -178,7 +178,16 @@ public static Command CreateCommand( var environment = setupConfig?.Environment ?? "prod"; // Resolve resource configuration based on --resource option - var (resourceAppId, resourceName) = GetResourceConfig(resource, environment); + var resolvedResource = ResourceResolutionHelper.ResolveByKeyword(resource, environment); + if (resolvedResource is null) + { + logger.LogError(ErrorMessages.UnknownResourceKeyword, resource); + Environment.Exit(1); + return; + } + + var resourceAppId = resolvedResource.ResourceAppId; + var resourceName = resolvedResource.DisplayName; logger.LogInformation("Target resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); logger.LogInformation(""); @@ -258,21 +267,4 @@ public static Command CreateCommand( return command; } - /// - /// Resolves resource configuration based on the resource key and environment - /// - /// Resource identifier (e.g., "agent365tools", "powerplatform") - /// Environment (e.g., "prod", "test") - /// Tuple containing the resource app ID and display name - private static (string AppId, string Name) GetResourceConfig(string resourceKey, string environment) - { - return resourceKey.ToLowerInvariant() switch - { - "mcp" => - (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools"), - "powerplatform" => - (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API"), - _ => throw new ArgumentException($"Unknown resource: {resourceKey}. Valid options are: mcp, powerplatform") - }; - } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 608e8dfb..b941feca 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -159,24 +159,25 @@ public static Command CreateCommand( } // User provided explicit resource ID - resourceAppId = resourceId; - resourceDisplayName = $"Custom Resource ({resourceId})"; + var customResolved = ResourceResolutionHelper.ResolveByCustomId(resourceId); + resourceAppId = customResolved.ResourceAppId; + resourceDisplayName = customResolved.DisplayName; logger.LogInformation("Using custom resource ID: {ResourceId}", resourceId); } else { // Resolve resource keyword to GUID (default to "mcp" if null) - var resolved = ResolveResourceApp(resource, environment); - if (resolved == null) + var resolved = ResourceResolutionHelper.ResolveByKeyword(resource, environment); + if (resolved is null) { - logger.LogError("Unknown resource keyword '{Resource}'. Valid options: mcp, powerplatform", resource); + logger.LogError(ErrorMessages.UnknownResourceKeyword, resource); Environment.Exit(1); return; } - resourceAppId = resolved.Value.resourceAppId; - resourceDisplayName = resolved.Value.displayName; - resourceUrl = resolved.Value.url; + resourceAppId = resolved.ResourceAppId; + resourceDisplayName = resolved.DisplayName; + resourceUrl = resolved.Url; logger.LogInformation("Using resource: {DisplayName}", resourceDisplayName); } @@ -376,27 +377,6 @@ private static async Task AcquireAndDisplayTokenAsync( } } - /// - /// Resolves a resource keyword to its corresponding resource app info. - /// - /// The resource keyword (e.g., "mcp", "powerplatform"). - /// The environment to use for MCP resource resolution. - /// A tuple containing the resource app ID, display name, and URL, or null if the keyword is unknown. - private static (string resourceAppId, string displayName, string? url)? ResolveResourceApp(string? keyword, string environment) - { - if (string.IsNullOrWhiteSpace(keyword)) - { - keyword = "mcp"; // Default to MCP if no keyword provided - } - - return keyword.ToLowerInvariant() switch - { - "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)", ConfigConstants.GetDiscoverEndpointUrl(environment)), - "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API", null), - _ => null - }; - } - private static void DisplayResults( List results, string outputFormat, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs index f551d6af..7eecc825 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs @@ -106,6 +106,13 @@ public static List GetGenericAppServicePlanMitigation() #endregion + #region Resource Resolution Messages + + public const string UnknownResourceKeyword = + "Unknown resource keyword '{0}'. Valid options: mcp, powerplatform"; + + #endregion + #region Client App Validation Messages public const string ClientAppValidationFailed = diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs new file mode 100644 index 00000000..47be294f --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Helpers; + +/// +/// Represents a resolved resource with its application ID, display name, and optional URL. +/// +/// The Azure AD application ID for the resource. +/// A human-readable display name for the resource. +/// An optional URL associated with the resource (e.g., endpoint URL). +public record ResolvedResource(string ResourceAppId, string DisplayName, string? Url); + +/// +/// Provides shared resource keyword resolution logic for CLI subcommands. +/// Maps well-known resource keywords (e.g., "mcp", "powerplatform") to their +/// corresponding Azure AD application IDs, display names, and optional URLs. +/// +public static class ResourceResolutionHelper +{ + /// + /// Resolves a resource keyword to its corresponding resource application info. + /// Defaults to "mcp" when the keyword is null or empty. + /// + /// The resource keyword (e.g., "mcp", "powerplatform"). Defaults to "mcp" when null or empty. + /// The environment to use for environment-aware resource resolution (e.g., "prod"). + /// A containing the app ID, display name, and optional URL, or null if the keyword is unknown. + public static ResolvedResource? ResolveByKeyword(string? keyword, string environment) + { + if (string.IsNullOrWhiteSpace(keyword)) + { + keyword = "mcp"; + } + + return keyword.ToLowerInvariant() switch + { + "mcp" => new ResolvedResource( + ConfigConstants.GetAgent365ToolsResourceAppId(environment), + "Agent 365 Tools (MCP)", + ConfigConstants.GetDiscoverEndpointUrl(environment)), + "powerplatform" => new ResolvedResource( + MosConstants.PowerPlatformApiResourceAppId, + "Power Platform API", + null), + _ => null + }; + } + + /// + /// Wraps a custom resource application ID in a with a generic display name. + /// Used when the caller provides an explicit resource GUID rather than a keyword. + /// + /// The custom resource application ID (GUID). + /// A with a generic display name and no URL. + public static ResolvedResource ResolveByCustomId(string resourceId) + { + return new ResolvedResource(resourceId, $"Custom Resource ({resourceId})", null); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs index c4d770e6..b2896fe5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs @@ -48,7 +48,6 @@ public void CreateCommand_ShouldHaveDescriptiveMessage() var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); // Assert - command.Description.Should().Contain("MCP"); command.Description.Should().Contain("permission"); } @@ -134,16 +133,17 @@ public void CreateCommand_ShouldHaveAllRequiredOptions() var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); // Assert - command.Options.Should().HaveCount(6); + command.Options.Should().HaveCount(7); var optionNames = command.Options.Select(opt => opt.Name).ToList(); - optionNames.Should().Contain(new[] - { - "config", - "manifest", - "app-id", - "scopes", - "verbose", - "dry-run" + optionNames.Should().Contain(new[] + { + "config", + "manifest", + "app-id", + "scopes", + "resource", + "verbose", + "dry-run" }); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs new file mode 100644 index 00000000..3fb38ed1 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; + +/// +/// Unit tests for ResourceResolutionHelper +/// +public class ResourceResolutionHelperTests +{ + private const string DefaultEnvironment = "prod"; + + #region ResolveByKeyword - MCP keyword tests + + [Fact] + public void ResolveByKeyword_McpKeyword_ReturnsCorrectAppId() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Fact] + public void ResolveByKeyword_McpKeyword_ReturnsCorrectDisplayName() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveByKeyword_McpKeyword_ReturnsDiscoverEndpointUrl() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.Url.Should().Be(ConfigConstants.GetDiscoverEndpointUrl(DefaultEnvironment)); + } + + #endregion + + #region ResolveByKeyword - PowerPlatform keyword tests + + [Fact] + public void ResolveByKeyword_PowerPlatformKeyword_ReturnsCorrectAppId() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.ResourceAppId.Should().Be(MosConstants.PowerPlatformApiResourceAppId); + } + + [Fact] + public void ResolveByKeyword_PowerPlatformKeyword_ReturnsCorrectDisplayName() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Power Platform API"); + } + + [Fact] + public void ResolveByKeyword_PowerPlatformKeyword_ReturnsNullUrl() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.Url.Should().BeNull(); + } + + #endregion + + #region ResolveByKeyword - Case insensitivity tests + + [Theory] + [InlineData("MCP")] + [InlineData("Mcp")] + [InlineData("mCp")] + public void ResolveByKeyword_McpCaseInsensitive_ReturnsCorrectResult(string keyword) + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(keyword, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Theory] + [InlineData("POWERPLATFORM")] + [InlineData("PowerPlatform")] + [InlineData("Powerplatform")] + public void ResolveByKeyword_PowerPlatformCaseInsensitive_ReturnsCorrectResult(string keyword) + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(keyword, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Power Platform API"); + result.ResourceAppId.Should().Be(MosConstants.PowerPlatformApiResourceAppId); + } + + #endregion + + #region ResolveByKeyword - Null/empty defaults to MCP + + [Fact] + public void ResolveByKeyword_NullKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(null, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Fact] + public void ResolveByKeyword_EmptyKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveByKeyword_WhitespaceKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(" ", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + #endregion + + #region ResolveByKeyword - Unknown keyword + + [Fact] + public void ResolveByKeyword_UnknownKeyword_ReturnsNull() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("unknownresource", DefaultEnvironment); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ResolveByKeyword_RandomString_ReturnsNull() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("graph", DefaultEnvironment); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region ResolveByCustomId tests + + [Fact] + public void ResolveByCustomId_ValidGuid_ReturnsResourceWithCorrectAppId() + { + // Arrange + var customId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveByCustomId(customId); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(customId); + } + + [Fact] + public void ResolveByCustomId_ValidGuid_ReturnsGenericDisplayName() + { + // Arrange + var customId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveByCustomId(customId); + + // Assert + result.Should().NotBeNull(); + result.DisplayName.Should().Be($"Custom Resource ({customId})"); + } + + [Fact] + public void ResolveByCustomId_ValidGuid_ReturnsNullUrl() + { + // Arrange + var customId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveByCustomId(customId); + + // Assert + result.Should().NotBeNull(); + result.Url.Should().BeNull(); + } + + #endregion + + #region ResolvedResource record tests + + [Fact] + public void ResolvedResource_RecordEquality_WorksCorrectly() + { + // Arrange + var resource1 = new ResolvedResource("app-id", "Display Name", "https://example.com"); + var resource2 = new ResolvedResource("app-id", "Display Name", "https://example.com"); + + // Assert + resource1.Should().Be(resource2); + } + + [Fact] + public void ResolvedResource_WithNullUrl_IsValid() + { + // Act + var resource = new ResolvedResource("app-id", "Display Name", null); + + // Assert + resource.ResourceAppId.Should().Be("app-id"); + resource.DisplayName.Should().Be("Display Name"); + resource.Url.Should().BeNull(); + } + + #endregion + + #region ErrorMessages constant test + + [Fact] + public void ErrorMessages_UnknownResourceKeyword_ContainsExpectedContent() + { + // Assert + ErrorMessages.UnknownResourceKeyword.Should().Contain("mcp"); + ErrorMessages.UnknownResourceKeyword.Should().Contain("powerplatform"); + ErrorMessages.UnknownResourceKeyword.Should().Contain("{0}"); + } + + #endregion +} From 96f1f34959cd10d83472269eb295205469005245 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 11:29:40 -0800 Subject: [PATCH 12/23] Add ResolveResource for duplicate code --- .../AddPermissionsSubcommand.cs | 130 ++++++++++-------- .../DevelopSubcommands/GetTokenSubcommand.cs | 58 +++----- .../Helpers/ResourceResolutionHelper.cs | 40 ++++++ .../Commands/AddPermissionsSubcommandTests.cs | 28 +++- .../Helpers/ResourceResolutionHelperTests.cs | 109 +++++++++++++++ 5 files changed, 267 insertions(+), 98 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 996f64a1..56aae173 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; @@ -51,10 +50,21 @@ public static Command CreateCommand( ["--verbose", "-v"], description: "Show detailed output"); - var resourceOption = new Option( + var resourceOption = new Option( ["--resource", "-r"], - getDefaultValue: () => "mcp", - description: "Target resource API: 'mcp' (default), 'powerplatform'"); + description: "Target resource API: 'mcp' (default), 'powerplatform'. " + + "When specified, --scopes is required for non-mcp resources.") + { + IsRequired = false + }; + + var resourceIdOption = new Option( + ["--resource-id"], + description: "Resource application ID (GUID) to add permissions for. " + + "When specified, --scopes is required.") + { + IsRequired = false + }; var dryRunOption = new Option( ["--dry-run"], @@ -65,10 +75,11 @@ public static Command CreateCommand( command.AddOption(appIdOption); command.AddOption(scopesOption); command.AddOption(resourceOption); + command.AddOption(resourceIdOption); command.AddOption(verboseOption); command.AddOption(dryRunOption); - command.SetHandler(async (config, manifest, appId, scopes, resource, verbose, dryRun) => + command.SetHandler(async (config, manifest, appId, scopes, resource, resourceId, verbose, dryRun) => { try { @@ -120,6 +131,32 @@ public static Command CreateCommand( var manifestPath = manifest?.FullName ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, "ToolingManifest.json"); + var environment = setupConfig?.Environment ?? "prod"; + + // Resolve resource app ID + ResolvedResource resolvedResource; + try + { + resolvedResource = ResourceResolutionHelper.ResolveResource(resourceId, resource, environment); + } + catch (ArgumentException ex) + { + logger.LogError("Resource resolution error: {ErrorMessage}", ex.Message); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop add-permissions --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + Environment.Exit(1); + return; + } + + var resourceAppId = resolvedResource.ResourceAppId; + var resourceName = resolvedResource.DisplayName; + + logger.LogInformation("Target resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); + logger.LogInformation(""); + + // Determine if custom resource is being used + bool isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); + // Determine which scopes to add string[] requestedScopes; @@ -130,67 +167,48 @@ public static Command CreateCommand( logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); } + else if (isCustomResource) + { + logger.LogError("The --scopes option is required when using --resource or --resource-id."); + logger.LogInformation(""); + logger.LogInformation("Manifest-based scopes are only supported for the default flow."); + logger.LogInformation("Please omit the --resource and --resource-id options if you'd like to use manifest-based scopes."); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop add-permissions --resource powerplatform --scopes .default"); + Environment.Exit(1); + return; + } else { - // Only read scopes from ToolingManifest.json for mcp resource - if (resource.ToLowerInvariant() is "mcp") + // Default MCP flow: read scopes from ToolingManifest.json + if (!File.Exists(manifestPath)) { - // Read scopes from ToolingManifest.json - if (!File.Exists(manifestPath)) - { - logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath); - logger.LogInformation(""); - logger.LogInformation("Please ensure ToolingManifest.json exists in your project directory"); - logger.LogInformation("or specify scopes explicitly with --scopes option."); - logger.LogInformation(""); - logger.LogInformation("Example: a365 develop add-permissions --scopes McpServers.Mail.All McpServers.Calendar.All"); - Environment.Exit(1); - return; - } - - logger.LogInformation("Reading MCP server configuration from: {Path}", manifestPath); - - // Use ManifestHelper to extract scopes (includes fallback to mappings and McpServersMetadata.Read.All) - requestedScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - - if (requestedScopes.Length == 0) - { - logger.LogError("No scopes found in ToolingManifest.json"); - logger.LogInformation("You can specify scopes explicitly with --scopes option."); - Environment.Exit(1); - return; - } - - logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", - requestedScopes.Length, string.Join(", ", requestedScopes)); - } - else - { - // For other resources (like powerplatform), scopes are required - logger.LogError("--scopes is required when --resource {resource} is specified.", resource); + logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath); + logger.LogInformation(""); + logger.LogInformation("Please ensure ToolingManifest.json exists in your project directory"); + logger.LogInformation("or specify scopes explicitly with --scopes option."); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop add-permissions --resource {resource} --scopes ExampleScope.ReadWrite.All", resource); + logger.LogInformation("Example: a365 develop add-permissions --scopes McpServers.Mail.All McpServers.Calendar.All"); Environment.Exit(1); return; } - } - var environment = setupConfig?.Environment ?? "prod"; + logger.LogInformation("Reading MCP server configuration from: {Path}", manifestPath); - // Resolve resource configuration based on --resource option - var resolvedResource = ResourceResolutionHelper.ResolveByKeyword(resource, environment); - if (resolvedResource is null) - { - logger.LogError(ErrorMessages.UnknownResourceKeyword, resource); - Environment.Exit(1); - return; - } + // Use ManifestHelper to extract scopes (includes fallback to mappings and McpServersMetadata.Read.All) + requestedScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - var resourceAppId = resolvedResource.ResourceAppId; - var resourceName = resolvedResource.DisplayName; + if (requestedScopes.Length == 0) + { + logger.LogError("No scopes found in ToolingManifest.json"); + logger.LogInformation("You can specify scopes explicitly with --scopes option."); + Environment.Exit(1); + return; + } - logger.LogInformation("Target resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); - logger.LogInformation(""); + logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", + requestedScopes.Length, string.Join(", ", requestedScopes)); + } // Dry run mode if (dryRun) @@ -262,7 +280,7 @@ public static Command CreateCommand( logger.LogError(ex, "Failed to add API permissions: {Message}", ex.Message); Environment.Exit(1); } - }, configOption, manifestOption, appIdOption, scopesOption, resourceOption, verboseOption, dryRunOption); + }, configOption, manifestOption, appIdOption, scopesOption, resourceOption, resourceIdOption, verboseOption, dryRunOption); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index b941feca..cedbf1c1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -104,14 +104,6 @@ public static Command CreateCommand( try { - // Validate mutual exclusivity of --resource and --resource-id - if (!string.IsNullOrWhiteSpace(resource) && !string.IsNullOrWhiteSpace(resourceId)) - { - logger.LogError("Cannot specify both --resource and --resource-id. Use one or the other."); - Environment.Exit(1); - return; - } - // Determine if custom resource is being used bool isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); @@ -143,44 +135,28 @@ public static Command CreateCommand( var environment = setupConfig?.Environment ?? "prod"; // Resolve resource app ID - string resourceAppId; - string resourceDisplayName; - string? resourceUrl = null; - if (!string.IsNullOrWhiteSpace(resourceId)) + ResolvedResource resolvedResource; + try { - // Validate that resource ID is a valid GUID - if (!Guid.TryParse(resourceId, out _)) - { - logger.LogError("Invalid resource application ID: {ResourceId}. Expected a valid GUID.", resourceId); - logger.LogInformation(""); - logger.LogInformation("Example: a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); - Environment.Exit(1); - return; - } - - // User provided explicit resource ID - var customResolved = ResourceResolutionHelper.ResolveByCustomId(resourceId); - resourceAppId = customResolved.ResourceAppId; - resourceDisplayName = customResolved.DisplayName; - logger.LogInformation("Using custom resource ID: {ResourceId}", resourceId); + resolvedResource = ResourceResolutionHelper.ResolveResource(resourceId, resource, environment); } - else + catch (ArgumentException ex) { - // Resolve resource keyword to GUID (default to "mcp" if null) - var resolved = ResourceResolutionHelper.ResolveByKeyword(resource, environment); - if (resolved is null) - { - logger.LogError(ErrorMessages.UnknownResourceKeyword, resource); - Environment.Exit(1); - return; - } - - resourceAppId = resolved.ResourceAppId; - resourceDisplayName = resolved.DisplayName; - resourceUrl = resolved.Url; - logger.LogInformation("Using resource: {DisplayName}", resourceDisplayName); + logger.LogError("Resource resolution error: {ErrorMessage}", ex.Message); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + Environment.Exit(1); + return; } + var resourceAppId = resolvedResource.ResourceAppId; + var resourceDisplayName = resolvedResource.DisplayName; + var resourceUrl = resolvedResource.Url; + + // Log which resource was selected + logger.LogInformation("Selected resource: {ResourceDisplayName} (App ID: {ResourceAppId})", + resourceDisplayName, resourceAppId); + // Determine which scopes to request string[] requestedScopes; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs index 47be294f..cf2db0e2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs @@ -58,4 +58,44 @@ public static ResolvedResource ResolveByCustomId(string resourceId) { return new ResolvedResource(resourceId, $"Custom Resource ({resourceId})", null); } + + /// + /// Resolves a resource from either a custom resource ID (GUID) or a keyword. + /// Handles mutual exclusivity validation and GUID validation. + /// + /// The custom resource application ID (GUID), or null. + /// The resource keyword (e.g., "mcp", "powerplatform"), or null. + /// The environment to use for environment-aware resource resolution (e.g., "prod"). + /// A containing the app ID, display name, and optional URL. + /// Thrown when both resourceId and resource are provided, when resourceId is not a valid GUID, or when the resource keyword is unknown. + public static ResolvedResource ResolveResource(string? resourceId, string? resource, string environment) + { + // Validate mutual exclusivity + if (!string.IsNullOrWhiteSpace(resourceId) && !string.IsNullOrWhiteSpace(resource)) + { + throw new ArgumentException("Cannot specify both resourceId and resource. Use one or the other."); + } + + if (!string.IsNullOrWhiteSpace(resourceId)) + { + // Validate that resource ID is a valid GUID + if (!Guid.TryParse(resourceId, out _)) + { + throw new ArgumentException($"Invalid resource application ID: {resourceId}. Expected a valid GUID."); + } + + return ResolveByCustomId(resourceId); + } + else + { + // Resolve resource keyword to GUID (defaults to "mcp" if null) + var resolved = ResolveByKeyword(resource, environment); + if (resolved is null) + { + throw new ArgumentException($"Unknown resource keyword: {resource}"); + } + + return resolved; + } + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs index b2896fe5..bdf2fa76 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs @@ -114,6 +114,31 @@ public void CreateCommand_ShouldHaveVerboseOption() verboseOption.Aliases.Should().Contain("-v"); } + [Fact] + public void CreateCommand_ShouldHaveResourceOption() + { + // Act + var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); + + // Assert + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.Aliases.Should().Contain("--resource"); + resourceOption.Aliases.Should().Contain("-r"); + } + + [Fact] + public void CreateCommand_ShouldHaveResourceIdOption() + { + // Act + var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); + + // Assert + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.Aliases.Should().Contain("--resource-id"); + } + [Fact] public void CreateCommand_ShouldHaveDryRunOption() { @@ -133,7 +158,7 @@ public void CreateCommand_ShouldHaveAllRequiredOptions() var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); // Assert - command.Options.Should().HaveCount(7); + command.Options.Should().HaveCount(8); var optionNames = command.Options.Select(opt => opt.Name).ToList(); optionNames.Should().Contain(new[] { @@ -142,6 +167,7 @@ public void CreateCommand_ShouldHaveAllRequiredOptions() "app-id", "scopes", "resource", + "resource-id", "verbose", "dry-run" }); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs index 3fb38ed1..833bbf90 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs @@ -257,6 +257,115 @@ public void ResolvedResource_WithNullUrl_IsValid() #endregion + #region ResolveResource tests + + [Fact] + public void ResolveResource_WithValidResourceId_ReturnsCustomResource() + { + // Arrange + var resourceId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveResource(resourceId, null, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(resourceId); + result.DisplayName.Should().Be($"Custom Resource ({resourceId})"); + result.Url.Should().BeNull(); + } + + [Fact] + public void ResolveResource_WithInvalidResourceId_ThrowsArgumentException() + { + // Arrange + var invalidResourceId = "not-a-guid"; + + // Act + var act = () => ResourceResolutionHelper.ResolveResource(invalidResourceId, null, DefaultEnvironment); + + // Assert + act.Should().Throw() + .WithMessage("*Invalid resource application ID*"); + } + + [Fact] + public void ResolveResource_WithValidResourceKeyword_ReturnsResource() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, "mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + result.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveResource_WithUnknownResourceKeyword_ThrowsArgumentException() + { + // Act + var act = () => ResourceResolutionHelper.ResolveResource(null, "unknown", DefaultEnvironment); + + // Assert + act.Should().Throw() + .WithMessage("*Unknown resource keyword*"); + } + + [Fact] + public void ResolveResource_WithBothResourceIdAndKeyword_ThrowsArgumentException() + { + // Arrange + var resourceId = "12345678-1234-1234-1234-123456789abc"; + var resourceKeyword = "mcp"; + + // Act + var act = () => ResourceResolutionHelper.ResolveResource(resourceId, resourceKeyword, DefaultEnvironment); + + // Assert + act.Should().Throw() + .WithMessage("*Cannot specify both*"); + } + + [Fact] + public void ResolveResource_WithNeitherResourceIdNorKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, null, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + result.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveResource_WithEmptyResourceKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, "", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + result.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveResource_WithPowerPlatformKeyword_ReturnsPowerPlatformResource() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, "powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(MosConstants.PowerPlatformApiResourceAppId); + result.DisplayName.Should().Be("Power Platform API"); + result.Url.Should().BeNull(); + } + + #endregion + #region ErrorMessages constant test [Fact] From 6c042bc27380458119976a0c19eedc1717281576 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 11:36:52 -0800 Subject: [PATCH 13/23] update messages and comments --- .../AddPermissionsSubcommand.cs | 2 +- .../Constants/ErrorMessages.cs | 6 ++++++ .../Helpers/ResourceResolutionHelper.cs | 20 +++++++++---------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 56aae173..18542dc0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -180,7 +180,7 @@ public static Command CreateCommand( } else { - // Default MCP flow: read scopes from ToolingManifest.json + // Read scopes from ToolingManifest.json if (!File.Exists(manifestPath)) { logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs index 7eecc825..82108054 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs @@ -111,6 +111,12 @@ public static List GetGenericAppServicePlanMitigation() public const string UnknownResourceKeyword = "Unknown resource keyword '{0}'. Valid options: mcp, powerplatform"; + public const string InvalidResourceApplicationId = + "Invalid resource application ID: {0}. Expected a valid GUID."; + + public const string CannotSpecifyBothResourceIdAndKeyword = + "Cannot specify both resourceId and resource. Use one or the other."; + #endregion #region Client App Validation Messages diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs index cf2db0e2..d797a7d8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs @@ -73,7 +73,7 @@ public static ResolvedResource ResolveResource(string? resourceId, string? resou // Validate mutual exclusivity if (!string.IsNullOrWhiteSpace(resourceId) && !string.IsNullOrWhiteSpace(resource)) { - throw new ArgumentException("Cannot specify both resourceId and resource. Use one or the other."); + throw new ArgumentException(ErrorMessages.CannotSpecifyBothResourceIdAndKeyword); } if (!string.IsNullOrWhiteSpace(resourceId)) @@ -81,21 +81,19 @@ public static ResolvedResource ResolveResource(string? resourceId, string? resou // Validate that resource ID is a valid GUID if (!Guid.TryParse(resourceId, out _)) { - throw new ArgumentException($"Invalid resource application ID: {resourceId}. Expected a valid GUID."); + throw new ArgumentException(string.Format(ErrorMessages.InvalidResourceApplicationId, resourceId)); } return ResolveByCustomId(resourceId); } - else - { - // Resolve resource keyword to GUID (defaults to "mcp" if null) - var resolved = ResolveByKeyword(resource, environment); - if (resolved is null) - { - throw new ArgumentException($"Unknown resource keyword: {resource}"); - } - return resolved; + // Resolve resource keyword to GUID (defaults to "mcp" if null) + var resolved = ResolveByKeyword(resource, environment); + if (resolved is null) + { + throw new ArgumentException(string.Format(ErrorMessages.UnknownResourceKeyword, resource)); } + + return resolved; } } From d119463e75b7818710a6fd1e95279cb1a5dbaa65 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 11:39:29 -0800 Subject: [PATCH 14/23] Update option description --- .../Commands/DevelopSubcommands/AddPermissionsSubcommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 18542dc0..074255ef 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -53,7 +53,7 @@ public static Command CreateCommand( var resourceOption = new Option( ["--resource", "-r"], description: "Target resource API: 'mcp' (default), 'powerplatform'. " + - "When specified, --scopes is required for non-mcp resources.") + "When specified, --scopes is required.") { IsRequired = false }; From e7e2ea1e736efeace6a48714f38f9bfdc022052b Mon Sep 17 00:00:00 2001 From: Jesus Daniel Terrazas <96103167+JesuTerraz@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:50:45 -0800 Subject: [PATCH 15/23] Update docs/commands/develop/develop-addpermissions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/commands/develop/develop-addpermissions.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index d55d3463..6bdcbe34 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -23,10 +23,15 @@ a365 develop add-permissions [options] | `--manifest` | `-m` | Path to ToolingManifest.json (for mcp resource) | `/ToolingManifest.json` | | `--app-id` | | Application (client) ID to add permissions to | `clientAppId` from config | | `--resource` | `-r` | Target resource API: 'mcp' (default), 'powerplatform' | `mcp` | -| `--scopes` | | Specific scopes to add (space-separated) | All scopes from ToolingManifest.json (mcp) or required (powerplatform) | +| `--resource-id` | | Resource application ID (GUID) to add permissions to. Overrides `--resource`. Requires `--scopes` unless the resource and scopes can be resolved from `ToolingManifest.json` (for `mcp`). | (derived from `--resource`/manifest) | +| `--scopes` | | Specific scopes to add (space-separated). Required when using `--resource-id` for resources whose scopes cannot be inferred from `ToolingManifest.json`. | All scopes from ToolingManifest.json (mcp) or required defaults (powerplatform) | | `--verbose` | `-v` | Show detailed output | `false` | | `--dry-run` | | Show what would be done without making changes | `false` | +> **Resource and scopes behavior**: +> - If you only specify `--resource mcp` (default) with a valid `--manifest`, the command reads the resource application ID and default scopes from `ToolingManifest.json`. +> - If you specify `--resource-id` without `--scopes`, the command can only infer scopes when the resource ID matches the MCP resource defined in `ToolingManifest.json`; otherwise you must pass `--scopes`. +> - For any other `--resource-id`, you must provide `--scopes` explicitly. ## When to Use This Command ### Development Scenarios From b29512c2f31bd930a9ff836f644f73f559692057 Mon Sep 17 00:00:00 2001 From: Jesus Daniel Terrazas <96103167+JesuTerraz@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:54:17 -0800 Subject: [PATCH 16/23] Update src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Constants/ErrorMessages.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs index 82108054..225d9795 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs @@ -115,7 +115,7 @@ public static List GetGenericAppServicePlanMitigation() "Invalid resource application ID: {0}. Expected a valid GUID."; public const string CannotSpecifyBothResourceIdAndKeyword = - "Cannot specify both resourceId and resource. Use one or the other."; + "Cannot specify both `--resource-id` and `--resource`. Use one or the other."; #endregion From 8524a9f46b14ac0d1b8b1dd0d0652f355af4c7e2 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 11:57:34 -0800 Subject: [PATCH 17/23] update doc --- docs/commands/develop/develop-addpermissions.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index 6bdcbe34..d1eb2860 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -29,9 +29,8 @@ a365 develop add-permissions [options] | `--dry-run` | | Show what would be done without making changes | `false` | > **Resource and scopes behavior**: -> - If you only specify `--resource mcp` (default) with a valid `--manifest`, the command reads the resource application ID and default scopes from `ToolingManifest.json`. -> - If you specify `--resource-id` without `--scopes`, the command can only infer scopes when the resource ID matches the MCP resource defined in `ToolingManifest.json`; otherwise you must pass `--scopes`. -> - For any other `--resource-id`, you must provide `--scopes` explicitly. +> - If you specify either `--resourced` or `--resource-id`, you must provide `--scopes` explicitly. + ## When to Use This Command ### Development Scenarios @@ -138,6 +137,4 @@ a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --sco - **Resource Key**: `powerplatform` - **Target**: Power Platform API (`8578e004-a5c6-46e7-913e-12f58912df43`) - **Scope Source**: **Required** explicit `--scopes` (no defaults) -- **Example Scopes**: `CopilotStudio.Copilots.Invoke` - -**Note**: When using `--resource powerplatform`, the `--scopes` option is required. The command will not default to any scope and will show an error if scopes are not explicitly provided. \ No newline at end of file +- **Example Scopes**: `CopilotStudio.Copilots.Invoke` \ No newline at end of file From 90536629384356b18a34aa31dc0836f24455f2a0 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 14:10:51 -0800 Subject: [PATCH 18/23] Update docs to be consistent about explicit scopes with resource flags --- docs/commands/develop/develop-addpermissions.md | 4 ++-- .../Helpers/ResourceResolutionHelper.cs | 8 ++++---- .../Helpers/ResourceResolutionHelperTests.cs | 13 ++----------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index d1eb2860..f08111e6 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -23,8 +23,8 @@ a365 develop add-permissions [options] | `--manifest` | `-m` | Path to ToolingManifest.json (for mcp resource) | `/ToolingManifest.json` | | `--app-id` | | Application (client) ID to add permissions to | `clientAppId` from config | | `--resource` | `-r` | Target resource API: 'mcp' (default), 'powerplatform' | `mcp` | -| `--resource-id` | | Resource application ID (GUID) to add permissions to. Overrides `--resource`. Requires `--scopes` unless the resource and scopes can be resolved from `ToolingManifest.json` (for `mcp`). | (derived from `--resource`/manifest) | -| `--scopes` | | Specific scopes to add (space-separated). Required when using `--resource-id` for resources whose scopes cannot be inferred from `ToolingManifest.json`. | All scopes from ToolingManifest.json (mcp) or required defaults (powerplatform) | +| `--resource-id` | | Resource application ID (GUID) to add permissions to. Overrides `--resource`. Requires `--scopes` | (derived from `--resource`) | +| `--scopes` | | Specific scopes to add (space-separated). Required when using `--resource` or `--resource-id` | All scopes from ToolingManifest.json (mcp) or required defaults | | `--verbose` | `-v` | Show detailed output | `false` | | `--dry-run` | | Show what would be done without making changes | `false` | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs index d797a7d8..02192305 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs @@ -56,7 +56,7 @@ public static class ResourceResolutionHelper /// A with a generic display name and no URL. public static ResolvedResource ResolveByCustomId(string resourceId) { - return new ResolvedResource(resourceId, $"Custom Resource ({resourceId})", null); + return new ResolvedResource(resourceId, $"Custom Resource", null); } /// @@ -73,7 +73,7 @@ public static ResolvedResource ResolveResource(string? resourceId, string? resou // Validate mutual exclusivity if (!string.IsNullOrWhiteSpace(resourceId) && !string.IsNullOrWhiteSpace(resource)) { - throw new ArgumentException(ErrorMessages.CannotSpecifyBothResourceIdAndKeyword); + throw new ArgumentException(ErrorMessages.CannotSpecifyBothResourceIdAndKeyword, nameof(resource)); } if (!string.IsNullOrWhiteSpace(resourceId)) @@ -81,7 +81,7 @@ public static ResolvedResource ResolveResource(string? resourceId, string? resou // Validate that resource ID is a valid GUID if (!Guid.TryParse(resourceId, out _)) { - throw new ArgumentException(string.Format(ErrorMessages.InvalidResourceApplicationId, resourceId)); + throw new ArgumentException(string.Format(ErrorMessages.InvalidResourceApplicationId, resourceId), nameof(resourceId)); } return ResolveByCustomId(resourceId); @@ -91,7 +91,7 @@ public static ResolvedResource ResolveResource(string? resourceId, string? resou var resolved = ResolveByKeyword(resource, environment); if (resolved is null) { - throw new ArgumentException(string.Format(ErrorMessages.UnknownResourceKeyword, resource)); + throw new ArgumentException(string.Format(ErrorMessages.UnknownResourceKeyword, resource), nameof(resource)); } return resolved; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs index 833bbf90..064b44ea 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs @@ -145,6 +145,7 @@ public void ResolveByKeyword_EmptyKeyword_DefaultsToMcp() // Assert result.Should().NotBeNull(); result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); } [Fact] @@ -156,6 +157,7 @@ public void ResolveByKeyword_WhitespaceKeyword_DefaultsToMcp() // Assert result.Should().NotBeNull(); result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); } #endregion @@ -232,17 +234,6 @@ public void ResolveByCustomId_ValidGuid_ReturnsNullUrl() #region ResolvedResource record tests - [Fact] - public void ResolvedResource_RecordEquality_WorksCorrectly() - { - // Arrange - var resource1 = new ResolvedResource("app-id", "Display Name", "https://example.com"); - var resource2 = new ResolvedResource("app-id", "Display Name", "https://example.com"); - - // Assert - resource1.Should().Be(resource2); - } - [Fact] public void ResolvedResource_WithNullUrl_IsValid() { From 2aed990ce16a677c1cc565ec87b2211758466447 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 14:14:33 -0800 Subject: [PATCH 19/23] Specify resource / scope behavior a little more --- docs/commands/develop/develop-addpermissions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index f08111e6..841b0f85 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -23,13 +23,15 @@ a365 develop add-permissions [options] | `--manifest` | `-m` | Path to ToolingManifest.json (for mcp resource) | `/ToolingManifest.json` | | `--app-id` | | Application (client) ID to add permissions to | `clientAppId` from config | | `--resource` | `-r` | Target resource API: 'mcp' (default), 'powerplatform' | `mcp` | -| `--resource-id` | | Resource application ID (GUID) to add permissions to. Overrides `--resource`. Requires `--scopes` | (derived from `--resource`) | +| `--resource-id` | | Resource application ID (GUID) to add permissions to. Requires `--scopes` | None | | `--scopes` | | Specific scopes to add (space-separated). Required when using `--resource` or `--resource-id` | All scopes from ToolingManifest.json (mcp) or required defaults | | `--verbose` | `-v` | Show detailed output | `false` | | `--dry-run` | | Show what would be done without making changes | `false` | > **Resource and scopes behavior**: +> - The `--resource` and `--resource-id` flags are mutually exclusive. > - If you specify either `--resourced` or `--resource-id`, you must provide `--scopes` explicitly. +> - If you wish to use the scopes specified in the ToolingManifest.json, please omit the resource flags. ## When to Use This Command From e38f0eabb6b04f66197a4d8449b02c951f638e94 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 14:29:22 -0800 Subject: [PATCH 20/23] update tests --- .../Helpers/ResourceResolutionHelperTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs index 064b44ea..1caf50c0 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs @@ -213,7 +213,7 @@ public void ResolveByCustomId_ValidGuid_ReturnsGenericDisplayName() // Assert result.Should().NotBeNull(); - result.DisplayName.Should().Be($"Custom Resource ({customId})"); + result.DisplayName.Should().Be("Custom Resource"); } [Fact] @@ -262,7 +262,7 @@ public void ResolveResource_WithValidResourceId_ReturnsCustomResource() // Assert result.Should().NotBeNull(); result.ResourceAppId.Should().Be(resourceId); - result.DisplayName.Should().Be($"Custom Resource ({resourceId})"); + result.DisplayName.Should().Be("Custom Resource"); result.Url.Should().BeNull(); } From 71ba35578a5b7946f030b02ecdd21d44eaf8f1da Mon Sep 17 00:00:00 2001 From: Jesus Daniel Terrazas <96103167+JesuTerraz@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:31:26 -0800 Subject: [PATCH 21/23] Update docs/commands/develop/develop-addpermissions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/commands/develop/develop-addpermissions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index 841b0f85..8374de20 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -30,7 +30,7 @@ a365 develop add-permissions [options] > **Resource and scopes behavior**: > - The `--resource` and `--resource-id` flags are mutually exclusive. -> - If you specify either `--resourced` or `--resource-id`, you must provide `--scopes` explicitly. +> - If you specify either `--resource` or `--resource-id`, you must provide `--scopes` explicitly. > - If you wish to use the scopes specified in the ToolingManifest.json, please omit the resource flags. ## When to Use This Command From 928f4ad362f0ae653531f84f78bfc19b31383adb Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Tue, 10 Feb 2026 14:45:52 -0800 Subject: [PATCH 22/23] add more example usage --- .../Commands/DevelopSubcommands/AddPermissionsSubcommand.cs | 2 ++ .../Commands/DevelopSubcommands/GetTokenSubcommand.cs | 2 ++ .../Helpers/ResourceResolutionHelper.cs | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 074255ef..1dbcf4c4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -144,6 +144,8 @@ public static Command CreateCommand( logger.LogError("Resource resolution error: {ErrorMessage}", ex.Message); logger.LogInformation(""); logger.LogInformation("Example: a365 develop add-permissions --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + logger.LogInformation("Example: a365 develop add-permissions --resource powerplatform --scopes .default"); + logger.LogInformation("Example: a365 develop add-permissions"); Environment.Exit(1); return; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index cedbf1c1..d13437b5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -145,6 +145,8 @@ public static Command CreateCommand( logger.LogError("Resource resolution error: {ErrorMessage}", ex.Message); logger.LogInformation(""); logger.LogInformation("Example: a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + logger.LogInformation("Example: a365 develop get-token --resource powerplatform --scopes .default"); + logger.LogInformation("Example: a365 develop get-token"); Environment.Exit(1); return; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs index 02192305..1d2bcd79 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs @@ -56,7 +56,7 @@ public static class ResourceResolutionHelper /// A with a generic display name and no URL. public static ResolvedResource ResolveByCustomId(string resourceId) { - return new ResolvedResource(resourceId, $"Custom Resource", null); + return new ResolvedResource(resourceId, "Custom Resource", null); } /// From 377697b2e815ecab68501e4cdf3fbda2b6feb274 Mon Sep 17 00:00:00 2001 From: Jesus Terrazas Date: Wed, 11 Feb 2026 16:11:19 -0800 Subject: [PATCH 23/23] add missing using directive --- .../Commands/DevelopSubcommands/AddPermissionsSubcommand.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 3b5ef1cb..e79ef7e1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging;