From 33b4b5457d26aeca57c8c5e5b411c3e137aab64e Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 17 Apr 2026 13:41:55 +0530 Subject: [PATCH 1/5] fix: add V2 MCP audience support and prevent removal by setup blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - McpConstants: add V2ScopeValue, IsV1Scope(), ResolveAudienceOrAtgFallback() to centralize audience normalization (null/api://-prefix/"default" → ATG AppId) - ManifestHelper: add GetScopesByAudienceAsync() returning scopes grouped by resolved audience (resourceAppId) — V1 entries map to ATG, V2 GUID entries get their own key - PermissionsSubcommand.ConfigureMcpPermissionsAsync: replace single ATG ResourcePermissionSpec with per-audience specs from GetScopesByAudienceAsync, fixing V2 inheritable-permission configuration in 'setup permissions mcp' - PermissionsSubcommand.RemoveStaleCustomPermissionsAsync: extend protectedIds with all audience IDs from the manifest so re-running 'setup blueprint' no longer removes V2 inheritable permissions set by 'setup permissions mcp' - AddPermissionsSubcommand: when reading from manifest, use GetScopesByAudienceAsync and call AddRequiredResourceAccessAsync per audience (V1 + V2); explicit --scopes path unchanged (still targets ATG) --- .../AddPermissionsSubcommand.cs | 100 +++++++++++------- .../SetupSubcommands/PermissionsSubcommand.cs | 26 ++++- .../Constants/McpConstants.cs | 14 +++ .../Helpers/ManifestHelper.cs | 92 ++++++++++++++++ 4 files changed, 190 insertions(+), 42 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 0ce9e9cd..810b2714 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -112,19 +112,20 @@ public static Command CreateCommand( var manifestPath = manifest?.FullName ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, McpConstants.ToolingManifestFileName); - // Determine which scopes to add - string[] requestedScopes; - + // Determine which scopes to add. + // Explicit --scopes: single ATG call (no audience info available). + // Manifest: per-audience calls via GetScopesByAudienceAsync (V1 + V2 support). + string[]? requestedScopes = null; + Dictionary? scopesByAudience = null; + if (scopes != null && scopes.Length > 0) { - // User provided explicit scopes requestedScopes = scopes; logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); } else { - // Read scopes from ToolingManifest.json if (!File.Exists(manifestPath)) { logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath); @@ -139,10 +140,9 @@ public static Command CreateCommand( 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); + scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath); - if (requestedScopes.Length == 0) + if (scopesByAudience.Count == 0) { logger.LogError("No scopes found in ToolingManifest.json"); logger.LogInformation("You can specify scopes explicitly with --scopes option."); @@ -150,14 +150,14 @@ public static Command CreateCommand( return; } - logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", - requestedScopes.Length, string.Join(", ", requestedScopes)); + var totalScopes = scopesByAudience.Values.SelectMany(s => s).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + logger.LogInformation("Found {AudienceCount} audience(s) with {ScopeCount} unique scope(s) from manifest", + scopesByAudience.Count, totalScopes); } var environment = setupConfig?.Environment ?? "prod"; - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); - - logger.LogInformation("Target resource: Agent 365 Tools ({ResourceAppId})", resourceAppId); + var atgResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); + logger.LogInformation(""); // Dry run mode @@ -166,8 +166,15 @@ public static Command CreateCommand( logger.LogInformation("DRY RUN: Add MCP Server Permissions"); logger.LogInformation("Would add the following permissions to application {AppId}:", targetAppId); logger.LogInformation(""); - logger.LogInformation("Resource: {ResourceAppId}", resourceAppId); - logger.LogInformation(" Scopes: {Scopes}", string.Join(", ", requestedScopes)); + if (scopesByAudience != null) + { + foreach (var kvp in scopesByAudience) + logger.LogInformation(" {ResourceAppId} — {Scopes}", kvp.Key, string.Join(", ", kvp.Value)); + } + else + { + logger.LogInformation(" {ResourceAppId} — {Scopes}", atgResourceAppId, string.Join(", ", requestedScopes!)); + } logger.LogInformation(""); logger.LogInformation("No changes made (dry run mode)"); return; @@ -177,35 +184,54 @@ public static Command CreateCommand( logger.LogInformation("Adding permissions to application..."); logger.LogInformation(""); - // Determine tenant ID (from config or detect from Azure CLI) string tenantId = await TenantDetectionHelper.DetectTenantIdAsync(setupConfig, logger) ?? string.Empty; - logger.LogInformation("Processing resource: {ResourceAppId}", resourceAppId); - - bool success; - try + bool success = true; + if (scopesByAudience != null) { - success = await blueprintService.AddRequiredResourceAccessAsync( - tenantId, - targetAppId, - resourceAppId, - requestedScopes, - isDelegated: true); - - if (success) + // Per-audience calls — one entry per resource app ID (V1 + V2) + foreach (var kvp in scopesByAudience) { - logger.LogInformation(" [SUCCESS] Successfully added permissions for {ResourceAppId}", resourceAppId); - } - else - { - logger.LogError(" [FAILED] Failed to add permissions for {ResourceAppId}", resourceAppId); + logger.LogInformation("Processing resource: {ResourceAppId}", kvp.Key); + try + { + var ok = await blueprintService.AddRequiredResourceAccessAsync( + tenantId, targetAppId, kvp.Key, kvp.Value, isDelegated: true); + if (ok) + logger.LogInformation(" [SUCCESS] Added permissions for {ResourceAppId}", kvp.Key); + else + { + logger.LogError(" [FAILED] Failed to add permissions for {ResourceAppId}", kvp.Key); + success = false; + } + } + catch (Exception ex) + { + logger.LogError(" [ERROR] {ResourceAppId}: {Message}", kvp.Key, ex.Message); + logger.LogDebug(" {StackTrace}", ex.StackTrace); + success = false; + } } } - catch (Exception ex) + else { - logger.LogError(" [ERROR] Exception adding permissions for {ResourceAppId}: {Message}", resourceAppId, ex.Message); - logger.LogDebug(" {StackTrace}", ex.StackTrace); - success = false; + // Explicit --scopes: single ATG call + logger.LogInformation("Processing resource: {ResourceAppId}", atgResourceAppId); + try + { + success = await blueprintService.AddRequiredResourceAccessAsync( + tenantId, targetAppId, atgResourceAppId, requestedScopes!, isDelegated: true); + if (success) + logger.LogInformation(" [SUCCESS] Successfully added permissions for {ResourceAppId}", atgResourceAppId); + else + logger.LogError(" [FAILED] Failed to add permissions for {ResourceAppId}", atgResourceAppId); + } + catch (Exception ex) + { + logger.LogError(" [ERROR] Exception adding permissions for {ResourceAppId}: {Message}", atgResourceAppId, ex.Message); + logger.LogDebug(" {StackTrace}", ex.StackTrace); + success = false; + } } logger.LogInformation(""); 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 1196eb18..128086b4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -363,14 +363,22 @@ public static async Task ConfigureMcpPermissionsAsync( try { var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var toolingScopes = await ReadMcpScopesAsync(manifestPath, logger); - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); + var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath); - var specs = new List + if (scopesByAudience.Count == 0) { - new ResourcePermissionSpec(resourceAppId, "Agent 365 Tools", toolingScopes, SetInheritable: true), - }; + logger.LogInformation("No MCP permissions to configure — manifest is empty or not found."); + return true; + } + + var specs = scopesByAudience + .Select(kvp => new ResourcePermissionSpec(kvp.Key, "Agent 365 Tools", kvp.Value, SetInheritable: true)) + .ToList(); + + logger.LogInformation("Configuring permissions for {Count} resource(s):", specs.Count); + foreach (var spec in specs) + logger.LogInformation(" {AppId} — {Scopes}", spec.ResourceAppId, string.Join(", ", spec.Scopes)); var (_, _, consentGranted, _) = await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( graphApiService, blueprintService, setupConfig, @@ -488,6 +496,14 @@ internal static async Task RemoveStaleCustomPermissionsAsync( AuthenticationConstants.MicrosoftGraphResourceAppId, }; + // Protect V2 MCP audience GUIDs — these are managed by 'setup permissions mcp', + // not by custom-permission reconciliation. Without this, re-running 'setup blueprint' + // would treat them as stale and remove them. + var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); + var mcpAudiences = await ManifestHelper.GetScopesByAudienceAsync(manifestPath); + foreach (var audienceId in mcpAudiences.Keys) + protectedIds.Add(audienceId); + // Must match RequiredPermissionGrantScopes exactly so the PowerShell token acquired // for inheritable permissions is reused (same cache key) rather than triggering // a second Connect-MgGraph prompt. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs index 29168b35..319dbae6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs @@ -12,6 +12,20 @@ public static class McpConstants // WorkIQ Tools App ID public const string WorkIQToolsProdAppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"; + public const string V2ScopeValue = "Tools.ListInvoke.All"; + + public static bool IsV1Scope(string? scope) => + !string.IsNullOrEmpty(scope) && + scope.StartsWith("McpServers.", StringComparison.OrdinalIgnoreCase) && + scope.EndsWith(".All", StringComparison.OrdinalIgnoreCase); + + public static string ResolveAudienceOrAtgFallback(string? audience) => + string.IsNullOrWhiteSpace(audience) || + audience.StartsWith("api://", StringComparison.OrdinalIgnoreCase) || + string.Equals(audience, "default", StringComparison.OrdinalIgnoreCase) + ? WorkIQToolsProdAppId + : audience; + /// /// Agent 365 Tools identifier URI (used for admin consent URL construction). /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs index fbdcf2dc..8896fd8c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs @@ -204,6 +204,98 @@ public static List ConvertToServerObjects(IEnumerable jsonE return servers; } + /// + /// Reads ToolingManifest.json and returns scopes grouped by their resolved audience (resourceAppId). + /// Supports V1 (shared ATG AppId), V2 (per-server AppId), and mixed manifests. + /// Fallback rules — the following audience values all resolve to : + /// - missing / null / whitespace + /// - any value starting with api:// (legacy V1 format) + /// - the literal string "default" + /// + /// Path to ToolingManifest.json + /// + /// When true, omits all entries whose resolved audience is the shared ATG AppId. + /// Pass true only when removing V1/legacy scopes (--remove-legacy-scopes). + /// + public static async Task> GetScopesByAudienceAsync( + string manifestPath, + bool excludeLegacyAtg = false) + { + var atgAppId = McpConstants.WorkIQToolsProdAppId; + var scopesByAudience = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + // McpServersMetadata.Read.All is always required and belongs to the ATG AppId + if (!excludeLegacyAtg) + { + scopesByAudience[atgAppId] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "McpServersMetadata.Read.All" + }; + } + + var parsed = await ReadManifestAsync(manifestPath); + if (parsed is null) + return ToDictionary(scopesByAudience); + + var (servers, _) = parsed.Value; + + foreach (var element in servers) + { + // Resolve scope — prefer manifest field, fall back to static mapping + string? scope = null; + if (element.TryGetProperty(McpConstants.ManifestProperties.Scope, out var scopeEl) && + scopeEl.ValueKind == JsonValueKind.String) + { + var raw = scopeEl.GetString(); + if (!string.Equals(raw, "null", StringComparison.OrdinalIgnoreCase)) + scope = raw; + } + if (string.IsNullOrWhiteSpace(scope)) + { + var serverName = ExtractServerName(element); + if (!string.IsNullOrWhiteSpace(serverName)) + { + var (mappedScope, _) = McpConstants.ServerScopeMappings.GetScopeAndAudience(serverName); + scope = mappedScope; + } + } + if (string.IsNullOrWhiteSpace(scope)) continue; + + // Resolve audience — null/whitespace/api://-prefixed/"default" → ATG AppId + string? audience = null; + if (element.TryGetProperty(McpConstants.ManifestProperties.Audience, out var audienceEl)) + audience = audienceEl.GetString(); + + audience = McpConstants.ResolveAudienceOrAtgFallback(audience); + + if (excludeLegacyAtg && + string.Equals(audience, atgAppId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!scopesByAudience.TryGetValue(audience, out var scopeSet)) + { + scopeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + scopesByAudience[audience] = scopeSet; + } + + foreach (var s in scope.Split(' ', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + scopeSet.Add(s); + } + } + + return ToDictionary(scopesByAudience); + + static Dictionary ToDictionary(Dictionary> src) => + src.ToDictionary( + k => k.Key, + v => v.Value.OrderBy(s => s).ToArray(), + StringComparer.OrdinalIgnoreCase); + } + /// /// Reads ToolingManifest.json and returns the unique list of scopes required by all MCP servers. /// Strategy: From 7f9db5d5597db20ed74e8d861cddb93a3f2e451f Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 17 Apr 2026 14:30:46 +0530 Subject: [PATCH 2/5] fix: remove unreachable empty-scopes guard in ConfigureMcpPermissionsAsync GetScopesByAudienceAsync always seeds McpServersMetadata.Read.All under the ATG key when excludeLegacyAtg=false, so Count==0 was never reachable. --- .../Commands/SetupSubcommands/PermissionsSubcommand.cs | 6 ------ 1 file changed, 6 deletions(-) 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 0d3b4351..5d0f5259 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -424,12 +424,6 @@ public static async Task ConfigureMcpPermissionsAsync( var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync( manifestPath, excludeLegacyAtg: removeLegacyAtgScopes); - if (scopesByAudience.Count == 0) - { - logger.LogInformation("No MCP permissions to configure — manifest is empty or not found."); - return true; - } - // Validate all scopes are known: V1 pattern, V2 value, or metadata scope var unknownScopes = scopesByAudience.Values .SelectMany(s => s) From f83350d6698ba08f482fccfdb2bf2705e8dc289e Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 17 Apr 2026 14:37:52 +0530 Subject: [PATCH 3/5] fix: thread environment-resolved ATG app ID through audience normalization ResolveAudienceOrAtgFallback and GetScopesByAudienceAsync hard-coded WorkIQToolsProdAppId, causing permissions to target the wrong resource app when A365_MCP_APP_ID_* env var overrides are active. - Add ResolveAudienceOrAtgFallback(audience, atgAppId) overload; existing no-arg overload delegates to it (backward compatible). - Add resolvedAtgAppId param to GetScopesByAudienceAsync and GetServerNamesByAudienceAsync (default null = prod constant). - All callers with setupConfig.Environment now pass ConfigConstants.GetAgent365ToolsResourceAppId(environment) so the resolved value flows through manifest parsing and audience grouping. --- .../AddPermissionsSubcommand.cs | 8 +++---- .../DevelopSubcommands/GetTokenSubcommand.cs | 5 ++-- .../SetupSubcommands/AdminSubcommand.cs | 3 ++- .../SetupSubcommands/AllSubcommand.cs | 3 +-- .../SetupSubcommands/PermissionsSubcommand.cs | 15 +++++++----- .../Constants/McpConstants.cs | 16 +++++++++---- .../Helpers/ManifestHelper.cs | 23 +++++++++++++------ 7 files changed, 47 insertions(+), 26 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 810b2714..e00922be 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -112,6 +112,9 @@ public static Command CreateCommand( var manifestPath = manifest?.FullName ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, McpConstants.ToolingManifestFileName); + var environment = setupConfig?.Environment ?? "prod"; + var atgResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); + // Determine which scopes to add. // Explicit --scopes: single ATG call (no audience info available). // Manifest: per-audience calls via GetScopesByAudienceAsync (V1 + V2 support). @@ -140,7 +143,7 @@ public static Command CreateCommand( logger.LogInformation("Reading MCP server configuration from: {Path}", manifestPath); - scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath); + scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: atgResourceAppId); if (scopesByAudience.Count == 0) { @@ -155,9 +158,6 @@ public static Command CreateCommand( scopesByAudience.Count, totalScopes); } - var environment = setupConfig?.Environment ?? "prod"; - var atgResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); - logger.LogInformation(""); // Dry run mode 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 0ce654ab..de2ecc34 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -380,8 +380,9 @@ private static async Task AcquireAndDisplayManifestTokensAsync( logger.LogInformation(""); - var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath); - var serverNamesByAudience = await ManifestHelper.GetServerNamesByAudienceAsync(manifestPath); + var tokenAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig?.Environment ?? "prod"); + var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId); + var serverNamesByAudience = await ManifestHelper.GetServerNamesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId); var tokenResults = new List(); foreach (var kvp in scopesByAudience) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs index fcee4b93..ad700ea2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs @@ -160,7 +160,8 @@ await RequirementsSubcommand.RunChecksOrExitAsync( var mcpManifestPath = Path.Combine( setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false); + var adminAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); + var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false, resolvedAtgAppId: adminAtgAppId); var specs = new List { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 2917e6cf..425a75f2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -343,11 +343,10 @@ await PermissionsSubcommand.RemoveStaleCustomPermissionsAsync( var mcpManifestPath = Path.Combine( setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false); - // Derive ATG-AppId entry for consent URL helpers (V1 backward compat). // V2-only manifests produce an empty array here, which is correct. var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); + var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false, resolvedAtgAppId: mcpResourceAppId); var mcpScopes = scopesByAudience.TryGetValue(mcpResourceAppId, out var atgScopes) ? atgScopes : Array.Empty(); 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 5d0f5259..86e9b7d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -150,12 +150,13 @@ private static Command CreateMcpSubcommand( logger.LogInformation("DRY RUN: Configure MCP Permissions"); logger.LogInformation(" Blueprint: {BlueprintId}", setupConfig.AgentBlueprintId); + var dryRunAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); if (removeLegacyScopes) { // Parse once, then split into removed (ATG) vs remaining (non-ATG) in memory. - var allScopes = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, excludeLegacyAtg: false); + var allScopes = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, excludeLegacyAtg: false, resolvedAtgAppId: dryRunAtgAppId); var remainingScopes = allScopes - .Where(kvp => !string.Equals(kvp.Key, McpConstants.WorkIQToolsProdAppId, StringComparison.OrdinalIgnoreCase)) + .Where(kvp => !string.Equals(kvp.Key, dryRunAtgAppId, StringComparison.OrdinalIgnoreCase)) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); var removedAudiences = allScopes.Keys .Where(k => !remainingScopes.ContainsKey(k)) @@ -176,7 +177,7 @@ private static Command CreateMcpSubcommand( } else { - var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, excludeLegacyAtg: false); + var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, excludeLegacyAtg: false, resolvedAtgAppId: dryRunAtgAppId); logger.LogInformation("Would configure OAuth2 grants and inheritable permissions:"); foreach (var (audience, scopes) in scopesByAudience) logger.LogInformation(" - Resource: {Audience} Scopes: {Scopes}", @@ -421,8 +422,9 @@ public static async Task ConfigureMcpPermissionsAsync( { var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); + var atgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync( - manifestPath, excludeLegacyAtg: removeLegacyAtgScopes); + manifestPath, excludeLegacyAtg: removeLegacyAtgScopes, resolvedAtgAppId: atgAppId); // Validate all scopes are known: V1 pattern, V2 value, or metadata scope var unknownScopes = scopesByAudience.Values @@ -558,9 +560,10 @@ internal static async Task RemoveStaleCustomPermissionsAsync( CancellationToken cancellationToken) { // Resource app IDs owned by standard setup subcommands — never remove these + var envAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); var protectedIds = new HashSet(StringComparer.OrdinalIgnoreCase) { - ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment), + envAtgAppId, ConfigConstants.MessagingBotApiAppId, ConfigConstants.ObservabilityApiAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, @@ -571,7 +574,7 @@ internal static async Task RemoveStaleCustomPermissionsAsync( // not by custom-permission reconciliation. Without this, re-running 'setup blueprint' // would treat them as stale and remove them. var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var mcpAudiences = await ManifestHelper.GetScopesByAudienceAsync(manifestPath); + var mcpAudiences = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: envAtgAppId); foreach (var audienceId in mcpAudiences.Keys) protectedIds.Add(audienceId); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs index a5e28c71..db68e2e0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs @@ -51,8 +51,8 @@ public static bool IsV1Scope(string? scope) => scope.EndsWith(".All", StringComparison.OrdinalIgnoreCase); /// - /// Resolves an audience string to a concrete App ID, mapping legacy and unset values to the - /// shared ATG AppId (). The following inputs all fall back to ATG: + /// Resolves an audience string to a concrete App ID, mapping legacy and unset values to + /// . The following inputs all fall back to ATG: /// /// null, empty, or whitespace /// values starting with api:// (V1 legacy format) @@ -60,13 +60,21 @@ public static bool IsV1Scope(string? scope) => /// /// All other values are returned unchanged. /// - public static string ResolveAudienceOrAtgFallback(string? audience) => + public static string ResolveAudienceOrAtgFallback(string? audience, string atgAppId) => string.IsNullOrWhiteSpace(audience) || audience.StartsWith("api://", StringComparison.OrdinalIgnoreCase) || string.Equals(audience, "default", StringComparison.OrdinalIgnoreCase) - ? WorkIQToolsProdAppId + ? atgAppId : audience; + /// + /// Resolves an audience string to a concrete App ID using + /// as the ATG fallback. Prefer the overload that accepts an explicit atgAppId when the + /// environment-resolved resource app ID is available. + /// + public static string ResolveAudienceOrAtgFallback(string? audience) => + ResolveAudienceOrAtgFallback(audience, WorkIQToolsProdAppId); + // HTTP Headers public static class MediaTypes { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs index 60e3405d..952d934a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ManifestHelper.cs @@ -275,7 +275,7 @@ static void AddScopeString(HashSet set, string scopeValue) /// /// Reads ToolingManifest.json and returns scopes grouped by their audience (resourceAppId). /// Supports V1 (shared ATG AppId), V2 (per-server AppId), and mixed manifests. - /// Fallback rules — the following audience values all resolve to (ATG AppId): + /// Fallback rules — the following audience values all resolve to : /// • missing / null / whitespace /// • any value starting with api:// (legacy V1 format) /// • the literal string "default" @@ -285,12 +285,18 @@ static void AddScopeString(HashSet set, string scopeValue) /// When true, omits all entries whose resolved audience is the shared ATG AppId. /// Only pass true when V2 SDK is confirmed live (--remove-legacy-scopes flag). /// + /// + /// Environment-resolved ATG resource app ID. Defaults to + /// when null. Pass ConfigConstants.GetAgent365ToolsResourceAppId(environment) to respect + /// A365_MCP_APP_ID_* environment variable overrides. + /// /// Dictionary of resourceAppId → ordered scopes array public static async Task> GetScopesByAudienceAsync( string manifestPath, - bool excludeLegacyAtg = false) + bool excludeLegacyAtg = false, + string? resolvedAtgAppId = null) { - var atgAppId = McpConstants.WorkIQToolsProdAppId; + var atgAppId = resolvedAtgAppId ?? McpConstants.WorkIQToolsProdAppId; var scopesByAudience = new Dictionary>(StringComparer.OrdinalIgnoreCase); // McpServersMetadata.Read.All is always required and belongs to the ATG AppId @@ -335,7 +341,7 @@ public static async Task> GetScopesByAudienceAsync( if (element.TryGetProperty(McpConstants.ManifestProperties.Audience, out var audienceEl)) audience = audienceEl.GetString(); - audience = McpConstants.ResolveAudienceOrAtgFallback(audience); + audience = McpConstants.ResolveAudienceOrAtgFallback(audience, atgAppId); if (excludeLegacyAtg && string.Equals(audience, atgAppId, StringComparison.OrdinalIgnoreCase)) @@ -370,8 +376,11 @@ static Dictionary ToDictionary(Dictionary - public static async Task>> GetServerNamesByAudienceAsync(string manifestPath) + public static async Task>> GetServerNamesByAudienceAsync( + string manifestPath, + string? resolvedAtgAppId = null) { + var atgAppId = resolvedAtgAppId ?? McpConstants.WorkIQToolsProdAppId; var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); var parsed = await ReadManifestAsync(manifestPath); @@ -387,8 +396,8 @@ public static async Task>> GetServerNamesByAudie // Skip all forms that resolve to the shared ATG audience — these are handled via the // shared BEARER_TOKEN env var, not per-server BEARER_TOKEN_{AUDIENCE} entries. - var resolvedAudience = McpConstants.ResolveAudienceOrAtgFallback(audience); - if (string.Equals(resolvedAudience, McpConstants.WorkIQToolsProdAppId, StringComparison.OrdinalIgnoreCase)) + var resolvedAudience = McpConstants.ResolveAudienceOrAtgFallback(audience, atgAppId); + if (string.Equals(resolvedAudience, atgAppId, StringComparison.OrdinalIgnoreCase)) continue; var serverName = ExtractServerName(element); From 798057b0b59a56ab090fa95fd134580e7b13cc88 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 17 Apr 2026 19:12:32 +0530 Subject: [PATCH 4/5] updtaed script with V2 ids --- ...gent365ToolsServicePrincipalProdPublic.ps1 | 130 +++++++++++++----- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 b/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 index 370752ca..addc439e 100644 --- a/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 +++ b/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 @@ -10,39 +10,37 @@ All V1 servers share this single resource and use McpServers.*.All scopes. V2 model: Creates one Service Principal per MCP server using per-server AppIds. + V2 AppIds are discovered from the live Agent 365 V2 endpoint: + https://agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers V2 servers use the Tools.ListInvoke.All scope against their own audience GUID. - AppIds are extracted from ToolingManifest.json (-ManifestPath) or passed - directly via -V2AppIds. + Pass -V2AppIds to bypass the live call and supply AppIds directly. Use -Mode All (default) during migration when the tenant may have both V1 and V2 servers. .PARAMETER Mode V1 - Provision only the shared V1 ATG Service Principal. - V2 - Provision per-server V2 Service Principals only. + V2 - Provision per-server V2 Service Principals only (discovered from live endpoint). All - Provision both V1 and all V2 servers (default, recommended during migration). -.PARAMETER ManifestPath - Path to ToolingManifest.json. The script reads audience GUIDs where scope equals - 'Tools.ListInvoke.All' and creates a Service Principal for each unique V2 AppId found. - .PARAMETER V2AppIds - Explicit list of V2 per-server AppIds. Used when -ManifestPath is not provided. + Explicit list of V2 per-server AppIds. Bypasses the live discover endpoint call. .EXAMPLE .\New-Agent365ToolsServicePrincipalProdPublic.ps1 - (Creates the V1 SP; V2 is skipped unless -ManifestPath or -V2AppIds are supplied.) + (Creates V1 SP and discovers V2 SPs from the live endpoint.) .EXAMPLE - .\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode V2 -ManifestPath ".\ToolingManifest.json" + .\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode V2 .EXAMPLE - .\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode All -ManifestPath ".\ToolingManifest.json" + .\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode All .EXAMPLE .\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode V2 -V2AppIds @("05879165-0320-489e-b644-f72b33f3edf0") .NOTES Requires: Admin permissions to create Service Principals. + Requires: Az CLI (az login) to acquire a token for the discover endpoint. This script is safe to re-run — existing Service Principals are skipped, not re-created. #> @@ -50,10 +48,7 @@ param( [ValidateSet("V1", "V2", "All")] [string]$Mode = "All", - # Path to ToolingManifest.json — used to auto-extract V2 per-server AppIds - [string]$ManifestPath = "", - - # Explicit V2 per-server AppIds (alternative to -ManifestPath) + # Explicit V2 per-server AppIds — bypasses the live discover endpoint call [string[]]$V2AppIds = @() ) @@ -63,6 +58,26 @@ Set-StrictMode -Version Latest # V1: shared ATG AppId (WorkIQToolsProdAppId) — all V1 servers share this resource $v1AppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" +# V2 discover endpoint — returns a bare JSON array of available MCP servers +$v2DiscoverUrl = "https://agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers" + +# V2 scope value used by all per-server entries +$v2ScopeValue = "Tools.ListInvoke.All" + +# V2 fallback AppIds — used when the discover endpoint is unreachable. +# Source: MCPPlatform_McpScopedApps__ServerAppMappings__* configuration values. +$v2FallbackAppIds = @( + "16b1878d-62c7-4009-aa25-68989d63bbad", # mcp_MailTools + "147dc821-b413-44c0-8009-1a3098378012", # mcp_MeServer + "910333d2-47e9-43ca-981f-6df2f4531ef4", # mcp_CalendarTools + "ce5029ee-c1d3-45c0-bdcc-efb5a4245687", # mcp_TeamsServer + "b0b2a2bb-6361-4549-a00c-a018417eb8e2", # mcp_OneDriveRemoteServer + "292cff14-c0e8-4116-9e3b-99934ae05766", # mcp_SharePointRemoteServer + "2dbeefeb-6462-48a4-abe6-1c4989699319", # mcp_AdminTools + "c2d0c2b6-8013-4346-9f8b-b81d3b754a29", # mcp_WordServer + "ab7c82de-7946-4454-ac28-70249d17c95e" # mcp_M365Copilot +) + # --- Helper: create Service Principal if it does not already exist --- function Register-ServicePrincipalIfMissing { param([string]$AppId, [string]$Label) @@ -80,6 +95,51 @@ function Register-ServicePrincipalIfMissing { Write-Host " Created: $($sp.DisplayName) (SP ID: $($sp.Id))" -ForegroundColor Green } +# --- Helper: call the V2 discover endpoint and extract per-server AppIds --- +function Get-V2AppIdsFromDiscoverEndpoint { + Write-Host "Discovering V2 AppIds from: $v2DiscoverUrl" -ForegroundColor Cyan + + # Acquire a token for the ATG audience using az CLI + try { + $token = az account get-access-token --resource $v1AppId --query accessToken -o tsv 2>$null + if ([string]::IsNullOrWhiteSpace($token)) { + Write-Host " WARNING: Could not acquire token via az CLI. Ensure you are logged in with 'az login'." -ForegroundColor Yellow + return @() + } + } + catch { + Write-Host " WARNING: az CLI token acquisition failed: $($_.Exception.Message)" -ForegroundColor Yellow + return @() + } + + try { + $headers = @{ Authorization = "Bearer $token" } + $response = Invoke-RestMethod -Uri $v2DiscoverUrl -Headers $headers -Method Get -ErrorAction Stop + + # V2 returns a bare array; V1 (legacy) returns a wrapped { mcpServers: [...] } object + $servers = if ($response -is [array]) { $response } else { $response.mcpServers } + + if (-not $servers -or $servers.Count -eq 0) { + Write-Host " No servers returned from discover endpoint." -ForegroundColor Yellow + return @() + } + + $appIds = @( + $servers | + Where-Object { $_.scope -eq $v2ScopeValue -and $_.audience -match '(?i)^[0-9a-f]{8}-' } | + Select-Object -ExpandProperty audience -Unique + ) + + Write-Host " Found $($appIds.Count) V2 AppId(s) from discover endpoint." -ForegroundColor Cyan + Write-Host "" + return $appIds + } + catch { + Write-Host " WARNING: Failed to call discover endpoint: $($_.Exception.Message)" -ForegroundColor Yellow + return @() + } +} + Write-Host "========================================" -ForegroundColor Cyan Write-Host "Service Principal Creation for Agent 365 MCP Servers (Admin Only)" -ForegroundColor Cyan Write-Host " Mode: $Mode" -ForegroundColor Cyan @@ -93,23 +153,18 @@ Write-Host "" $resolvedV2AppIds = @() if ($Mode -ne "V1") { - if ($ManifestPath -and (Test-Path $ManifestPath)) { - Write-Host "Reading V2 AppIds from manifest: $ManifestPath" -ForegroundColor Cyan - $manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json - $resolvedV2AppIds = @( - $manifest.mcpServers | - Where-Object { $_.scope -eq "Tools.ListInvoke.All" -and $_.audience -match '(?i)^[0-9a-f]{8}-' } | - Select-Object -ExpandProperty audience -Unique - ) - Write-Host " Found $($resolvedV2AppIds.Count) V2 AppId(s) in manifest." -ForegroundColor Cyan - Write-Host "" - } - elseif ($V2AppIds.Count -gt 0) { + if ($V2AppIds.Count -gt 0) { $resolvedV2AppIds = $V2AppIds + Write-Host "Using explicit V2 AppIds provided via -V2AppIds." -ForegroundColor Cyan + Write-Host "" } - elseif ($Mode -eq "V2") { - Write-Host "ERROR: -Mode V2 requires -ManifestPath or -V2AppIds." -ForegroundColor Red - exit 1 + else { + $resolvedV2AppIds = Get-V2AppIdsFromDiscoverEndpoint + if ($resolvedV2AppIds.Count -eq 0) { + Write-Host " Discover endpoint returned no V2 AppIds. Falling back to hardcoded AppIds." -ForegroundColor Yellow + Write-Host "" + $resolvedV2AppIds = $v2FallbackAppIds + } } } @@ -131,17 +186,17 @@ Import-Module Microsoft.Graph.Authentication -ErrorAction Stop # --- Connect to Microsoft Graph --- Write-Host "" Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan -Write-Host "⚠ You need admin permissions for this operation." -ForegroundColor Yellow +Write-Host "You need admin permissions for this operation." -ForegroundColor Yellow Write-Host "" try { Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All" -NoWelcome $context = Get-MgContext - Write-Host "✓ Connected to tenant: $($context.TenantId)" -ForegroundColor Green + Write-Host "Connected to tenant: $($context.TenantId)" -ForegroundColor Green Write-Host "" } catch { - Write-Host "✗ Failed to connect to Microsoft Graph" -ForegroundColor Red + Write-Host "Failed to connect to Microsoft Graph" -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red exit 1 } @@ -155,7 +210,7 @@ try { Register-ServicePrincipalIfMissing -AppId $v1AppId -Label "V1 Shared ATG" } - # V2: per-server Service Principals + # V2: per-server Service Principals discovered from the live endpoint if (($Mode -eq "V2" -or $Mode -eq "All") -and $resolvedV2AppIds.Count -gt 0) { foreach ($appId in $resolvedV2AppIds) { Register-ServicePrincipalIfMissing -AppId $appId -Label "V2 Per-Server" @@ -163,18 +218,17 @@ try { } elseif ($Mode -eq "All" -and $resolvedV2AppIds.Count -eq 0) { Write-Host "" - Write-Host " V2 provisioning skipped — no V2 AppIds found." -ForegroundColor Yellow - Write-Host " Provide -ManifestPath or -V2AppIds to provision V2 servers." -ForegroundColor Yellow + Write-Host " V2 provisioning skipped — no V2 AppIds available." -ForegroundColor Yellow } } catch { Write-Host "" - Write-Host "✗ Failed to create Service Principal" -ForegroundColor Red + Write-Host "Failed to create Service Principal" -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red Write-Host "" if ($_.Exception.Message -like "*Insufficient privileges*" -or $_.Exception.Message -like "*Authorization*") { - Write-Host "⚠ This error usually means you don't have admin permissions." -ForegroundColor Yellow + Write-Host "This error usually means you don't have admin permissions." -ForegroundColor Yellow Write-Host "" Write-Host "Required Permissions:" -ForegroundColor Cyan Write-Host " - AppRoleAssignment.ReadWrite.All" -ForegroundColor White From 8449ccb0f29e9ef9fb1b2e2dc31e86f3aa9099a4 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Fri, 17 Apr 2026 19:14:51 +0530 Subject: [PATCH 5/5] fixed the logic --- .../New-Agent365ToolsServicePrincipalProdPublic.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 b/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 index addc439e..af901f88 100644 --- a/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 +++ b/scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1 @@ -159,12 +159,12 @@ if ($Mode -ne "V1") { Write-Host "" } else { - $resolvedV2AppIds = Get-V2AppIdsFromDiscoverEndpoint - if ($resolvedV2AppIds.Count -eq 0) { - Write-Host " Discover endpoint returned no V2 AppIds. Falling back to hardcoded AppIds." -ForegroundColor Yellow - Write-Host "" - $resolvedV2AppIds = $v2FallbackAppIds - } + $liveAppIds = Get-V2AppIdsFromDiscoverEndpoint + # Always union live results with the hardcoded fallback so servers absent from + # the discover response (e.g. mcp_MeServer) are still provisioned. + $resolvedV2AppIds = @($liveAppIds + $v2FallbackAppIds | Select-Object -Unique) + Write-Host " Total V2 AppIds to provision (live + fallback): $($resolvedV2AppIds.Count)" -ForegroundColor Cyan + Write-Host "" } }