From f5146e4fce613d140f99e189e07496ea238f3e56 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 13 Nov 2025 22:02:45 -0800 Subject: [PATCH 1/2] Add Deploy mcp sub commands --- README.md | 4 + docs/commands/config-init.md | 2 +- docs/commands/deploy.md | 192 ++++++++ .../Commands/CreateInstanceCommand.cs | 12 +- .../Commands/DeployCommand.cs | 422 ++++++++++++++++-- .../Commands/SetupCommand.cs | 20 +- .../Services/A365CreateInstanceRunner.cs | 42 +- .../Services/A365SetupRunner.cs | 58 +-- .../Services/GraphApiService.cs | 107 +++++ 9 files changed, 746 insertions(+), 113 deletions(-) create mode 100644 docs/commands/deploy.md diff --git a/README.md b/README.md index f3eac9b2..ebfca96a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ a365 create-instance enable-notifications ### Deploy & Cleanup ```bash a365 deploy # Full build and deploy +a365 deploy app # Deploy application binaries to the configured Azure App Service +a365 deploy mcp # Update Agent365 Tool permissions a365 deploy --restart # Skip build, deploy existing publish folder (quick iteration) a365 deploy --inspect # Pause before deployment to verify package contents a365 deploy --restart --inspect # Combine flags for quick redeploy with inspection @@ -110,6 +112,8 @@ a365 cleanup **Deploy Options Explained:** - **Default** (`a365 deploy`): Full build pipeline - platform detection, environment validation, build, manifest creation, packaging, and deployment +- **app**: Deploy application binaries to the configured Azure App Service +- **mcp**: Update Agent365 Tool permissions - **`--restart`**: Skip all build steps and start from compressing the existing `publish/` folder. Perfect for quick iteration when you've manually modified files in the publish directory (e.g., tweaking `requirements.txt`, `.deployment`, or other config files) - **`--inspect`**: Pause before deployment to review the publish folder and ZIP contents. Useful for verifying package structure before uploading to Azure - **`--verbose`**: Enable detailed logging for all build and deployment steps diff --git a/docs/commands/config-init.md b/docs/commands/config-init.md index eac76716..2abe1ca2 100644 --- a/docs/commands/config-init.md +++ b/docs/commands/config-init.md @@ -190,7 +190,7 @@ Uses: ```bash # Deploy your agent to Azure -a365 deploy +a365 deploy app ``` Uses: diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md new file mode 100644 index 00000000..27c34d43 --- /dev/null +++ b/docs/commands/deploy.md @@ -0,0 +1,192 @@ +# Agent365 CLI – Deploy Command Guide + +> **Command**: `a365 deploy` +> **Purpose**: Deploy your application to Azure App Service **and** update Agent365 Tool (MCP) permissions in one run. Subcommands let you run either phase independently. +--- + +## TL;DR + +```bash +# Full two-phase deploy (App binaries, then MCP permissions) +a365 deploy + +# App-only deploy +a365 deploy app + +# MCP-only permissions update +a365 deploy mcp + +# Common flags +a365 deploy --restart # reuse existing publish/ (skip build) +a365 deploy --inspect # pause to review publish/ and zip +a365 deploy --dry-run # print actions, no changes +a365 deploy --verbose # detailed logs +``` + +--- + +## What the command actually does + +### Default (`a365 deploy`) +Runs **two phases sequentially**: + +**Part 1 — App Binaries** +1. Load `a365.config.json` (+ dynamic state from generated config). +2. **Azure preflight** + - Validates Azure CLI auth + subscription context (`ValidateAllAsync`). + - Ensures target Web App exists via `az webapp show`. +3. Build/package via `DeploymentService.DeployAsync(...)` (supports `--inspect` and `--restart`). +4. Log success/failure. + +**Part 2 — MCP Permissions** +1. Re-load config (same path). +2. Read required scopes from `deploymentProjectPath/toolingManifest.json`. +3. Apply **in order**: + - **OAuth2 grant**: `CreateOrUpdateOauth2PermissionGrantAsync` + - **Inheritable permissions**: `SetInheritablePermissionsAsync` + - **Admin consent (agent identity)**: `ReplaceOauth2PermissionGrantAsync` +4. Log success/failure. + +--- + +## Subcommands & Flags + +### `a365 deploy` (default, two-phase) +- **Options**: `--config|-c`, `--verbose|-v`, `--dry-run`, `--inspect`, `--restart` +- **Behavior**: Runs **App** then **MCP**, prints “Part 1…” and “Part 2…” sections (even on `--dry-run`). + +### `a365 deploy app` (app-only) +- **Options**: `--config|-c`, `--verbose|-v`, `--dry-run`, `--inspect`, `--restart` +- **Behavior**: Only runs the App phase (includes the same Azure validations and Web App existence check). + +### `a365 deploy mcp` (MCP-only) +- **Options**: `--config|-c`, `--verbose|-v`, `--dry-run` +- **Behavior**: Only runs the MCP permissions sequence (no `--inspect` or `--restart` here). + +--- + +## Preflight Checks + +- **Azure auth & subscription**: Validated via `ValidateAllAsync(subscriptionId)`. + If invalid, deployment is stopped with a clear error. +- **Web App existence**: `az webapp show --resource-group --name --subscription ` must succeed before app deploy proceeds. + +--- + +## Configuration Inputs + +- **`a365.config.json`** (user-maintained) and **`a365.generated.config.json`** (dynamic state) +- **Tooling scopes**: Read from `/toolingManifest.json` during the MCP phase +- `--config` defaults to `a365.config.json` in the current directory + +> The CLI also keeps **global** copies of config/state in: +> - Windows: `%LocalAppData%\Microsoft.Agents.A365.DevTools.Cli` +> - Linux/macOS: `$HOME/.config/Microsoft.Agents.A365.DevTools.Cli` +--- + +## Flags (behavior details) + +- `--restart` + Skip a fresh build and start from compressing the **existing** `publish/` folder. If `publish/` is missing, the deploy fails with guidance to run a full deploy. + +- `--inspect` + Pause before upload so you can inspect `publish/` and the generated ZIP. (App phase only.) + +- `--dry-run` + Print everything that would happen. The default command shows **two sections**: + - *Part 1 — Deploy application binaries* (target RG/app, config path) + - *Part 2 — Deploy/update Agent 365 Tool permissions* (the three MCP steps) + No changes are made. + +- `--verbose` + Enables detailed logging in both phases. + +--- + +## MCP Permission Update Flow (exact order) + +When running `a365 deploy` or `a365 deploy mcp`: + +1. **OAuth2 permission grant** + `CreateOrUpdateOauth2PermissionGrantAsync(tenant, blueprintSp, mcpPlatformSp, scopes)` + +2. **Inheritable permissions** + `SetInheritablePermissionsAsync(tenant, agentBlueprintAppId, mcpResourceAppId, scopes)` + +3. **Admin consent** (agent identity → MCP platform) + `ReplaceOauth2PermissionGrantAsync(tenant, agenticAppSpObjectId, mcpPlatformResourceSpObjectId, scopes)` + +> All scopes are sourced from `toolingManifest.json` in your project root. +--- + +## Typical Flows + +### Full two-phase deploy with visibility +```bash +a365 deploy --verbose +``` + +### Quick iteration (reuse last build) +```bash +a365 deploy --restart +``` + +### MCP only (permissions/scopes refresh) +```bash +a365 deploy mcp --verbose +``` + +### Validate everything without changing anything +```bash +a365 deploy --dry-run --inspect +``` + +--- + +## Troubleshooting + +- **“Not logged into Azure” or wrong subscription** + Fix with `az login --tenant ` and `az account set --subscription `. + +- **Web App not found** + Ensure `a365 setup` has been run or correct `webAppName`/`resourceGroup` are in `a365.config.json`. + +- **Permissions update fails** + - Confirm `AgentBlueprintId`, `AgenticAppId`, and environment are set in config. + - Ensure your account has rights to manage service principals and grants. + - Verify `toolingManifest.json` exists and contains valid scopes. + +- **`--restart` fails** + Run a full build once (no `--restart`) to produce `publish/`. + +--- + +## Logs + +**CLI logs** +- Windows: `%LocalAppData%\Microsoft.Agents.A365.DevTools.Cli\logs\` +- Linux/macOS: `~/.config/a365/logs/` + +Tail latest deploy logs: +```powershell +# Windows +Get-Content $env:LOCALAPPDATA\Microsoft.Agents.A365.DevTools.Cli\logs\a365.deploy.log -Tail 80 +``` + +```bash +# Linux/Mac +tail -80 ~/.config/a365/logs/a365.deploy.log +``` + +**App Service logs** +Use Log Stream in the Azure Portal for runtime stdout/stderr. + +--- + +## Related + +- [`a365 setup`](./setup.md) — provision resources and register the messaging endpoint +- [`a365 create-instance`](./create-instance.md) — create agent identity/user and licenses +- [`a365 config init`](./config-init.md) — initialize configuration + +--- diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 78cd10c5..3ce478ff 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -114,16 +114,16 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" Agent User Principal Name: {AgentUserPrincipalName}", instanceConfig.AgentUserPrincipalName ?? "(not set)"); // Step 4: Admin consent for MCP scopes (oauth2PermissionGrants) - logger.LogInformation("Step 5/5: Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); + logger.LogInformation("Step 4: Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); var manifestPath = Path.Combine(instanceConfig.DeploymentProjectPath ?? string.Empty, "ToolingManifest.json"); var scopesForAgent = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - // clientId must be the *service principal objectId* of the agent identity app - var agentIdentitySpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( + // clientId must be the *service principal objectId* of the agentic app + var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( instanceConfig.TenantId, instanceConfig.AgenticAppId ?? string.Empty - ) ?? throw new InvalidOperationException($"Service Principal not found for agent identity appId {instanceConfig.AgenticAppId}"); + ) ?? throw new InvalidOperationException($"Service Principal not found for agentic app Id {instanceConfig.AgenticAppId}"); var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(instanceConfig.Environment); var Agent365ToolsResourceSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(instanceConfig.TenantId, resourceAppId) @@ -131,7 +131,7 @@ public static Command CreateCommand(ILogger logger, IConf var response = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( instanceConfig.TenantId, - agentIdentitySpObjectId, + agenticAppSpObjectId , Agent365ToolsResourceSpObjectId, scopesForAgent ); @@ -154,7 +154,7 @@ public static Command CreateCommand(ILogger logger, IConf // Grant oauth2PermissionGrants: *agent identity SP* -> Messaging Bot API SP var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( instanceConfig.TenantId, - agentIdentitySpObjectId, + agenticAppSpObjectId , botApiResourceSpObjectId, new[] { "Authorization.ReadWrite", "user_impersonation" }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index b7331460..f5ddd45e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.CommandLine; +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; @@ -18,7 +20,7 @@ public static Command CreateCommand( IAzureValidator azureValidator) { // Top-level command name set to 'deploy' so it appears in CLI help as 'deploy' - var command = new Command("deploy", "Deploy Agent 365 application binaries to the configured Azure App Service"); + var command = new Command("deploy", "Deploy Agent 365 application binaries to the configured Azure App Service and update Agent 365 Tool permissions"); var configOption = new Option( new[] { "--config", "-c" }, @@ -47,6 +49,10 @@ public static Command CreateCommand( command.AddOption(inspectOption); command.AddOption(restartOption); + // Add subcommands + command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, azureValidator)); + command.AddCommand(CreateMcpSubcommand(logger, configService, executor)); + // Single handler for the deploy command command.SetHandler(async (config, verbose, dryRun, inspect, restart) => { @@ -54,77 +60,243 @@ public static Command CreateCommand( { // Suppress stale warning since deploy is a legitimate read-only operation var configData = await configService.LoadAsync(config.FullName); - if (configData == null) return; if (dryRun) { - logger.LogInformation("DRY RUN: Deploy application binaries"); + logger.LogInformation("DRY RUN: Step 1 - Deploy application binaries"); logger.LogInformation("Target resource group: {ResourceGroup}", configData.ResourceGroup); logger.LogInformation("Target web app: {WebAppName}", configData.WebAppName); logger.LogInformation("Configuration file validated: {ConfigFile}", config.FullName); + logger.LogInformation(""); + logger.LogInformation("DRY RUN: Step 2 - Deploy/update Agent 365 Tool permissions"); + logger.LogInformation("Update MCP OAuth2 permission grants and inheritable permissions"); + logger.LogInformation("Consent to required scopes for the agent identity"); return; } - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(configData.SubscriptionId)) - { - logger.LogError("Deployment cannot proceed without proper Azure CLI authentication and the correct subscription context"); - return; - } + // Step 1: Deploy application binaries + logger.LogInformation("Step 1: Start deploying application binaries..."); + + var validatedConfig = await ValidateDeploymentPrerequisitesAsync( + config.FullName, configService, azureValidator, executor, logger); + if (validatedConfig == null) return; + + var appDeploySuccess = await DeployApplicationAsync( + validatedConfig, deploymentService, verbose, inspect, restart, logger); + if (!appDeploySuccess) return; + + // Step 2: Deploy MCP Tool Permissions + logger.LogInformation("Step 2: Start deploying Agent 365 Tool Permissions..."); + await DeployMcpToolPermissionsAsync(validatedConfig, executor, logger); + } + catch (Exception ex) + { + HandleDeploymentException(ex, logger); + if (ex is not FileNotFoundException) + throw; + } + }, configOption, verboseOption, dryRunOption, inspectOption, restartOption); + + return command; + } + + private static Command CreateAppSubcommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor, + DeploymentService deploymentService, + IAzureValidator azureValidator) + { + var command = new Command("app", "Deploy Agent365 application binaries to the configured Azure App Service"); + + var configOption = new Option( + new[] { "--config", "-c" }, + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Path to the configuration file (default: a365.config.json)"); + + var verboseOption = new Option( + new[] { "--verbose", "-v" }, + description: "Enable verbose logging"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + var inspectOption = new Option( + "--inspect", + description: "Pause before deployment to inspect publish folder and ZIP contents"); + + var restartOption = new Option( + "--restart", + description: "Skip build and start from compressing existing publish folder (for quick iteration after manual changes)"); - // Validate Azure Web App exists before starting deployment - logger.LogInformation("Validating Azure Web App exists..."); - var checkResult = await executor.ExecuteAsync("az", - $"webapp show --resource-group {configData.ResourceGroup} --name {configData.WebAppName} --subscription {configData.SubscriptionId}", - captureOutput: true, - suppressErrorLogging: true); + command.AddOption(configOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + command.AddOption(inspectOption); + command.AddOption(restartOption); + + command.SetHandler(async (config, verbose, dryRun, inspect, restart) => + { + try + { + // Suppress stale warning since deploy is a legitimate read-only operation + var configData = await configService.LoadAsync(config.FullName); + if (configData == null) return; - if (!checkResult.Success) + if (dryRun) { - logger.LogError("Azure Web App '{WebAppName}' does not exist in resource group '{ResourceGroup}'", - configData.WebAppName, configData.ResourceGroup); - logger.LogInformation(""); - logger.LogInformation("Please ensure the Web App exists before deploying:"); - logger.LogInformation(" 1. Run 'a365 setup' to create all required Azure resources"); - logger.LogInformation(" 2. Or verify your a365.config.json has the correct WebAppName and ResourceGroup"); - logger.LogInformation(""); - logger.LogError("Deployment cannot proceed without a valid Azure Web App target"); + logger.LogInformation("DRY RUN: Deploy application binaries"); + logger.LogInformation("Target resource group: {ResourceGroup}", configData.ResourceGroup); + logger.LogInformation("Target web app: {WebAppName}", configData.WebAppName); + logger.LogInformation("Configuration file validated: {ConfigFile}", config.FullName); return; } - - logger.LogInformation("Confirmed Azure Web App '{WebAppName}' exists", configData.WebAppName); - var deployConfig = ConvertToDeploymentConfig(configData); - var success = await deploymentService.DeployAsync(deployConfig, verbose, inspect, restart); - if (!success) - { - logger.LogError("Deployment failed"); - } - else - { - logger.LogInformation("Deployment completed successfully"); - } + var validatedConfig = await ValidateDeploymentPrerequisitesAsync( + config.FullName, configService, azureValidator, executor, logger); + if (validatedConfig == null) return; + + await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger); } - catch (FileNotFoundException ex) + catch (Exception ex) { - logger.LogError("Configuration file not found: {Message}", ex.Message); - logger.LogInformation(""); - logger.LogInformation("To get started:"); - logger.LogInformation(" 1. Copy a365.config.example.json to a365.config.json"); - logger.LogInformation(" 2. Edit a365.config.json with your Azure tenant and subscription details"); - logger.LogInformation(" 3. Run 'a365 deploy' to perform a deployment"); - logger.LogInformation(""); + HandleDeploymentException(ex, logger); + } + }, configOption, verboseOption, dryRunOption, inspectOption, restartOption); + + return command; + } + + private static Command CreateMcpSubcommand( + ILogger logger, + IConfigService configService, + CommandExecutor executor) + { + var command = new Command("mcp", "Update mcp servers scopes and permissions on existing agent blueprint"); + + var configOption = new Option( + ["--config", "-c"], + getDefaultValue: () => new FileInfo("a365.config.json"), + description: "Configuration file path"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Enable verbose logging"); + + 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) => + { + if (dryRun) + { + logger.LogInformation("DRY RUN: Deploy/update Agent 365 Tool Permissions"); + logger.LogInformation("This would execute the following operations:"); + logger.LogInformation(" 1. Update MCP OAuth2 permission grants and inheritable permissions"); + logger.LogInformation(" 2. Consent to required scopes for the agent identity"); + logger.LogInformation("No actual changes will be made."); + return; + } + + logger.LogInformation("Starting deploy Agent 365 Tool Permissions..."); + logger.LogInformation(""); // Empty line for readability + + try + { + // Load configuration from specified file + var updateConfig = await configService.LoadAsync(config.FullName); + if (updateConfig == null) Environment.Exit(1); + + await DeployMcpToolPermissionsAsync(updateConfig, executor, logger); } catch (Exception ex) { - logger.LogError(ex, "Deployment failed: {Message}", ex.Message); + logger.LogError(ex, "Agent 365 Tool Permissions deploy/update failed: {Message}", ex.Message); + throw; } - }, configOption, verboseOption, dryRunOption, inspectOption, restartOption); + }, configOption, verboseOption, dryRunOption); return command; } + /// + /// Validates configuration, Azure authentication, and Web App existence + /// + private static async Task ValidateDeploymentPrerequisitesAsync( + string configPath, + IConfigService configService, + IAzureValidator azureValidator, + CommandExecutor executor, + ILogger logger) + { + // Load configuration + var configData = await configService.LoadAsync(configPath); + if (configData == null) return null; + + // Validate Azure CLI authentication, subscription, and environment + if (!await azureValidator.ValidateAllAsync(configData.SubscriptionId)) + { + logger.LogError("Deployment cannot proceed without proper Azure CLI authentication and the correct subscription context"); + return null; + } + + // Validate Azure Web App exists before starting deployment + logger.LogInformation("Validating Azure Web App exists..."); + var checkResult = await executor.ExecuteAsync("az", + $"webapp show --resource-group {configData.ResourceGroup} --name {configData.WebAppName} --subscription {configData.SubscriptionId}", + captureOutput: true, + suppressErrorLogging: true); + + if (!checkResult.Success) + { + logger.LogError("Azure Web App '{WebAppName}' does not exist in resource group '{ResourceGroup}'", + configData.WebAppName, configData.ResourceGroup); + logger.LogInformation(""); + logger.LogInformation("Please ensure the Web App exists before deploying:"); + logger.LogInformation(" 1. Run 'a365 setup' to create all required Azure resources"); + logger.LogInformation(" 2. Or verify your a365.config.json has the correct WebAppName and ResourceGroup"); + logger.LogInformation(""); + logger.LogError("Deployment cannot proceed without a valid Azure Web App target"); + return null; + } + + logger.LogInformation("Confirmed Azure Web App '{WebAppName}' exists", configData.WebAppName); + return configData; + } + + /// + /// Performs application deployment using DeploymentService + /// + private static async Task DeployApplicationAsync( + Agent365Config configData, + DeploymentService deploymentService, + bool verbose, + bool inspect, + bool restart, + ILogger logger) + { + var deployConfig = ConvertToDeploymentConfig(configData); + var success = await deploymentService.DeployAsync(deployConfig, verbose, inspect, restart); + + if (!success) + { + logger.LogError("Deployment failed"); + } + else + { + logger.LogInformation("Deployment completed successfully"); + } + + return success; + } + /// /// Convert Agent 365Config to DeploymentConfiguration /// @@ -140,4 +312,162 @@ private static DeploymentConfiguration ConvertToDeploymentConfig(Agent365Config Platform = null // Auto-detect platform }; } + + /// + /// Performs MCP tool permissions deployment + /// + private static async Task DeployMcpToolPermissionsAsync( + Agent365Config config, + CommandExecutor executor, + ILogger logger) + { + // Read scopes from toolingManifest.json (at deploymentProjectPath) + var manifestPath = Path.Combine(config.DeploymentProjectPath ?? string.Empty, "toolingManifest.json"); + var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + + var graphService = new GraphApiService( + LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), + executor); + + // Step 1: Apply MCP OAuth2 permission grants + logger.LogInformation("Step 1: Applying MCP OAuth2 permission grants and inheritable permissions..."); + await EnsureMcpOauth2PermissionGrantsAsync( + graphService, + config, + toolingScopes, + logger + ); + + // Step 2: Apply inheritable permissions on the agent identity blueprint + logger.LogInformation("Step 2: Applying MCP inheritable permissions..."); + await EnsureMcpInheritablePermissionsAsync( + graphService, + config, + toolingScopes, + logger + ); + + // Step 3: Consent to required scopes for the agent identity + logger.LogInformation("Step 3: Consenting to required MCP scopes for the agent identity..."); + await EnsureAdminConsentForAgenticAppAsync( + graphService, + config, + toolingScopes, + logger + ); + + logger.LogInformation("Deploy Agent 365 Tool Permissions completed successfully!"); + } + + private static async Task EnsureMcpOauth2PermissionGrantsAsync( + GraphApiService graphService, + Agent365Config config, + string[] scopes, + ILogger logger, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + + var blueprintSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct) + ?? throw new InvalidOperationException("Blueprint Service Principal not found for appId " + config.AgentBlueprintId); + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var mcpPlatformSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct) + ?? throw new InvalidOperationException("MCP Platform Service Principal not found for appId " + resourceAppId); + + var ok = await graphService.ReplaceOauth2PermissionGrantAsync( + config.TenantId, blueprintSpObjectId, mcpPlatformSpObjectId, scopes, ct); + + if (!ok) throw new InvalidOperationException("Failed to update oauth2PermissionGrant."); + + logger.LogInformation(" - OAuth2 granted: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", + blueprintSpObjectId, mcpPlatformSpObjectId, string.Join(' ', scopes)); + } + + private static async Task EnsureMcpInheritablePermissionsAsync( + GraphApiService graphService, + Agent365Config config, + string[] scopes, + ILogger logger, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + + var (ok, alreadyExists, err) = await graphService.SetInheritablePermissionsAsync( + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, ct); + + if (!ok && !alreadyExists) + { + config.InheritanceConfigured = false; + config.InheritanceConfigError = err; + throw new InvalidOperationException("Failed to set inheritable permissions: " + err); + } + + config.InheritanceConfigured = true; + config.InheritablePermissionsAlreadyExist = alreadyExists; + config.InheritanceConfigError = null; + + logger.LogInformation(" - Inheritable permissions completed: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", + config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); + } + + private static async Task EnsureAdminConsentForAgenticAppAsync( + GraphApiService graphService, + Agent365Config config, + string[] scopes, + ILogger logger, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(config.AgenticAppId)) + throw new InvalidOperationException("AgenticAppId is required."); + + // clientId must be the *service principal objectId* of the agentic app + var agenticAppSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync( + config.TenantId, + config.AgenticAppId ?? string.Empty + ) ?? throw new InvalidOperationException($"Service Principal not found for agentic appId {config.AgenticAppId}"); + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var mcpPlatformResourceSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId) + ?? throw new InvalidOperationException("MCP Platform Service Principal not found for appId " + resourceAppId); + + var ok = await graphService.ReplaceOauth2PermissionGrantAsync( + config.TenantId, + agenticAppSpObjectId, + mcpPlatformResourceSpObjectId, + scopes, + ct + ); + + if (!ok) throw new InvalidOperationException("Failed to ensure admin consent for agent identity."); + + logger.LogInformation(" - Admin consented: agent identity {AgenticAppId} to resourceAppId {ResourceAppId} scopes [{Scopes}]", + config.AgenticAppId, resourceAppId, string.Join(' ', scopes)); + } + + /// + /// Handles common deployment exceptions and provides user guidance + /// + private static void HandleDeploymentException(Exception ex, ILogger logger) + { + switch (ex) + { + case FileNotFoundException fileNotFound: + logger.LogError("Configuration file not found: {Message}", fileNotFound.Message); + logger.LogInformation(""); + logger.LogInformation("To get started:"); + logger.LogInformation(" 1. Copy a365.config.example.json to a365.config.json"); + logger.LogInformation(" 2. Edit a365.config.json with your Azure tenant and subscription details"); + logger.LogInformation(" 3. Run 'a365 deploy' to perform a deployment"); + logger.LogInformation(""); + break; + default: + logger.LogError(ex, "Deployment failed: {Message}", ex.Message); + break; + } + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 16b73bb8..0e6f541e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -309,8 +309,8 @@ private static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, // Configuration files logger.LogInformation("Configuration Files:"); - logger.LogInformation(" • Setup Config: {SetupConfig}", setupConfigFile.FullName); - logger.LogInformation(" • Generated Config: {GeneratedConfig}", generatedConfigPath); + logger.LogInformation(" - Setup Config: {SetupConfig}", setupConfigFile.FullName); + logger.LogInformation(" - Generated Config: {GeneratedConfig}", generatedConfigPath); logger.LogInformation(""); logger.LogInformation("Next Steps:"); @@ -347,7 +347,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } // Register Bot Service provider (hidden as messaging endpoint provider) - logger.LogInformation(" • Ensuring messaging endpoint provider is registered"); + logger.LogInformation(" - Ensuring messaging endpoint provider is registered"); var providerRegistered = await botConfigurator.EnsureBotServiceProviderAsync( setupConfig.SubscriptionId, setupConfig.ResourceGroup); @@ -362,10 +362,10 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( var endpointName = $"{setupConfig.WebAppName}-endpoint"; var messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; - logger.LogInformation(" • Registering blueprint messaging endpoint"); - logger.LogInformation(" - Endpoint Name: {EndpointName}", endpointName); - logger.LogInformation(" - Messaging Endpoint: {Endpoint}", messagingEndpoint); - logger.LogInformation(" - Using Agent Blueprint ID: {AgentBlueprintId}", setupConfig.AgentBlueprintId); + logger.LogInformation(" - Registering blueprint messaging endpoint"); + logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName); + logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint); + logger.LogInformation(" * Using Agent Blueprint ID: {AgentBlueprintId}", setupConfig.AgentBlueprintId); var endpointRegistered = await botConfigurator.CreateOrUpdateBotWithAgentBlueprintAsync( appServiceName: setupConfig.WebAppName, @@ -385,7 +385,7 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } // Configure channels (Teams, Email) as messaging integrations - logger.LogInformation(" • Configuring messaging integrations"); + logger.LogInformation(" - Configuring messaging integrations"); var integrationsConfigured = await botConfigurator.ConfigureChannelsAsync( endpointName, setupConfig.ResourceGroup, @@ -435,7 +435,7 @@ private static async Task EnsureMcpOauth2PermissionGrantsAsync( var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, resourceAppId, ct) ?? throw new InvalidOperationException("Agent 365 Tools Service Principal not found for appId " + resourceAppId); - logger.LogInformation(" • OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", + logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]", blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes)); var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync( @@ -456,7 +456,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(cfg.Environment); - logger.LogInformation(" • Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", + logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", cfg.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 38f9b812..a5ba136f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -459,9 +459,9 @@ string GetConfig(string name) => try { _logger.LogInformation("Creating Agent Identity using Graph API..."); - _logger.LogInformation(" • Display Name: {Name}", displayName); - _logger.LogInformation(" • Agent Blueprint ID: {Id}", agentBlueprintId); - _logger.LogInformation(" • Authenticating using blueprint client credentials..."); + _logger.LogInformation(" - Display Name: {Name}", displayName); + _logger.LogInformation(" - Agent Blueprint ID: {Id}", agentBlueprintId); + _logger.LogInformation(" - Authenticating using blueprint client credentials..."); // Validate that we have client secret if (string.IsNullOrWhiteSpace(agentBlueprintClientSecret)) @@ -504,7 +504,7 @@ string GetConfig(string name) => var meJson = await meResponse.Content.ReadAsStringAsync(ct); var me = JsonNode.Parse(meJson)!.AsObject(); currentUserId = me["id"]!.GetValue(); - _logger.LogInformation(" • Current user ID (sponsor): {UserId}", currentUserId); + _logger.LogInformation(" - Current user ID (sponsor): {UserId}", currentUserId); } } } @@ -530,7 +530,7 @@ string GetConfig(string name) => }; } - _logger.LogInformation(" • Sending request to create agent identity..."); + _logger.LogInformation(" - Sending request to create agent identity..."); var identityResponse = await httpClient.PostAsync( createIdentityUrl, new StringContent(identityBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), @@ -549,8 +549,8 @@ string GetConfig(string name) => _logger.LogError("This usually means the blueprint application doesn't have the required permissions"); _logger.LogError(""); _logger.LogError("REQUIRED PERMISSIONS:"); - _logger.LogError(" • Application.ReadWrite.All (Application permission)"); - _logger.LogError(" • AgentIdentity.Create.OwnedBy (Application permission)"); + _logger.LogError(" - Application.ReadWrite.All (Application permission)"); + _logger.LogError(" - AgentIdentity.Create.OwnedBy (Application permission)"); _logger.LogError(""); return (false, null); } @@ -587,7 +587,7 @@ string GetConfig(string name) => var identityId = identity["id"]!.GetValue(); _logger.LogInformation("Agent Identity created successfully!"); - _logger.LogInformation(" • Agent Identity ID: {Id}", identityId); + _logger.LogInformation(" - Agent Identity ID: {Id}", identityId); return (true, identityId); } @@ -676,9 +676,9 @@ string GetConfig(string name) => try { _logger.LogInformation("Creating Agent User using Graph API..."); - _logger.LogInformation(" • Display Name: {Name}", displayName); - _logger.LogInformation(" • User Principal Name: {UPN}", userPrincipalName); - _logger.LogInformation(" • Agent Identity ID: {Id}", agenticAppId); + _logger.LogInformation(" - Display Name: {Name}", displayName); + _logger.LogInformation(" - User Principal Name: {UPN}", userPrincipalName); + _logger.LogInformation(" - Agent Identity ID: {Id}", agenticAppId); // Get Graph access token var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct); @@ -750,8 +750,8 @@ string GetConfig(string name) => var userId = user["id"]!.GetValue(); _logger.LogInformation("Agent User created successfully!"); - _logger.LogInformation(" • Agent User ID: {Id}", userId); - _logger.LogInformation(" • User Principal Name: {UPN}", userPrincipalName); + _logger.LogInformation(" - Agent User ID: {Id}", userId); + _logger.LogInformation(" - User Principal Name: {UPN}", userPrincipalName); // Assign manager if provided if (!string.IsNullOrWhiteSpace(managerEmail)) @@ -779,7 +779,7 @@ private async Task AssignManagerAsync( { try { - _logger.LogInformation(" • Assigning manager"); + _logger.LogInformation(" - Assigning manager"); using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); @@ -822,7 +822,7 @@ private async Task AssignManagerAsync( if (assignResponse.IsSuccessStatusCode) { - _logger.LogInformation(" • Manager assigned"); + _logger.LogInformation(" - Manager assigned"); } else { @@ -952,7 +952,7 @@ private async Task AssignLicensesAsync( // Set usage location if provided if (!string.IsNullOrWhiteSpace(usageLocation)) { - _logger.LogInformation(" • Setting usage location: {Location}", usageLocation); + _logger.LogInformation(" - Setting usage location: {Location}", usageLocation); var updateUserUrl = $"https://graph.microsoft.com/v1.0/users/{userId}"; var updateBody = new JsonObject { @@ -972,7 +972,7 @@ private async Task AssignLicensesAsync( } // Assign licenses - _logger.LogInformation(" • Assigning Microsoft 365 licenses"); + _logger.LogInformation(" - Assigning Microsoft 365 licenses"); var assignLicenseUrl = $"https://graph.microsoft.com/v1.0/users/{userId}/assignLicense"; var licenseBody = new JsonObject { @@ -992,8 +992,8 @@ private async Task AssignLicensesAsync( if (licenseResponse.IsSuccessStatusCode) { _logger.LogInformation("Licenses assigned successfully"); - _logger.LogInformation(" • Microsoft Teams Enterprise"); - _logger.LogInformation(" • Microsoft 365 E5 (no Teams)"); + _logger.LogInformation(" - Microsoft Teams Enterprise"); + _logger.LogInformation(" - Microsoft 365 E5 (no Teams)"); } else { @@ -1240,8 +1240,8 @@ private async Task VerifyServicePrincipalExistsAsync( var spDisplayName = sp["displayName"]?.GetValue(); _logger.LogInformation(" Service Principal found:"); - _logger.LogInformation(" • Object ID: {ObjectId}", spObjectId); - _logger.LogInformation(" • Display Name: {DisplayName}", spDisplayName); + _logger.LogInformation(" - Object ID: {ObjectId}", spObjectId); + _logger.LogInformation(" - Display Name: {DisplayName}", spDisplayName); return true; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index d7bc6778..3c3a5629 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -412,10 +412,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, var blueprintObjectId = blueprintResult.objectId; _logger.LogInformation("Agent Blueprint Details:"); - _logger.LogInformation(" • Display Name: {Name}", agentBlueprintDisplayName); - _logger.LogInformation(" • App ID: {Id}", blueprintAppId); - _logger.LogInformation(" • Object ID: {Id}", blueprintObjectId); - _logger.LogInformation(" • Identifier URI: api://{Id}", blueprintAppId); + _logger.LogInformation(" - Display Name: {Name}", agentBlueprintDisplayName); + _logger.LogInformation(" - App ID: {Id}", blueprintAppId); + _logger.LogInformation(" - Object ID: {Id}", blueprintObjectId); + _logger.LogInformation(" - Identifier URI: api://{Id}", blueprintAppId); // Convert to camelCase and save var camelCaseConfig = new JsonObject @@ -493,9 +493,9 @@ public async Task RunAsync(string configPath, string generatedConfigPath, _logger.LogInformation("=========================================="); _logger.LogInformation(""); _logger.LogInformation("Agent Blueprint Details:"); - _logger.LogInformation(" • Display Name: {Name}", cfg["agentBlueprintDisplayName"]?.GetValue()); - _logger.LogInformation(" • Object ID: {Id}", generatedConfig["agentBlueprintObjectId"]?.GetValue()); - _logger.LogInformation(" • Identifier URI: api://{Id}", generatedConfig["agentBlueprintId"]?.GetValue()); + _logger.LogInformation(" - Display Name: {Name}", cfg["agentBlueprintDisplayName"]?.GetValue()); + _logger.LogInformation(" - Object ID: {Id}", generatedConfig["agentBlueprintObjectId"]?.GetValue()); + _logger.LogInformation(" - Identifier URI: api://{Id}", generatedConfig["agentBlueprintId"]?.GetValue()); // Print summary to console as the very last output AppDomain.CurrentDomain.ProcessExit += (_, __) => @@ -594,10 +594,10 @@ public async Task RunAsync(string configPath, string generatedConfigPath, var createAppUrl = "https://graph.microsoft.com/beta/applications"; _logger.LogInformation("Creating Agent Blueprint application..."); - _logger.LogInformation(" • Display Name: {DisplayName}", displayName); + _logger.LogInformation(" - Display Name: {DisplayName}", displayName); if (!string.IsNullOrEmpty(sponsorUserId)) { - _logger.LogInformation(" • Sponsor: User ID {UserId}", sponsorUserId); + _logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId); } var appResponse = await httpClient.PostAsync( @@ -643,8 +643,8 @@ public async Task RunAsync(string configPath, string generatedConfigPath, var objectId = app["id"]!.GetValue(); _logger.LogInformation("Application created successfully"); - _logger.LogInformation(" • App ID: {AppId}", appId); - _logger.LogInformation(" • Object ID: {ObjectId}", objectId); + _logger.LogInformation(" - App ID: {AppId}", appId); + _logger.LogInformation(" - Object ID: {ObjectId}", objectId); // Wait for application propagation const int maxRetries = 30; @@ -783,7 +783,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, applicationScopes.Add("User.Read"); } - _logger.LogInformation(" • Application scopes: {Scopes}", string.Join(", ", applicationScopes)); + _logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes)); // Generate consent URLs for Graph and Connectivity var applicationScopesJoined = string.Join(' ', applicationScopes); @@ -887,10 +887,10 @@ private async Task CreateFederatedIdentityCredentialAsync( return false; } - _logger.LogInformation(" • Credential Name: {Name}", credentialName); - _logger.LogInformation(" • Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); - _logger.LogInformation(" • Subject (MSI Principal ID): {MsiId}", msiPrincipalId); - + _logger.LogInformation(" - Credential Name: {Name}", credentialName); + _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); + _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); + return true; } catch (Exception ex) @@ -1107,7 +1107,7 @@ await File.WriteAllTextAsync( ct); _logger.LogInformation("Client secret created successfully!"); - _logger.LogInformation(" • Secret stored in generated config (encrypted: {IsProtected})", RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + _logger.LogInformation(" - Secret stored in generated config (encrypted: {IsProtected})", RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); _logger.LogWarning("IMPORTANT: The client secret has been stored in {Path}", generatedConfigPath); _logger.LogWarning("Keep this file secure and do not commit it to source control!"); @@ -1322,9 +1322,9 @@ private async Task ConfigureInheritablePermissionsAsync( var graphUrl = $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; _logger.LogInformation("Configuring Graph inheritable permissions"); - _logger.LogInformation(" • Request URL: {Url}", graphUrl); - _logger.LogInformation(" • Blueprint Object ID: {ObjectId}", blueprintObjectId); - + _logger.LogInformation(" - Request URL: {Url}", graphUrl); + _logger.LogInformation(" - Blueprint Object ID: {ObjectId}", blueprintObjectId); + // Convert scope list to JsonArray var scopesArray = new JsonArray(); foreach (var scope in inheritableScopes) @@ -1342,7 +1342,7 @@ private async Task ConfigureInheritablePermissionsAsync( } }; - _logger.LogInformation(" • Request body: {Body}", graphBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + _logger.LogInformation(" - Request body: {Body}", graphBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); var graphResponse = await httpClient.PostAsync( graphUrl, @@ -1360,7 +1360,7 @@ private async Task ConfigureInheritablePermissionsAsync( if (isAlreadyConfigured) { - _logger.LogInformation(" • Graph inheritable permissions already configured (idempotent)"); + _logger.LogInformation(" - Graph inheritable permissions already configured (idempotent)"); } else { @@ -1373,8 +1373,8 @@ private async Task ConfigureInheritablePermissionsAsync( else { _logger.LogInformation("Successfully configured Graph inheritable permissions"); - _logger.LogInformation(" • Resource: Microsoft Graph"); - _logger.LogInformation(" • Scopes: {Scopes}", string.Join(", ", inheritableScopes)); + _logger.LogInformation(" - Resource: Microsoft Graph"); + _logger.LogInformation(" - Scopes: {Scopes}", string.Join(", ", inheritableScopes)); generatedConfig["graphInheritanceConfigured"] = true; } @@ -1385,7 +1385,7 @@ private async Task ConfigureInheritablePermissionsAsync( _logger.LogInformation(""); _logger.LogInformation("Configuring Connectivity inheritable permissions"); - _logger.LogInformation(" • Request URL: {Url}", connectivityUrl); + _logger.LogInformation(" - Request URL: {Url}", connectivityUrl); var connectivityBody = new JsonObject { @@ -1397,7 +1397,7 @@ private async Task ConfigureInheritablePermissionsAsync( } }; - _logger.LogInformation(" • Request body: {Body}", connectivityBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + _logger.LogInformation(" - Request body: {Body}", connectivityBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); var connectivityResponse = await httpClient.PostAsync( connectivityUrl, @@ -1415,7 +1415,7 @@ private async Task ConfigureInheritablePermissionsAsync( if (isAlreadyConfigured) { - _logger.LogInformation(" • Connectivity inheritable permissions already configured (idempotent)"); + _logger.LogInformation(" - Connectivity inheritable permissions already configured (idempotent)"); } else { @@ -1427,8 +1427,8 @@ private async Task ConfigureInheritablePermissionsAsync( else { _logger.LogInformation("Successfully configured Connectivity inheritable permissions"); - _logger.LogInformation(" • Resource: Connectivity Service"); - _logger.LogInformation(" • Scope: Connectivity.Connections.Read"); + _logger.LogInformation(" - Resource: Connectivity Service"); + _logger.LogInformation(" - Scope: Connectivity.Connections.Read"); generatedConfig["connectivityInheritanceConfigured"] = true; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index aad4b449..51a0cac5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -566,6 +566,7 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _httpClient.DefaultRequestHeaders.Remove("ConsistencyLevel"); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("ConsistencyLevel", "eventual"); + return true; } @@ -578,6 +579,7 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo var resp = await _httpClient.GetAsync(url, ct); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(ct); + return JsonDocument.Parse(json); } @@ -591,6 +593,7 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo var resp = await _httpClient.PostAsync(url, content, ct); var body = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) return null; + return string.IsNullOrWhiteSpace(body) ? null : JsonDocument.Parse(body); } @@ -603,10 +606,39 @@ public async Task GraphPatchAsync(string tenantId, string relativePath, ob var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) { Content = content }; var resp = await _httpClient.SendAsync(request, ct); + // Many PATCH calls return 204 NoContent on success return resp.IsSuccessStatusCode; } + public async Task GraphDeleteAsync( + string tenantId, + string relativePath, + CancellationToken ct = default, + bool treatNotFoundAsSuccess = true) + { + if (!await EnsureGraphHeadersAsync(tenantId, ct)) return false; + + var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? relativePath + : $"https://graph.microsoft.com{relativePath}"; + + using var req = new HttpRequestMessage(HttpMethod.Delete, url); + using var resp = await _httpClient.SendAsync(req, ct); + + // 404 can be considered success for idempotent deletes + if (treatNotFoundAsSuccess && (int)resp.StatusCode == 404) return true; + + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Graph DELETE {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + return false; + } + + return true; + } + public async Task LookupServicePrincipalByAppIdAsync(string tenantId, string appId, CancellationToken ct = default) { var doc = await GraphGetAsync(tenantId, $"/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id", ct); @@ -706,6 +738,8 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( ct); // Success => created or updated + _logger.LogInformation("Inheritable permissions set: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", + blueprintAppId, resourceAppId, scopesString); return (ok: true, alreadyExists: false, error: null); } catch (Exception ex) @@ -715,9 +749,82 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( msg.Contains("conflict", StringComparison.OrdinalIgnoreCase) || msg.Contains("409")) { + _logger.LogWarning("Inheritable permissions already exist: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", + blueprintAppId, resourceAppId, scopesString); return (ok: true, alreadyExists: true, error: null); } + _logger.LogError("Failed to set inheritable permissions: {Error}", msg); return (ok: false, alreadyExists: false, error: msg); } } + + public async Task ReplaceOauth2PermissionGrantAsync( + string tenantId, + string clientSpObjectId, + string resourceSpObjectId, + IEnumerable scopes, + CancellationToken ct = default) + { + // Normalize scopes -> single space-delimited string (Graph’s required shape) + var desiredSet = new HashSet( + (scopes ?? Enumerable.Empty()) + .SelectMany(s => (s ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)), + StringComparer.OrdinalIgnoreCase); + + var desiredScopeString = string.Join(' ', desiredSet.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); + + // 1) Find existing grant(s) for client resource + var listDoc = await GraphGetAsync( + tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'", + ct); + + var existing = listDoc?.RootElement.TryGetProperty("value", out var arr) == true ? arr : default; + + // 2) Delete all existing grants for this pair (rare but possible to have >1) + if (existing.ValueKind == JsonValueKind.Array && existing.GetArrayLength() > 0) + { + foreach (var item in existing.EnumerateArray()) + { + var id = item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; + if (!string.IsNullOrWhiteSpace(id)) + { + _logger.LogDebug("Deleting existing oauth2PermissionGrant {Id} for client {ClientId} and resource {ResourceId}", + id, clientSpObjectId, resourceSpObjectId); + + var ok = await GraphDeleteAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{id}", ct); + if (!ok) + { + _logger.LogError("Failed to delete existing oauth2PermissionGrant {Id} for client {ClientId} and resource {ResourceId}. " + + "This may indicate insufficient permissions or the grant is protected. " + + "Required permissions: DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All", + id, clientSpObjectId, resourceSpObjectId); + _logger.LogError("Troubleshooting steps:"); + _logger.LogError(" 1. Verify your account has sufficient Azure AD permissions"); + _logger.LogError(" 2. Check if you are a Global Administrator or Application Administrator"); + _logger.LogError(" 3. Ensure the oauth2PermissionGrant exists and is not system-protected"); + _logger.LogError(" 4. Try running: az login --tenant {TenantId} with elevated privileges", tenantId); + return false; + } + + _logger.LogDebug("Successfully deleted oauth2PermissionGrant {Id}", id); + } + } + } + + // If no scopes desired, we’re done (revoke only) + if (desiredSet.Count == 0) return true; + + // 3) Create the new grant with exactly the desired scopes + var payload = new + { + clientId = clientSpObjectId, + consentType = "AllPrincipals", + resourceId = resourceSpObjectId, + scope = desiredScopeString + }; + + var created = await GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); + return created != null; + } } From e5754968c5ab0dd387d0cccc0acd26e6123b1e96 Mon Sep 17 00:00:00 2001 From: Mengyi Xu Date: Thu, 13 Nov 2025 22:37:37 -0800 Subject: [PATCH 2/2] Resolving comments --- docs/commands/deploy.md | 16 ++++++++-------- .../Commands/CreateInstanceCommand.cs | 10 +++++----- .../Commands/DeployCommand.cs | 12 ++++++------ .../Services/GraphApiService.cs | 7 ++++--- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 27c34d43..a87672b9 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -17,8 +17,8 @@ a365 deploy app a365 deploy mcp # Common flags -a365 deploy --restart # reuse existing publish/ (skip build) -a365 deploy --inspect # pause to review publish/ and zip +a365 deploy app --restart # reuse existing publish/ (skip build) +a365 deploy app --inspect # pause to review publish/ and zip a365 deploy --dry-run # print actions, no changes a365 deploy --verbose # detailed logs ``` @@ -30,7 +30,7 @@ a365 deploy --verbose # detailed logs ### Default (`a365 deploy`) Runs **two phases sequentially**: -**Part 1 — App Binaries** +**Step 1 — App Binaries** 1. Load `a365.config.json` (+ dynamic state from generated config). 2. **Azure preflight** - Validates Azure CLI auth + subscription context (`ValidateAllAsync`). @@ -38,11 +38,11 @@ Runs **two phases sequentially**: 3. Build/package via `DeploymentService.DeployAsync(...)` (supports `--inspect` and `--restart`). 4. Log success/failure. -**Part 2 — MCP Permissions** +**Step 2 — MCP Permissions** 1. Re-load config (same path). 2. Read required scopes from `deploymentProjectPath/toolingManifest.json`. 3. Apply **in order**: - - **OAuth2 grant**: `CreateOrUpdateOauth2PermissionGrantAsync` + - **OAuth2 grant**: `ReplaceOauth2PermissionGrantAsync` - **Inheritable permissions**: `SetInheritablePermissionsAsync` - **Admin consent (agent identity)**: `ReplaceOauth2PermissionGrantAsync` 4. Log success/failure. @@ -108,7 +108,7 @@ Runs **two phases sequentially**: When running `a365 deploy` or `a365 deploy mcp`: 1. **OAuth2 permission grant** - `CreateOrUpdateOauth2PermissionGrantAsync(tenant, blueprintSp, mcpPlatformSp, scopes)` + `ReplaceOauth2PermissionGrantAsync(tenant, blueprintSp, mcpPlatformSp, scopes)` 2. **Inheritable permissions** `SetInheritablePermissionsAsync(tenant, agentBlueprintAppId, mcpResourceAppId, scopes)` @@ -128,7 +128,7 @@ a365 deploy --verbose ### Quick iteration (reuse last build) ```bash -a365 deploy --restart +a365 deploy app --restart ``` ### MCP only (permissions/scopes refresh) @@ -138,7 +138,7 @@ a365 deploy mcp --verbose ### Validate everything without changing anything ```bash -a365 deploy --dry-run --inspect +a365 deploy app --dry-run --inspect ``` --- diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 3ce478ff..b9065bdd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -113,14 +113,14 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" Agent User ID: {AgenticUserId}", instanceConfig.AgenticUserId ?? "(not set)"); logger.LogInformation(" Agent User Principal Name: {AgentUserPrincipalName}", instanceConfig.AgentUserPrincipalName ?? "(not set)"); - // Step 4: Admin consent for MCP scopes (oauth2PermissionGrants) - logger.LogInformation("Step 4: Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); + // Admin consent for MCP scopes (oauth2PermissionGrants) + logger.LogInformation("Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); var manifestPath = Path.Combine(instanceConfig.DeploymentProjectPath ?? string.Empty, "ToolingManifest.json"); var scopesForAgent = await ManifestHelper.GetRequiredScopesAsync(manifestPath); // clientId must be the *service principal objectId* of the agentic app - var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( + var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( instanceConfig.TenantId, instanceConfig.AgenticAppId ?? string.Empty ) ?? throw new InvalidOperationException($"Service Principal not found for agentic app Id {instanceConfig.AgenticAppId}"); @@ -131,7 +131,7 @@ public static Command CreateCommand(ILogger logger, IConf var response = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( instanceConfig.TenantId, - agenticAppSpObjectId , + agenticAppSpObjectId, Agent365ToolsResourceSpObjectId, scopesForAgent ); @@ -154,7 +154,7 @@ public static Command CreateCommand(ILogger logger, IConf // Grant oauth2PermissionGrants: *agent identity SP* -> Messaging Bot API SP var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( instanceConfig.TenantId, - agenticAppSpObjectId , + agenticAppSpObjectId, botApiResourceSpObjectId, new[] { "Authorization.ReadWrite", "user_impersonation" }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index f5ddd45e..96fbce72 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -329,8 +329,8 @@ private static async Task DeployMcpToolPermissionsAsync( LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), executor); - // Step 1: Apply MCP OAuth2 permission grants - logger.LogInformation("Step 1: Applying MCP OAuth2 permission grants and inheritable permissions..."); + // 1. Apply MCP OAuth2 permission grants + logger.LogInformation("1. Applying MCP OAuth2 permission grants..."); await EnsureMcpOauth2PermissionGrantsAsync( graphService, config, @@ -338,8 +338,8 @@ await EnsureMcpOauth2PermissionGrantsAsync( logger ); - // Step 2: Apply inheritable permissions on the agent identity blueprint - logger.LogInformation("Step 2: Applying MCP inheritable permissions..."); + // 2. Apply inheritable permissions on the agent identity blueprint + logger.LogInformation("2. Applying MCP inheritable permissions..."); await EnsureMcpInheritablePermissionsAsync( graphService, config, @@ -347,8 +347,8 @@ await EnsureMcpInheritablePermissionsAsync( logger ); - // Step 3: Consent to required scopes for the agent identity - logger.LogInformation("Step 3: Consenting to required MCP scopes for the agent identity..."); + // 3. Consent to required scopes for the agent identity + logger.LogInformation("3. Consenting to required MCP scopes for the agent identity..."); await EnsureAdminConsentForAgenticAppAsync( graphService, config, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 51a0cac5..5d32deba 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -632,11 +632,11 @@ public async Task GraphDeleteAsync( if (!resp.IsSuccessStatusCode) { var body = await resp.Content.ReadAsStringAsync(ct); - _logger.LogWarning("Graph DELETE {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + _logger.LogError("Graph DELETE {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); return false; } - return true; + return true; } public async Task LookupServicePrincipalByAppIdAsync(string tenantId, string appId, CancellationToken ct = default) @@ -804,7 +804,8 @@ public async Task ReplaceOauth2PermissionGrantAsync( _logger.LogError(" 2. Check if you are a Global Administrator or Application Administrator"); _logger.LogError(" 3. Ensure the oauth2PermissionGrant exists and is not system-protected"); _logger.LogError(" 4. Try running: az login --tenant {TenantId} with elevated privileges", tenantId); - return false; + + throw new InvalidOperationException($"Failed to delete existing oauth2PermissionGrant {id}"); } _logger.LogDebug("Successfully deleted oauth2PermissionGrant {Id}", id);