From b50eef4cf7decd9ce96c3f0a4b5ff98467a3706e Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Mon, 21 Sep 2026 10:05:11 -0700 Subject: [PATCH 01/20] Add develop-mcp commands to list and grant MCP server permissions Adds two subcommands under a365 develop-mcp: - list-agent-instances reports agent instances of a blueprint missing Tools.ListInvoke.All for a BYO MCP server and offers to grant it. - grant-mcpserver-permissions creates the AllPrincipals delegated grant for a single agent identity. The MCP server name resolves to the Entra application '{name} - BYO'; that application's service principal is the grant resource. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Commands/DevelopMcpCommand.cs | 9 +- .../McpServerPermissionsSubcommands.cs | 390 ++++++++++++++++++ .../Constants/McpConstants.cs | 14 + .../Models/McpServerPermissionModels.cs | 28 ++ .../Program.cs | 4 +- .../Services/McpServerPermissionService.cs | 131 ++++++ .../McpServerPermissionsSubcommandsTests.cs | 240 +++++++++++ .../McpServerPermissionServiceTests.cs | 192 +++++++++ 9 files changed, 1007 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerPermissionModels.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aa369b..24b149a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `a365 develop-mcp list-agent-instances` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and offers to grant it, while `a365 develop-mcp grant-mcpserver-permissions` grants that permission to a single agent identity. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 941b1d10..819df8e6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -22,7 +22,8 @@ public static Command CreateCommand( ILogger logger, IAgent365ToolingService toolingService, IEvaluationPipelineService? evaluationPipelineService = null, - GraphApiService? graphApiService = null) + GraphApiService? graphApiService = null, + McpServerPermissionService? mcpServerPermissionService = null) { var developMcpCommand = new Command("develop-mcp", "Manage MCP servers in Dataverse environments"); @@ -40,6 +41,12 @@ public static Command CreateCommand( developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateRegisterExternalMcpServerSubcommand(logger, toolingService, graphApiService)); + if (mcpServerPermissionService is not null) + { + developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(logger, mcpServerPermissionService)); + developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(logger, mcpServerPermissionService)); + } + if (evaluationPipelineService is not null) { developMcpCommand.AddCommand(CreateEvaluateSubcommand(logger, evaluationPipelineService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs new file mode 100644 index 00000000..d8742698 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +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; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands; + +/// +/// Subcommands that report and grant the MCP server permissions an agent identity needs +/// to call a BYO MCP server. +/// +public static class McpServerPermissionsSubcommands +{ + private const string ListCommandName = "list-agent-instances"; + private const string GrantCommandName = "grant-mcpserver-permissions"; + + /// + /// Creates the list-agent-instances subcommand, which reports agent instances of a blueprint + /// that are missing the MCP server scope and offers to grant it. + /// + public static Command CreateListAgentInstancesSubcommand( + ILogger logger, + McpServerPermissionService permissionService) + { + var command = new Command(ListCommandName, + $"List agent instances of a blueprint that are missing the '{McpConstants.V2ScopeValue}' permission for an MCP server.\n" + + "Offers to grant the permission interactively; prints the equivalent commands when input is redirected."); + + var blueprintIdOption = new Option( + "--agent-blueprint-id", + description: "Agent blueprint ID (GUID) whose agent instances should be checked.") + { + IsRequired = true, + }; + + var serverNameOption = new Option( + ["--mcp-server-name", "-s"], + description: "MCP server name. The Entra application is resolved as '{name} - BYO'.") + { + IsRequired = true, + }; + + var tenantIdOption = new Option( + "--tenant-id", + description: "Azure AD tenant ID. Defaults to the current Azure CLI context."); + + var yesOption = new Option( + ["--yes", "-y"], + description: "Grant the missing permission to every listed agent instance without prompting."); + + command.AddOption(blueprintIdOption); + command.AddOption(serverNameOption); + command.AddOption(tenantIdOption); + command.AddOption(yesOption); + command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); + + command.SetHandler(async (InvocationContext context) => + { + var blueprintIdRaw = context.ParseResult.GetValueForOption(blueprintIdOption); + var serverName = context.ParseResult.GetValueForOption(serverNameOption); + var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); + var grantAll = context.ParseResult.GetValueForOption(yesOption); + var ct = context.GetCancellationToken(); + + if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out var blueprintId)) + { + context.ExitCode = 1; + return; + } + + if (!TryValidateServerName(serverName, logger)) + { + context.ExitCode = 1; + return; + } + + var tenantId = await ResolveTenantIdAsync(tenantIdFlag, logger); + if (tenantId is null) + { + context.ExitCode = 1; + return; + } + + var resource = await permissionService.ResolveServerResourceAsync(tenantId, serverName!.Trim(), ct); + if (resource is null) + { + context.ExitCode = 1; + return; + } + + IReadOnlyList statuses; + try + { + statuses = await permissionService.GetAgentInstanceStatusesAsync(tenantId, blueprintId, resource.ServicePrincipalObjectId, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError("Failed to list agent instances for blueprint {BlueprintId}: {Message}", blueprintId, ex.Message); + context.ExitCode = 1; + return; + } + + if (statuses.Count == 0) + { + logger.LogWarning("No agent instances are linked to blueprint {BlueprintId}.", blueprintId); + return; + } + + var missing = statuses.Where(s => !s.HasScope).ToList(); + logger.LogInformation("MCP server : {DisplayName} ({AppId})", resource.DisplayName, resource.AppId); + logger.LogInformation("Scope : {Scope}", McpConstants.V2ScopeValue); + logger.LogInformation("Instances : {Total} total, {Missing} missing the permission", statuses.Count, missing.Count); + logger.LogInformation(""); + + if (missing.Count == 0) + { + logger.LogInformation("All agent instances already have the permission. Nothing to do."); + return; + } + + for (int i = 0; i < missing.Count; i++) + { + logger.LogInformation(" [{Index}] {DisplayName} {SpObjectId}", + i + 1, missing[i].DisplayName ?? "(no display name)", missing[i].ServicePrincipalObjectId); + } + logger.LogInformation(""); + + var selected = ResolveSelection(missing, grantAll, resource, logger, ct); + if (selected.Count == 0) + { + return; + } + + var failures = await GrantToSelectedAsync(permissionService, tenantId, resource, selected, logger, ct); + if (failures > 0) + { + context.ExitCode = 1; + } + }); + + return command; + } + + /// + /// Creates the grant-mcpserver-permissions subcommand, which grants a single agent identity + /// the MCP server scope. + /// + public static Command CreateGrantPermissionsSubcommand( + ILogger logger, + McpServerPermissionService permissionService) + { + var command = new Command(GrantCommandName, + $"Grant an agent identity the '{McpConstants.V2ScopeValue}' permission for an MCP server.\n" + + "Creates a tenant-wide (AllPrincipals) delegated permission grant."); + + var agentSpIdOption = new Option( + "--agent-serviceprincipal-id", + description: "Object ID (GUID) of the agent identity service principal receiving the permission.") + { + IsRequired = true, + }; + + var serverNameOption = new Option( + ["--mcp-server-name", "-s"], + description: "MCP server name. The Entra application is resolved as '{name} - BYO'.") + { + IsRequired = true, + }; + + var tenantIdOption = new Option( + "--tenant-id", + description: "Azure AD tenant ID. Defaults to the current Azure CLI context."); + + command.AddOption(agentSpIdOption); + command.AddOption(serverNameOption); + command.AddOption(tenantIdOption); + command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); + + command.SetHandler(async (InvocationContext context) => + { + var agentSpIdRaw = context.ParseResult.GetValueForOption(agentSpIdOption); + var serverName = context.ParseResult.GetValueForOption(serverNameOption); + var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); + var ct = context.GetCancellationToken(); + + if (!TryValidateGuid(agentSpIdRaw, "--agent-serviceprincipal-id", logger, out var agentSpId)) + { + context.ExitCode = 1; + return; + } + + if (!TryValidateServerName(serverName, logger)) + { + context.ExitCode = 1; + return; + } + + var tenantId = await ResolveTenantIdAsync(tenantIdFlag, logger); + if (tenantId is null) + { + context.ExitCode = 1; + return; + } + + var resource = await permissionService.ResolveServerResourceAsync(tenantId, serverName!.Trim(), ct); + if (resource is null) + { + context.ExitCode = 1; + return; + } + + var granted = await permissionService.GrantServerScopeAsync(tenantId, agentSpId, resource.ServicePrincipalObjectId, ct); + if (!granted) + { + logger.LogError("Failed to grant '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", + McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); + context.ExitCode = 1; + return; + } + + logger.LogInformation("Granted '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", + McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); + }); + + return command; + } + + /// + /// Determines which instances to grant: all when --yes is set, the user's selection when a + /// terminal is attached, or none (printing the equivalent commands) when input is redirected. + /// + private static List ResolveSelection( + List missing, + bool grantAll, + McpServerResource resource, + ILogger logger, + CancellationToken ct) + { + if (grantAll) + { + return missing; + } + + if (Console.IsInputRedirected) + { + logger.LogInformation("Input is redirected. Re-run with --yes to grant, or run the commands below:"); + foreach (var instance in missing) + { + logger.LogInformation(" a365 develop-mcp {Command} --agent-serviceprincipal-id {SpObjectId} --mcp-server-name {ServerName}", + GrantCommandName, instance.ServicePrincipalObjectId, resource.ServerName); + } + return []; + } + + Console.Write($"Grant '{McpConstants.V2ScopeValue}' now? Enter 'all', a comma-separated list of numbers, or press Enter to skip: "); + var response = ConsoleHelper.ReadLineCancellable(ct)?.Trim(); + + if (string.IsNullOrWhiteSpace(response)) + { + logger.LogInformation("No permissions granted."); + return []; + } + + if (string.Equals(response, "all", StringComparison.OrdinalIgnoreCase)) + { + return missing; + } + + var selected = new List(); + foreach (var token in response.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!int.TryParse(token, out var index) || index < 1 || index > missing.Count) + { + logger.LogError("Invalid selection '{Token}'. Enter numbers between 1 and {Max}, or 'all'.", token, missing.Count); + return []; + } + + var instance = missing[index - 1]; + if (!selected.Contains(instance)) + { + selected.Add(instance); + } + } + + return selected; + } + + /// + /// Grants the MCP server scope to each selected instance. Returns the number of failures so + /// the caller can set a non-zero exit code. + /// + private static async Task GrantToSelectedAsync( + McpServerPermissionService permissionService, + string tenantId, + McpServerResource resource, + List selected, + ILogger logger, + CancellationToken ct) + { + logger.LogInformation(""); + var failures = 0; + + foreach (var instance in selected) + { + ct.ThrowIfCancellationRequested(); + + bool granted; + try + { + granted = await permissionService.GrantServerScopeAsync( + tenantId, instance.ServicePrincipalObjectId, resource.ServicePrincipalObjectId, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(" {DisplayName} ({SpObjectId}): {Message}", + instance.DisplayName ?? "(no display name)", instance.ServicePrincipalObjectId, ex.Message); + failures++; + continue; + } + + if (granted) + { + logger.LogInformation(" Granted: {DisplayName} ({SpObjectId})", + instance.DisplayName ?? "(no display name)", instance.ServicePrincipalObjectId); + } + else + { + logger.LogError(" Failed: {DisplayName} ({SpObjectId})", + instance.DisplayName ?? "(no display name)", instance.ServicePrincipalObjectId); + failures++; + } + } + + logger.LogInformation(""); + logger.LogInformation("{Granted} of {Total} grant(s) succeeded.", selected.Count - failures, selected.Count); + return failures; + } + + private static bool TryValidateGuid(string? value, string optionName, ILogger logger, out string normalized) + { + if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value.Trim(), out var guid)) + { + logger.LogError("{OptionName} must be a GUID.", optionName); + normalized = string.Empty; + return false; + } + + normalized = guid.ToString("D"); + return true; + } + + private static bool TryValidateServerName(string? serverName, ILogger logger) + { + if (string.IsNullOrWhiteSpace(serverName)) + { + logger.LogError("--mcp-server-name must not be empty."); + return false; + } + + return true; + } + + private static async Task ResolveTenantIdAsync(string? tenantIdFlag, ILogger logger) + { + if (!string.IsNullOrWhiteSpace(tenantIdFlag)) + { + if (!Guid.TryParse(tenantIdFlag.Trim(), out var tenantGuid)) + { + logger.LogError("--tenant-id must be a GUID."); + return null; + } + return tenantGuid.ToString("D"); + } + + var detected = await TenantDetectionHelper.DetectTenantIdAsync(null, logger); + if (string.IsNullOrWhiteSpace(detected)) + { + logger.LogError("Tenant ID could not be determined. Pass --tenant-id or run 'az login'."); + return null; + } + + return detected; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs index 1730f54a..9dd6646c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs @@ -71,6 +71,20 @@ public static string BuildPpmiIdentifierUri(string environment, string tenantId, /// public const string V2ScopeValue = "Tools.ListInvoke.All"; + /// + /// Suffix appended to an MCP server name to form the display name of its BYO Entra application. + /// + public const string ByoAppNameSuffix = " - BYO"; + + /// + /// Builds the Entra application display name for a BYO MCP server (for example, "Foo" gives "Foo - BYO"). + /// + public static string BuildByoAppDisplayName(string serverName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + return $"{serverName.Trim()}{ByoAppNameSuffix}"; + } + /// /// Returns true when the scope matches the V1 pattern McpServers.*.All (shared ATG AppId model) /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerPermissionModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerPermissionModels.cs new file mode 100644 index 00000000..a3fa7306 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerPermissionModels.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// The Entra resource that represents a BYO MCP server. +/// +/// MCP server name as supplied by the caller. +/// Display name of the backing Entra application ("{ServerName} - BYO"). +/// Application (client) ID of the BYO application. +/// Object ID of the BYO service principal, used as the grant resourceId. +public sealed record McpServerResource( + string ServerName, + string DisplayName, + string AppId, + string ServicePrincipalObjectId); + +/// +/// An agent instance and whether it already holds the MCP server scope. +/// +/// Object ID of the agent identity service principal. +/// Display name of the agent identity, when Entra returns one. +/// True when an existing grant already covers the required scope. +public sealed record AgentInstancePermissionStatus( + string ServicePrincipalObjectId, + string? DisplayName, + bool HasScope); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 546dd535..6b581135 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -163,6 +163,7 @@ await Task.WhenAll( var graphApiService = serviceProvider.GetRequiredService(); var armApiService = serviceProvider.GetRequiredService(); var agentBlueprintService = serviceProvider.GetRequiredService(); + var mcpServerPermissionService = serviceProvider.GetRequiredService(); var blueprintLookupService = serviceProvider.GetRequiredService(); var federatedCredentialService = serviceProvider.GetRequiredService(); var platformDetector = serviceProvider.GetRequiredService(); @@ -174,7 +175,7 @@ await Task.WhenAll( // Add commands rootCommand.AddCommand(DevelopCommand.CreateCommand(developLogger, configService, executor, authService, graphApiService, agentBlueprintService, processService)); - rootCommand.AddCommand(DevelopMcpCommand.CreateCommand(developLogger, toolingService, evaluationPipelineService, graphApiService)); + rootCommand.AddCommand(DevelopMcpCommand.CreateCommand(developLogger, toolingService, evaluationPipelineService, graphApiService, mcpServerPermissionService)); var confirmationProvider = serviceProvider.GetRequiredService(); rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor, backendConfigurator, azureAuthValidator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator, confirmationProvider, armApiService, resolver: bootstrapResolver)); @@ -377,6 +378,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // For AgentApplication.Create permission diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs new file mode 100644 index 00000000..261ccc97 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Resolves BYO MCP server resources in Entra and manages the delegated permission grants +/// that let agent identities call those servers. +/// +public class McpServerPermissionService +{ + private readonly GraphApiService _graphApiService; + private readonly AgentBlueprintService _blueprintService; + private readonly ILogger _logger; + + public McpServerPermissionService( + GraphApiService graphApiService, + AgentBlueprintService blueprintService, + ILogger logger) + { + _graphApiService = graphApiService; + _blueprintService = blueprintService; + _logger = logger; + } + + /// + /// Resolves the BYO application for an MCP server name and returns its service principal, + /// which is the resourceId of the permission grant. Returns null when either the application + /// or its service principal cannot be found. + /// + public virtual async Task ResolveServerResourceAsync( + string tenantId, + string serverName, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + + var displayName = McpConstants.BuildByoAppDisplayName(serverName); + + var appId = await _graphApiService.FindApplicationByDisplayNameAsync(tenantId, displayName, ct); + if (string.IsNullOrWhiteSpace(appId)) + { + _logger.LogError("No Entra application named '{DisplayName}' was found in tenant {TenantId}.", displayName, tenantId); + return null; + } + + var spObjectId = await _graphApiService.LookupServicePrincipalByAppIdAsync( + tenantId, appId, ct, AuthenticationConstants.RequiredPermissionGrantScopes); + if (string.IsNullOrWhiteSpace(spObjectId)) + { + _logger.LogError( + "Application '{DisplayName}' ({AppId}) has no service principal in tenant {TenantId}. Permissions cannot be granted against it.", + displayName, appId, tenantId); + return null; + } + + _logger.LogDebug("Resolved MCP server '{ServerName}' to app {AppId}, service principal {SpObjectId}.", + serverName, appId, spObjectId); + + return new McpServerResource(serverName, displayName, appId, spObjectId); + } + + /// + /// Returns every agent instance linked to the blueprint together with whether it already + /// holds against the given MCP server resource. + /// + public virtual async Task> GetAgentInstanceStatusesAsync( + string tenantId, + string blueprintId, + string resourceSpObjectId, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(blueprintId); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceSpObjectId); + + var instances = await _blueprintService.GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, ct); + + var statuses = new List(instances.Count); + foreach (var instance in instances) + { + ct.ThrowIfCancellationRequested(); + + var grants = await _graphApiService.GetOauth2PermissionGrantsAsync(tenantId, instance.IdentitySpId, ct); + var hasScope = grants.Any(g => + string.Equals(g.resourceId, resourceSpObjectId, StringComparison.OrdinalIgnoreCase) && + ScopeStringContains(g.scope, McpConstants.V2ScopeValue)); + + statuses.Add(new AgentInstancePermissionStatus(instance.IdentitySpId, instance.DisplayName, hasScope)); + } + + return statuses; + } + + /// + /// Creates or updates the AllPrincipals grant that gives an agent identity + /// on the given MCP server resource. + /// + public virtual async Task GrantServerScopeAsync( + string tenantId, + string agentServicePrincipalObjectId, + string resourceSpObjectId, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(agentServicePrincipalObjectId); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceSpObjectId); + + return await _graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + tenantId, + agentServicePrincipalObjectId, + resourceSpObjectId, + [McpConstants.V2ScopeValue], + ct, + AuthenticationConstants.RequiredPermissionGrantScopes); + } + + /// + /// Grant scope values are a single space-delimited string, so a substring match would report + /// a false positive for any scope that merely shares a prefix. + /// + private static bool ScopeStringContains(string? scopeString, string scope) => + !string.IsNullOrWhiteSpace(scopeString) && + scopeString.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(s => string.Equals(s, scope, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs new file mode 100644 index 00000000..9878abfc --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Invocation tests for the develop-mcp subcommands that report and grant MCP server permissions. +/// These pin the CLI contract: which inputs are rejected, which exit codes are produced, and which +/// grants are issued. +/// +public class McpServerPermissionsSubcommandsTests +{ + private const string TenantId = "00000000-0000-0000-0000-0000000000aa"; + private const string BlueprintId = "33333333-3333-3333-3333-333333333333"; + private const string AgentSpId = "44444444-4444-4444-4444-444444444444"; + private const string ByoAppId = "11111111-1111-1111-1111-111111111111"; + private const string ByoSpObjectId = "22222222-2222-2222-2222-222222222222"; + private const string ServerName = "Foo"; + + private readonly ILogger _logger = NullLogger.Instance; + private readonly McpServerPermissionService _permissionService; + + public McpServerPermissionsSubcommandsTests() + { + var graph = Substitute.For(); + var blueprintService = Substitute.For( + Substitute.For>(), graph); + _permissionService = Substitute.For( + graph, blueprintService, Substitute.For>()); + } + + private static McpServerResource Resource() => + new(ServerName, McpConstants.BuildByoAppDisplayName(ServerName), ByoAppId, ByoSpObjectId); + + private void SetupResolvedResource() => + _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) + .Returns(Task.FromResult(Resource())); + + private void SetupInstances(params AgentInstancePermissionStatus[] statuses) => + _permissionService.GetAgentInstanceStatusesAsync(TenantId, BlueprintId, ByoSpObjectId, Arg.Any()) + .Returns(Task.FromResult>(statuses)); + + private Command ListCommand() => + McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(_logger, _permissionService); + + private Command GrantCommand() => + McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(_logger, _permissionService); + + [Fact] + public void ListAgentInstances_HasExpectedName() + { + ListCommand().Name.Should().Be("list-agent-instances"); + } + + [Fact] + public void GrantPermissions_HasExpectedName() + { + GrantCommand().Name.Should().Be("grant-mcpserver-permissions"); + } + + [Fact] + public async Task ListAgentInstances_NonGuidBlueprintId_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "the blueprint ID is interpolated into a Graph OData filter, so a non-GUID must be rejected before any request is made"); + await _permissionService.DidNotReceive().ResolveServerResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ListAgentInstances_NonGuidTenantId_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", "nope"]); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ListAgentInstances_WhitespaceServerName_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", " ", "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "an explicitly empty server name would resolve the application ' - BYO' rather than failing clearly"); + } + + [Fact] + public async Task ListAgentInstances_UnresolvableServer_ExitsWithOne() + { + _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) + .Returns(Task.FromResult(null)); + + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ListAgentInstances_AllInstancesHaveScope_ExitsWithZeroAndGrantsNothing() + { + SetupResolvedResource(); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: true)); + + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(0); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ListAgentInstances_WithoutYes_DoesNotGrantWhenSelectionIsSkipped() + { + SetupResolvedResource(); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: false)); + // Covers both paths: redirected input prints the equivalent commands, a terminal prompts and gets an empty answer. + ConsoleHelper.ReadLineOverrideForTests.Value = () => string.Empty; + try + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(0, + because: "listing is a read-only report; declining the prompt is not a failure"); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + finally + { + ConsoleHelper.ReadLineOverrideForTests.Value = null; + } + } + + [Fact] + public async Task ListAgentInstances_WithYes_GrantsOnlyInstancesMissingTheScope() + { + SetupResolvedResource(); + SetupInstances( + new AgentInstancePermissionStatus(AgentSpId, "Missing", HasScope: false), + new AgentInstancePermissionStatus("sp-has-scope", "Granted", HasScope: true)); + _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) + .Returns(Task.FromResult(true)); + + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId, "--yes"]); + + exitCode.Should().Be(0); + await _permissionService.Received(1).GrantServerScopeAsync( + TenantId, AgentSpId, ByoSpObjectId, Arg.Any()); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + TenantId, "sp-has-scope", ByoSpObjectId, Arg.Any()); + } + + [Fact] + public async Task ListAgentInstances_WithYes_FailedGrantExitsWithOne() + { + SetupResolvedResource(); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Missing", HasScope: false)); + _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) + .Returns(Task.FromResult(false)); + + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId, "--yes"]); + + exitCode.Should().Be(1, + because: "a failed grant must surface as a non-zero exit code so automation does not treat the agent as provisioned"); + } + + [Fact] + public async Task GrantPermissions_NonGuidAgentServicePrincipalId_ExitsWithOne() + { + var exitCode = await GrantCommand().InvokeAsync( + ["--agent-serviceprincipal-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GrantPermissions_UnresolvableServer_ExitsWithOne() + { + _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) + .Returns(Task.FromResult(null)); + + var exitCode = await GrantCommand().InvokeAsync( + ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GrantPermissions_Success_ExitsWithZeroAndGrantsAgainstResolvedResource() + { + SetupResolvedResource(); + _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) + .Returns(Task.FromResult(true)); + + var exitCode = await GrantCommand().InvokeAsync( + ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(0); + await _permissionService.Received(1).GrantServerScopeAsync( + TenantId, AgentSpId, ByoSpObjectId, Arg.Any()); + } + + [Fact] + public async Task GrantPermissions_GraphRejectsGrant_ExitsWithOne() + { + SetupResolvedResource(); + _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) + .Returns(Task.FromResult(false)); + + var exitCode = await GrantCommand().InvokeAsync( + ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs new file mode 100644 index 00000000..fc1785a7 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Tests for , which resolves the BYO Entra resource for an +/// MCP server and manages the delegated grants that let agent identities call it. +/// +public class McpServerPermissionServiceTests +{ + private const string TenantId = "00000000-0000-0000-0000-0000000000aa"; + private const string ServerName = "Foo"; + private const string ByoDisplayName = "Foo - BYO"; + private const string ByoAppId = "11111111-1111-1111-1111-111111111111"; + private const string ByoSpObjectId = "22222222-2222-2222-2222-222222222222"; + private const string BlueprintId = "33333333-3333-3333-3333-333333333333"; + private const string AgentSpId = "44444444-4444-4444-4444-444444444444"; + private const string OtherResourceSpId = "55555555-5555-5555-5555-555555555555"; + + private readonly GraphApiService _graph = Substitute.For(); + private readonly AgentBlueprintService _blueprintService; + private readonly McpServerPermissionService _service; + + public McpServerPermissionServiceTests() + { + _blueprintService = Substitute.For( + Substitute.For>(), _graph); + _service = new McpServerPermissionService( + _graph, _blueprintService, Substitute.For>()); + } + + [Fact] + public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() + { + _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult(ByoAppId)); + _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(ByoSpObjectId)); + + var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + + result.Should().NotBeNull(); + result!.DisplayName.Should().Be(ByoDisplayName, + because: "the BYO application for an MCP server is registered as '{name} - BYO'; any other name would resolve the wrong Entra resource"); + result.AppId.Should().Be(ByoAppId); + result.ServicePrincipalObjectId.Should().Be(ByoSpObjectId, + because: "the service principal object ID, not the app ID, is the resourceId of an oauth2PermissionGrant"); + } + + [Fact] + public async Task ResolveServerResourceAsync_ReturnsNull_WhenApplicationNotFound() + { + _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult(null)); + + var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + + result.Should().BeNull(); + await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + } + + [Fact] + public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMissing() + { + _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult(ByoAppId)); + _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(null)); + + var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + + result.Should().BeNull( + because: "a grant cannot be created without a resource service principal, so the caller must fail rather than proceed"); + } + + [Fact] + public async Task GetAgentInstanceStatusesAsync_FlagsInstancesWithAndWithoutTheScope() + { + _blueprintService.GetAgentInstancesForBlueprintAsync(TenantId, BlueprintId, Arg.Any()) + .Returns(Task.FromResult>( + [ + new AgentInstanceInfo { IdentitySpId = "sp-granted", DisplayName = "Granted" }, + new AgentInstanceInfo { IdentitySpId = "sp-missing", DisplayName = "Missing" }, + ])); + + _graph.GetOauth2PermissionGrantsAsync(TenantId, "sp-granted", Arg.Any()) + .Returns(Task.FromResult(new List<(string, string, string)> + { + (ByoSpObjectId, $"User.Read {McpConstants.V2ScopeValue}", "AllPrincipals"), + })); + _graph.GetOauth2PermissionGrantsAsync(TenantId, "sp-missing", Arg.Any()) + .Returns(Task.FromResult(new List<(string, string, string)> + { + (ByoSpObjectId, "User.Read", "AllPrincipals"), + })); + + var statuses = await _service.GetAgentInstanceStatusesAsync(TenantId, BlueprintId, ByoSpObjectId); + + statuses.Should().HaveCount(2); + statuses.Single(s => s.ServicePrincipalObjectId == "sp-granted").HasScope.Should().BeTrue(); + statuses.Single(s => s.ServicePrincipalObjectId == "sp-missing").HasScope.Should().BeFalse(); + } + + [Fact] + public async Task GetAgentInstanceStatusesAsync_IgnoresGrantsAgainstOtherResources() + { + _blueprintService.GetAgentInstancesForBlueprintAsync(TenantId, BlueprintId, Arg.Any()) + .Returns(Task.FromResult>( + [ + new AgentInstanceInfo { IdentitySpId = AgentSpId, DisplayName = "Agent" }, + ])); + + _graph.GetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) + .Returns(Task.FromResult(new List<(string, string, string)> + { + (OtherResourceSpId, McpConstants.V2ScopeValue, "AllPrincipals"), + })); + + var statuses = await _service.GetAgentInstanceStatusesAsync(TenantId, BlueprintId, ByoSpObjectId); + + statuses.Single().HasScope.Should().BeFalse( + because: "the same scope name is exposed by every MCP server, so a grant only counts when its resourceId is this server's service principal"); + } + + [Fact] + public async Task GetAgentInstanceStatusesAsync_DoesNotMatchScopeByPrefix() + { + _blueprintService.GetAgentInstancesForBlueprintAsync(TenantId, BlueprintId, Arg.Any()) + .Returns(Task.FromResult>( + [ + new AgentInstanceInfo { IdentitySpId = AgentSpId, DisplayName = "Agent" }, + ])); + + _graph.GetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) + .Returns(Task.FromResult(new List<(string, string, string)> + { + (ByoSpObjectId, $"{McpConstants.V2ScopeValue}.Extra", "AllPrincipals"), + })); + + var statuses = await _service.GetAgentInstanceStatusesAsync(TenantId, BlueprintId, ByoSpObjectId); + + statuses.Single().HasScope.Should().BeFalse( + because: "grant scope strings are space-delimited, so matching must be per-token; a substring match would report a scope the agent does not actually hold"); + } + + [Fact] + public async Task GrantServerScopeAsync_RequestsTheMcpServerScope() + { + _graph.CreateOrUpdateOauth2PermissionGrantAsync( + TenantId, AgentSpId, ByoSpObjectId, + Arg.Is>(s => s.SequenceEqual(new[] { McpConstants.V2ScopeValue })), + Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(true)); + + var granted = await _service.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId); + + granted.Should().BeTrue(); + } + + [Fact] + public async Task GrantServerScopeAsync_ReturnsFalse_WhenGraphRejectsTheGrant() + { + _graph.CreateOrUpdateOauth2PermissionGrantAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(false)); + + var granted = await _service.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId); + + granted.Should().BeFalse(); + } + + [Theory] + [InlineData("Foo", "Foo - BYO")] + [InlineData(" Foo ", "Foo - BYO")] + [InlineData("ext_MyServer", "ext_MyServer - BYO")] + public void BuildByoAppDisplayName_AppendsTheByoSuffix(string serverName, string expected) + { + McpConstants.BuildByoAppDisplayName(serverName).Should().Be(expected, + because: "the CLI must derive the same display name the BYO registration flow created, or the resource lookup fails"); + } +} From 9f1a56d4eaccff51b540f1be2db237f64bccac45 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Mon, 21 Sep 2026 10:36:32 -0700 Subject: [PATCH 02/20] Report sign-in failure distinctly from a missing BYO application A failed Graph sign-in made FindApplicationByDisplayNameAsync return null, which was reported as "No Entra application named ' - BYO' was found" and pointed the user at creating an app that may already exist. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/McpServerPermissionService.cs | 8 ++++++++ .../Services/McpServerPermissionServiceTests.cs | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index 261ccc97..18ab8042 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -42,6 +42,14 @@ public McpServerPermissionService( var displayName = McpConstants.BuildByoAppDisplayName(serverName); + // A failed sign-in also yields a null lookup result, which would otherwise be reported as + // "application not found" and send the user off to create an app that may already exist. + if (string.IsNullOrWhiteSpace(await _graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct))) + { + _logger.LogError("Could not sign in to tenant {TenantId}, so '{DisplayName}' could not be looked up.", tenantId, displayName); + return null; + } + var appId = await _graphApiService.FindApplicationByDisplayNameAsync(tenantId, displayName, ct); if (string.IsNullOrWhiteSpace(appId)) { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index fc1785a7..f91c5f09 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -36,6 +36,21 @@ public McpServerPermissionServiceTests() Substitute.For>(), _graph); _service = new McpServerPermissionService( _graph, _blueprintService, Substitute.For>()); + _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + } + + [Fact] + public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimingTheAppIsMissing() + { + _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + + result.Should().BeNull(); + await _graph.DidNotReceive().FindApplicationByDisplayNameAsync( + Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] From 1e81288d087c1718568be36822bef4fea036e517 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Mon, 21 Sep 2026 11:51:23 -0700 Subject: [PATCH 03/20] Add blueprint discovery and make device code sign-in usable Adds 'develop-mcp list-agent-blueprints', which lists Microsoft's first-party agent blueprint names alongside their IDs. Users had no way to find the GUID that --agent-blueprint-id requires. The invalid-GUID error and the option help both point at the new command, so the hint appears at the moment the user is stuck. Also makes --device-code work on the two MCP permission commands: - Connect-MgGraph cannot render a device code prompt from a child process with redirected I/O, so it silently produced no context. Device code now runs in-process via MSAL as the Graph command-line app, which is preauthorized for Graph delegated scopes (the Azure PowerShell app is rejected with AADSTS65002). - Device code credentials were rebuilt per token request with no AuthenticationRecord, so the persisted cache could be written but never silently read, prompting on every call. They now route through MsalBrowserCredential, which attempts silent acquisition first. - The MCP server lookup no longer acquires a pre-flight token on the success path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + .../AgentBlueprintCatalogSubcommand.cs | 76 +++++++++++++ .../Commands/DevelopMcpCommand.cs | 4 + .../McpServerPermissionsSubcommands.cs | 26 ++++- .../Constants/AgentBlueprintCatalog.cs | 48 ++++++++ .../Constants/AuthenticationConstants.cs | 7 ++ .../Services/AuthenticationService.cs | 30 +---- .../Services/GraphApiService.cs | 10 +- .../Internal/MicrosoftGraphTokenProvider.cs | 31 ++++- .../Services/McpServerPermissionService.cs | 26 +++-- .../Services/MsalBrowserCredential.cs | 14 ++- .../AgentBlueprintCatalogSubcommandTests.cs | 107 ++++++++++++++++++ .../Commands/DevelopMcpCommandTests.cs | 7 +- .../McpServerPermissionsSubcommandsTests.cs | 25 ++++ .../McpServerPermissionServiceTests.cs | 18 ++- .../MicrosoftGraphTokenProviderTests.cs | 40 ++++++- .../Services/MsalBrowserCredentialTests.cs | 20 ++++ 17 files changed, 442 insertions(+), 50 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b149a8..9dc78df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `a365 develop-mcp list-agent-blueprints` lists Microsoft's first-party agent blueprint names and IDs, so you can find the ID to pass to `--agent-blueprint-id` without looking it up elsewhere. +- `--device-code` option on `a365 develop-mcp list-agent-instances` and `grant-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. - `a365 develop-mcp list-agent-instances` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and offers to grant it, while `a365 develop-mcp grant-mcpserver-permissions` grants that permission to a single agent identity. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. @@ -60,6 +62,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed. - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs new file mode 100644 index 00000000..edc63b7f --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands; + +/// +/// Subcommand that lists well-known first-party agent blueprint IDs, so callers can discover the +/// ID to pass to the commands that require one. +/// +public static class AgentBlueprintCatalogSubcommand +{ + private const string CommandName = "list-agent-blueprints"; + + /// + /// Creates the list-agent-blueprints subcommand. + /// + public static Command CreateCommand(ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + var command = new Command(CommandName, + "List well-known Microsoft first-party agent blueprint IDs and their names."); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing"); + command.AddOption(dryRunOption); + + command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); + + command.SetHandler((InvocationContext context) => + { + if (context.ParseResult.GetValueForOption(dryRunOption)) + { + logger.LogInformation("[DRY RUN] Would list first-party agent blueprint IDs and names"); + context.ExitCode = 0; + return; + } + + var blueprints = AgentBlueprintCatalog.FirstPartyBlueprints; + + if (blueprints.Count == 0) + { + logger.LogWarning("No first-party agent blueprints are registered in this CLI version."); + context.ExitCode = 1; + return; + } + + // Pad to the longest name so IDs line up and stay easy to copy. + var nameWidth = blueprints.Max(b => b.DisplayName.Length); + + logger.LogInformation("First-party agent blueprints:"); + logger.LogInformation(""); + + foreach (var blueprint in blueprints) + { + logger.LogInformation(" {Name} {Id}", blueprint.DisplayName.PadRight(nameWidth), blueprint.BlueprintId); + } + + logger.LogInformation(""); + logger.LogInformation("Pass an ID with --agent-blueprint-id, for example:"); + logger.LogInformation( + " a365 develop-mcp list-agent-instances --agent-blueprint-id {Id} --mcp-server-name ", + blueprints[0].BlueprintId); + + context.ExitCode = 0; + }); + + return command; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 819df8e6..958f1809 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -41,6 +41,10 @@ public static Command CreateCommand( developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateRegisterExternalMcpServerSubcommand(logger, toolingService, graphApiService)); + // Registered unconditionally: the catalog is static, so blueprint discovery stays available + // even when the permission service is not wired up. + developMcpCommand.AddCommand(AgentBlueprintCatalogSubcommand.CreateCommand(logger)); + if (mcpServerPermissionService is not null) { developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(logger, mcpServerPermissionService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index d8742698..a9c1aeb6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -34,7 +34,8 @@ public static Command CreateListAgentInstancesSubcommand( var blueprintIdOption = new Option( "--agent-blueprint-id", - description: "Agent blueprint ID (GUID) whose agent instances should be checked.") + description: "Agent blueprint ID (GUID) whose agent instances should be checked. " + + "Run 'a365 develop-mcp list-agent-blueprints' to see first-party blueprint IDs.") { IsRequired = true, }; @@ -54,10 +55,15 @@ public static Command CreateListAgentInstancesSubcommand( ["--yes", "-y"], description: "Grant the missing permission to every listed agent instance without prompting."); + var deviceCodeOption = new Option( + "--device-code", + description: "Use device code authentication instead of the interactive browser flow (the WAM broker on Windows). Use when WAM cannot show a sign-in dialog, such as an embedded or remote terminal. Opens https://microsoft.com/devicelogin in your browser."); + command.AddOption(blueprintIdOption); command.AddOption(serverNameOption); command.AddOption(tenantIdOption); command.AddOption(yesOption); + command.AddOption(deviceCodeOption); command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); command.SetHandler(async (InvocationContext context) => @@ -68,7 +74,10 @@ public static Command CreateListAgentInstancesSubcommand( var grantAll = context.ParseResult.GetValueForOption(yesOption); var ct = context.GetCancellationToken(); - if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out var blueprintId)) + permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(deviceCodeOption); + + if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out var blueprintId, + hint: "Run 'a365 develop-mcp list-agent-blueprints' to see first-party blueprint IDs.")) { context.ExitCode = 1; return; @@ -177,9 +186,14 @@ public static Command CreateGrantPermissionsSubcommand( "--tenant-id", description: "Azure AD tenant ID. Defaults to the current Azure CLI context."); + var grantDeviceCodeOption = new Option( + "--device-code", + description: "Use device code authentication instead of the interactive browser flow (the WAM broker on Windows). Use when WAM cannot show a sign-in dialog, such as an embedded or remote terminal. Opens https://microsoft.com/devicelogin in your browser."); + command.AddOption(agentSpIdOption); command.AddOption(serverNameOption); command.AddOption(tenantIdOption); + command.AddOption(grantDeviceCodeOption); command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); command.SetHandler(async (InvocationContext context) => @@ -189,6 +203,8 @@ public static Command CreateGrantPermissionsSubcommand( var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); var ct = context.GetCancellationToken(); + permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(grantDeviceCodeOption); + if (!TryValidateGuid(agentSpIdRaw, "--agent-serviceprincipal-id", logger, out var agentSpId)) { context.ExitCode = 1; @@ -342,11 +358,15 @@ private static async Task GrantToSelectedAsync( return failures; } - private static bool TryValidateGuid(string? value, string optionName, ILogger logger, out string normalized) + private static bool TryValidateGuid(string? value, string optionName, ILogger logger, out string normalized, string? hint = null) { if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value.Trim(), out var guid)) { logger.LogError("{OptionName} must be a GUID.", optionName); + if (hint is not null) + { + logger.LogError("{Hint}", hint); + } normalized = string.Empty; return false; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs new file mode 100644 index 00000000..d2868b50 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Constants; + +/// +/// Well-known Microsoft first-party agent blueprints, so callers can find a blueprint ID by name +/// instead of having to know the GUID. Third-party and tenant-specific blueprints are not listed. +/// +public static class AgentBlueprintCatalog +{ + /// + /// A first-party agent blueprint published by Microsoft. + /// + /// The blueprint ID (GUID) passed to --agent-blueprint-id. + /// The product name shown to users. + public sealed record KnownBlueprint(string BlueprintId, string DisplayName); + + /// + /// First-party blueprints, ordered by display name for stable output. + /// + public static readonly IReadOnlyList FirstPartyBlueprints = + [ + new("eae28989-4f01-479b-8072-22902e554780", "Sales Development Agent"), + ]; + + /// + /// Returns the display name for a known first-party blueprint, or null when the ID is not + /// first-party. A null result is expected for tenant-specific blueprints and is not an error. + /// + public static string? TryGetDisplayName(string? blueprintId) + { + if (string.IsNullOrWhiteSpace(blueprintId)) + { + return null; + } + + foreach (var blueprint in FirstPartyBlueprints) + { + if (string.Equals(blueprint.BlueprintId, blueprintId.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return blueprint.DisplayName; + } + } + + return null; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index 5641dcfc..8b2d8353 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -16,6 +16,13 @@ public static class AuthenticationConstants public const string PowershellClientId = "1950a258-227b-4e31-a9cf-717495945fc2"; + /// + /// Microsoft Graph Command Line Tools public client ID - the app Connect-MgGraph authenticates as. + /// Preauthorized for Microsoft Graph delegated scopes, unlike , + /// which Graph rejects with AADSTS65002 for scopes it is not preauthorized against. + /// + public const string GraphPowershellClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; + /// /// Common tenant ID for multi-tenant authentication /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 878d50ec..180110f7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -546,36 +546,16 @@ protected virtual TokenCredential CreateBrowserCredential(string clientId, strin => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, loginHint: loginHint, forceRefresh: forceRefresh); /// - /// Creates a DeviceCodeCredential configured for interactive device code authentication. + /// Creates a credential configured for interactive device code authentication. /// This flow works in all environments including SSH, remote sessions, and platforms where /// browser-based authentication is unavailable. /// Protected virtual to allow substitution in tests. /// protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) - { - return new DeviceCodeCredential(new DeviceCodeCredentialOptions - { - TenantId = tenantId, - ClientId = clientId, - AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, - TokenCachePersistenceOptions = new TokenCachePersistenceOptions - { - Name = AuthenticationConstants.ApplicationName - }, - DeviceCodeCallback = (code, cancellation) => - { - _logger.LogInformation(""); - _logger.LogInformation("=========================================================================="); - _logger.LogInformation("To sign in, use a web browser to open the page:"); - _logger.LogInformation(" {VerificationUri}", code.VerificationUri); - _logger.LogInformation(""); - _logger.LogInformation("And enter the code: {UserCode}", code.UserCode); - _logger.LogInformation("=========================================================================="); - _logger.LogInformation(""); - return Task.CompletedTask; - } - }); - } + // Routed through MsalBrowserCredential so device code shares the OS-protected MSAL + // persistent cache and acquires silently when an account is already signed in. A bare + // DeviceCodeCredential has no AuthenticationRecord, so each new instance re-prompts. + => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, useWam: false, useDeviceCode: true); /// /// Resolves the login hint (UPN) from the OS-protected MSAL persistent cache by reading the diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 23fc92d8..3e31ee04 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -54,6 +54,12 @@ public class GraphApiService /// public string? CustomClientAppId { get; set; } + /// + /// Routes interactive sign-in through the device code flow instead of the WAM broker. + /// Set when the caller knows WAM cannot present a dialog (headless or embedded terminals). + /// + public bool UseDeviceCodeAuthentication { get; set; } + /// /// Override the Microsoft Graph base URL for sovereign / government cloud tenants. /// Defaults to (commercial cloud). @@ -170,7 +176,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor { var resource = GraphApiConstants.GetResource(_graphBaseUrl); var loginHint = await _loginHintResolver(); - var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, userId: loginHint, ct: ct); + var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, useInteractiveBrowser: !UseDeviceCodeAuthentication, userId: loginHint, ct: ct); if (!string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("Graph API access token acquired successfully"); @@ -269,7 +275,7 @@ private async Task EnsureGraphHeadersAsync( CustomClientAppId, string.Join(", ", effectiveScopes)); var loginHint = await ResolveLoginHintAsync(); token = await _tokenProvider.GetMgGraphAccessTokenAsync( - tenantId, effectiveScopes, false, CustomClientAppId, ct, loginHint, forceRefresh); + tenantId, effectiveScopes, UseDeviceCodeAuthentication, CustomClientAppId, ct, loginHint, forceRefresh); if (string.IsNullOrWhiteSpace(token)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index ce490f65..b7ec6add 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -149,7 +149,7 @@ public MicrosoftGraphTokenProvider( // and WAM on Windows authenticates via the OS broker (no browser, CAP-compliant). var token = MsalTokenAcquirerOverride != null ? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct) - : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh); + : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh, useDeviceCode); // Fall back to PowerShell Connect-MgGraph if MSAL is unavailable (e.g. no clientAppId) // or fails for any reason. @@ -383,15 +383,31 @@ private async Task ExecuteWithFallbackAsync( /// cross-user token contamination on shared machines. /// Returns null if clientAppId is unavailable; caller falls back to PowerShell Connect-MgGraph. /// + /// + /// Selects the client app used for in-process MSAL acquisition. Device code has no usable + /// PowerShell fallback, so it resolves to the Graph command-line app that Connect-MgGraph + /// would have authenticated as; otherwise a missing client app keeps the subprocess path. + /// + internal static string? ResolveMsalClientAppId(string? clientAppId, bool useDeviceCode) + => string.IsNullOrWhiteSpace(clientAppId) && useDeviceCode + ? AuthenticationConstants.GraphPowershellClientId + : clientAppId; + private async Task AcquireGraphTokenViaMsalAsync( string tenantId, string[] scopes, string? clientAppId, CancellationToken ct, string? loginHint = null, - bool forceRefresh = false) + bool forceRefresh = false, + bool useDeviceCode = false) { - if (string.IsNullOrWhiteSpace(clientAppId)) + // Device code must run in-process: Connect-MgGraph cannot render its prompt from a + // child process with redirected I/O, so fall back to the well-known PowerShell client + // app rather than the unusable subprocess path. + var effectiveClientAppId = ResolveMsalClientAppId(clientAppId, useDeviceCode); + + if (string.IsNullOrWhiteSpace(effectiveClientAppId)) { _logger.LogDebug("MSAL token acquisition skipped: no client app ID configured. Falling back to PowerShell Connect-MgGraph."); return null; @@ -406,7 +422,14 @@ private async Task ExecuteWithFallbackAsync( _logger.LogDebug("Acquiring Graph token via MSAL for scopes: {Scopes}", string.Join(", ", fullScopes)); - var msalCredential = new MsalBrowserCredential(clientAppId, tenantId, logger: _logger, loginHint: loginHint, forceRefresh: forceRefresh); + var msalCredential = new MsalBrowserCredential( + effectiveClientAppId, + tenantId, + logger: _logger, + useWam: !useDeviceCode, + loginHint: loginHint, + forceRefresh: forceRefresh, + useDeviceCode: useDeviceCode); var tokenResult = await msalCredential.GetTokenAsync(new TokenRequestContext(fullScopes), ct); if (string.IsNullOrWhiteSpace(tokenResult.Token)) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index 18ab8042..eb78a379 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -17,6 +17,16 @@ public class McpServerPermissionService private readonly AgentBlueprintService _blueprintService; private readonly ILogger _logger; + /// + /// Routes sign-in through the device code flow instead of the WAM broker, for terminals + /// where WAM cannot present a dialog. + /// + public virtual bool UseDeviceCodeAuthentication + { + get => _graphApiService.UseDeviceCodeAuthentication; + set => _graphApiService.UseDeviceCodeAuthentication = value; + } + public McpServerPermissionService( GraphApiService graphApiService, AgentBlueprintService blueprintService, @@ -42,17 +52,17 @@ public McpServerPermissionService( var displayName = McpConstants.BuildByoAppDisplayName(serverName); - // A failed sign-in also yields a null lookup result, which would otherwise be reported as - // "application not found" and send the user off to create an app that may already exist. - if (string.IsNullOrWhiteSpace(await _graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct))) - { - _logger.LogError("Could not sign in to tenant {TenantId}, so '{DisplayName}' could not be looked up.", tenantId, displayName); - return null; - } - var appId = await _graphApiService.FindApplicationByDisplayNameAsync(tenantId, displayName, ct); if (string.IsNullOrWhiteSpace(appId)) { + // A failed sign-in also yields a null lookup result, which would otherwise be reported + // as "application not found" and send the user off to create an app that may exist. + if (string.IsNullOrWhiteSpace(await _graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct))) + { + _logger.LogError("Could not sign in to tenant {TenantId}, so '{DisplayName}' could not be looked up.", tenantId, displayName); + return null; + } + _logger.LogError("No Entra application named '{DisplayName}' was found in tenant {TenantId}.", displayName, tenantId); return null; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index 4665d9ab..ba771d88 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -210,7 +210,8 @@ public MsalBrowserCredential( bool useWam = true, string? authority = null, string? loginHint = null, - bool forceRefresh = false) + bool forceRefresh = false, + bool useDeviceCode = false) { if (string.IsNullOrWhiteSpace(clientId)) { @@ -234,7 +235,8 @@ public MsalBrowserCredential( _authenticationMode = SelectAuthenticationMode( clientId, useWam, - OperatingSystem.IsWindows()); + OperatingSystem.IsWindows(), + useDeviceCode); if (OperatingSystem.IsWindows() && _authenticationMode == InteractiveAuthenticationMode.Wam) @@ -329,8 +331,14 @@ internal MsalBrowserCredential( internal static InteractiveAuthenticationMode SelectAuthenticationMode( string clientId, bool useWam, - bool isWindows) + bool isWindows, + bool useDeviceCode = false) { + // An explicit device code request wins: the caller knows no interactive dialog or browser + // can be presented, which WAM and the system browser would both assume. + if (useDeviceCode) + return InteractiveAuthenticationMode.DeviceCode; + if (useWam && isWindows) return InteractiveAuthenticationMode.Wam; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs new file mode 100644 index 00000000..3cef7a9a --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +public class AgentBlueprintCatalogSubcommandTests +{ + private readonly ILogger _logger = Substitute.For(); + + [Fact] + public async Task ListAgentBlueprints_Succeeds() + { + var command = AgentBlueprintCatalogSubcommand.CreateCommand(_logger); + + var exitCode = await command.InvokeAsync([]); + + exitCode.Should().Be(0, + because: "listing the static catalog takes no input and cannot fail, so it must not " + + "report an error exit code that a script would treat as a failure"); + } + + [Fact] + public void Catalog_IsNotEmpty() + { + AgentBlueprintCatalog.FirstPartyBlueprints.Should().NotBeEmpty( + because: "the discovery command exists solely to surface these IDs - an empty catalog " + + "would make it useless and is the failure branch the command guards against"); + } + + [Fact] + public void Catalog_EveryBlueprintIdIsAGuid() + { + foreach (var blueprint in AgentBlueprintCatalog.FirstPartyBlueprints) + { + Guid.TryParse(blueprint.BlueprintId, out _).Should().BeTrue( + because: $"'{blueprint.DisplayName}' is copied straight into --agent-blueprint-id, " + + "which rejects anything that is not a GUID - a malformed entry would ship " + + "a value that can never work"); + } + } + + [Fact] + public void Catalog_BlueprintIdsAreUnique() + { + var ids = AgentBlueprintCatalog.FirstPartyBlueprints + .Select(b => b.BlueprintId.ToLowerInvariant()); + + ids.Should().OnlyHaveUniqueItems( + because: "a duplicated ID under two names would make the listing ambiguous about which " + + "blueprint a user is targeting"); + } + + [Fact] + public void Catalog_NamesAreNotBlank() + { + foreach (var blueprint in AgentBlueprintCatalog.FirstPartyBlueprints) + { + blueprint.DisplayName.Should().NotBeNullOrWhiteSpace( + because: "the name is the only thing that makes the ID discoverable"); + } + } + + [Fact] + public void TryGetDisplayName_KnownId_ReturnsName() + { + var known = AgentBlueprintCatalog.FirstPartyBlueprints[0]; + + AgentBlueprintCatalog.TryGetDisplayName(known.BlueprintId) + .Should().Be(known.DisplayName); + } + + [Fact] + public void TryGetDisplayName_IsCaseAndWhitespaceInsensitive() + { + var known = AgentBlueprintCatalog.FirstPartyBlueprints[0]; + + AgentBlueprintCatalog.TryGetDisplayName($" {known.BlueprintId.ToUpperInvariant()} ") + .Should().Be(known.DisplayName, + because: "GUIDs pasted from portals and docs vary in casing and carry stray " + + "whitespace, and none of that changes which blueprint is meant"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-guid")] + [InlineData("11111111-2222-3333-4444-555555555555")] + public void TryGetDisplayName_UnknownOrInvalidId_ReturnsNull(string? blueprintId) + { + AgentBlueprintCatalog.TryGetDisplayName(blueprintId) + .Should().BeNull( + because: "tenant-specific blueprints are legitimate and simply absent from the " + + "first-party catalog, so an unknown ID must be reported as unknown rather " + + "than throwing or guessing a name"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs index 32d7c3f6..c97af2bb 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -42,7 +42,9 @@ public void CreateCommand_HasAllExpectedSubcommands() var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); // Assert - command.Subcommands.Should().HaveCount(5); + command.Subcommands.Should().HaveCount(6, + because: "blueprint discovery (list-agent-blueprints) is registered unconditionally so " + + "users can look up a blueprint ID without the permission service being wired up"); var subcommandNames = command.Subcommands.Select(sc => sc.Name).ToList(); subcommandNames.Should().Contain(new[] @@ -51,7 +53,8 @@ public void CreateCommand_HasAllExpectedSubcommands() "list-servers", "publish", "unpublish", - "register-external-mcp-server" + "register-external-mcp-server", + "list-agent-blueprints" }); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 9878abfc..181d0b6f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -185,6 +185,31 @@ public async Task ListAgentInstances_WithYes_FailedGrantExitsWithOne() because: "a failed grant must surface as a non-zero exit code so automation does not treat the agent as provisioned"); } + [Fact] + public async Task ListAgentInstances_DeviceCodeFlag_RoutesSignInThroughDeviceCode() + { + SetupResolvedResource(); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: true)); + + await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId, "--device-code"]); + + _permissionService.Received().UseDeviceCodeAuthentication = true; + } + + [Fact] + public async Task GrantPermissions_WithoutDeviceCodeFlag_UsesDefaultInteractiveSignIn() + { + SetupResolvedResource(); + _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) + .Returns(Task.FromResult(true)); + + await GrantCommand().InvokeAsync( + ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + _permissionService.Received().UseDeviceCodeAuthentication = false; + } + [Fact] public async Task GrantPermissions_NonGuidAgentServicePrincipalId_ExitsWithOne() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index f91c5f09..0269ec67 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -45,12 +45,26 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin { _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); + _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult(null)); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); result.Should().BeNull(); - await _graph.DidNotReceive().FindApplicationByDisplayNameAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ResolveServerResourceAsync_DoesNotAcquireATokenOnTheSuccessPath() + { + _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult(ByoAppId)); + _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(ByoSpObjectId)); + + await _service.ResolveServerResourceAsync(TenantId, ServerName); + + await _graph.DidNotReceive().GetGraphAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 33a834d9..413fba62 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -65,9 +65,47 @@ await _executor.Received(1).ExecuteWithStreamingAsync( Arg.Any()); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveMsalClientAppId_DeviceCodeWithoutClientApp_UsesWellKnownPowerShellApp(string? clientAppId) + { + var resolved = MicrosoftGraphTokenProvider.ResolveMsalClientAppId(clientAppId, useDeviceCode: true); + + resolved.Should().Be( + AuthenticationConstants.GraphPowershellClientId, + because: "device code has no working PowerShell fallback - Connect-MgGraph cannot render its " + + "prompt from a child process with redirected I/O - so MSAL must run in-process as the " + + "same Graph command-line app Connect-MgGraph uses, which is preauthorized for Graph " + + "delegated scopes (AADSTS65002 rejects apps that are not)"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveMsalClientAppId_WithoutDeviceCodeOrClientApp_KeepsSubprocessPath(string? clientAppId) + { + var resolved = MicrosoftGraphTokenProvider.ResolveMsalClientAppId(clientAppId, useDeviceCode: false); + + resolved.Should().BeNullOrWhiteSpace( + because: "without device code the PowerShell Connect-MgGraph fallback remains usable, so an " + + "unconfigured client app must not silently switch the browser flow to a different app"); + } + [Fact] - public async Task GetMgGraphAccessTokenAsync_WithoutClientAppId_OmitsClientIdParameter() + public void ResolveMsalClientAppId_WithConfiguredClientApp_IsNeverOverridden() { + const string configured = "11111111-2222-3333-4444-555555555555"; + + MicrosoftGraphTokenProvider.ResolveMsalClientAppId(configured, useDeviceCode: true) + .Should().Be(configured, + because: "the custom app carries the optional claims callers depend on, so device code must " + + "not substitute the well-known PowerShell app for it"); + } + + [Fact] + public async Task GetMgGraphAccessTokenAsync_WithoutClientAppId_OmitsClientIdParameter() { // Arrange var tenantId = "12345678-1234-1234-1234-123456789abc"; var scopes = new[] { "User.Read" }; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MsalBrowserCredentialTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MsalBrowserCredentialTests.cs index 785114e6..09f0f6c6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MsalBrowserCredentialTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MsalBrowserCredentialTests.cs @@ -209,6 +209,26 @@ public void SelectAuthenticationMode_CustomAppWithoutWam_UsesSystemBrowser( mode); } + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void SelectAuthenticationMode_ExplicitDeviceCode_OverridesWamAndSystemBrowser( + bool useWam, + bool isWindows) + { + var mode = MsalBrowserCredential.SelectAuthenticationMode( + ValidClientId, + useWam, + isWindows, + useDeviceCode: true); + + mode.Should().Be( + MsalBrowserCredential.InteractiveAuthenticationMode.DeviceCode, + because: "an explicit device code request means no dialog or browser can be shown, which WAM and the system browser both require"); + } + [Fact] public void SelectAuthenticationMode_CustomAppOnWindowsWithWam_PreservesWam() { From 9c40f0f7cdf7e85f9d748d45b082ad3395c39e14 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Mon, 21 Sep 2026 11:57:19 -0700 Subject: [PATCH 04/20] Surface blueprint IDs in help and errors instead of a command Replaces the standalone list-agent-blueprints command with the two surfaces users already reach: the --agent-blueprint-id help text and the error shown when the option is missing or not a GUID. With a single first-party blueprint, a dedicated command asked users to run something else to learn a value the failing command could simply print. Both surfaces render from AgentBlueprintCatalog, so adding a blueprint updates help and error output together and neither can go stale. The service principal ID option deliberately does not list blueprints: it takes a tenant-specific object ID, so those values are never valid there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../AgentBlueprintCatalogSubcommand.cs | 76 ---------------- .../Commands/DevelopMcpCommand.cs | 4 - .../McpServerPermissionsSubcommands.cs | 19 ++-- .../Constants/AgentBlueprintCatalog.cs | 23 ++++- .../Commands/DevelopMcpCommandTests.cs | 7 +- .../McpServerPermissionsSubcommandsTests.cs | 46 ++++++++++ .../AgentBlueprintCatalogTests.cs} | 90 +++++++++++++------ 8 files changed, 146 insertions(+), 121 deletions(-) delete mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs rename src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/{Commands/AgentBlueprintCatalogSubcommandTests.cs => Constants/AgentBlueprintCatalogTests.cs} (50%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc78df2..3d58097e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added -- `a365 develop-mcp list-agent-blueprints` lists Microsoft's first-party agent blueprint names and IDs, so you can find the ID to pass to `--agent-blueprint-id` without looking it up elsewhere. +- `a365 develop-mcp list-agent-instances --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. - `--device-code` option on `a365 develop-mcp list-agent-instances` and `grant-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. - `a365 develop-mcp list-agent-instances` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and offers to grant it, while `a365 develop-mcp grant-mcpserver-permissions` grants that permission to a single agent identity. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs deleted file mode 100644 index edc63b7f..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/AgentBlueprintCatalogSubcommand.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.CommandLine; -using System.CommandLine.Invocation; -using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.A365.DevTools.Cli.Commands; - -/// -/// Subcommand that lists well-known first-party agent blueprint IDs, so callers can discover the -/// ID to pass to the commands that require one. -/// -public static class AgentBlueprintCatalogSubcommand -{ - private const string CommandName = "list-agent-blueprints"; - - /// - /// Creates the list-agent-blueprints subcommand. - /// - public static Command CreateCommand(ILogger logger) - { - ArgumentNullException.ThrowIfNull(logger); - - var command = new Command(CommandName, - "List well-known Microsoft first-party agent blueprint IDs and their names."); - - var dryRunOption = new Option( - name: "--dry-run", - description: "Show what would be done without executing"); - command.AddOption(dryRunOption); - - command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); - - command.SetHandler((InvocationContext context) => - { - if (context.ParseResult.GetValueForOption(dryRunOption)) - { - logger.LogInformation("[DRY RUN] Would list first-party agent blueprint IDs and names"); - context.ExitCode = 0; - return; - } - - var blueprints = AgentBlueprintCatalog.FirstPartyBlueprints; - - if (blueprints.Count == 0) - { - logger.LogWarning("No first-party agent blueprints are registered in this CLI version."); - context.ExitCode = 1; - return; - } - - // Pad to the longest name so IDs line up and stay easy to copy. - var nameWidth = blueprints.Max(b => b.DisplayName.Length); - - logger.LogInformation("First-party agent blueprints:"); - logger.LogInformation(""); - - foreach (var blueprint in blueprints) - { - logger.LogInformation(" {Name} {Id}", blueprint.DisplayName.PadRight(nameWidth), blueprint.BlueprintId); - } - - logger.LogInformation(""); - logger.LogInformation("Pass an ID with --agent-blueprint-id, for example:"); - logger.LogInformation( - " a365 develop-mcp list-agent-instances --agent-blueprint-id {Id} --mcp-server-name ", - blueprints[0].BlueprintId); - - context.ExitCode = 0; - }); - - return command; - } -} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 958f1809..819df8e6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -41,10 +41,6 @@ public static Command CreateCommand( developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateRegisterExternalMcpServerSubcommand(logger, toolingService, graphApiService)); - // Registered unconditionally: the catalog is static, so blueprint discovery stays available - // even when the permission service is not wired up. - developMcpCommand.AddCommand(AgentBlueprintCatalogSubcommand.CreateCommand(logger)); - if (mcpServerPermissionService is not null) { developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(logger, mcpServerPermissionService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index a9c1aeb6..af5628d8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -35,7 +35,7 @@ public static Command CreateListAgentInstancesSubcommand( var blueprintIdOption = new Option( "--agent-blueprint-id", description: "Agent blueprint ID (GUID) whose agent instances should be checked. " + - "Run 'a365 develop-mcp list-agent-blueprints' to see first-party blueprint IDs.") + $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}.") { IsRequired = true, }; @@ -77,7 +77,7 @@ public static Command CreateListAgentInstancesSubcommand( permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(deviceCodeOption); if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out var blueprintId, - hint: "Run 'a365 develop-mcp list-agent-blueprints' to see first-party blueprint IDs.")) + listBlueprints: true)) { context.ExitCode = 1; return; @@ -358,15 +358,24 @@ private static async Task GrantToSelectedAsync( return failures; } - private static bool TryValidateGuid(string? value, string optionName, ILogger logger, out string normalized, string? hint = null) + private static bool TryValidateGuid(string? value, string optionName, ILogger logger, out string normalized, bool listBlueprints = false) { if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value.Trim(), out var guid)) { logger.LogError("{OptionName} must be a GUID.", optionName); - if (hint is not null) + + // List the IDs here rather than pointing elsewhere: this is the moment the caller + // needs one, and a redirect costs them another command. + var lines = listBlueprints ? AgentBlueprintCatalog.FormatAsLines() : []; + if (lines.Count > 0) { - logger.LogError("{Hint}", hint); + logger.LogError("First-party blueprints:"); + foreach (var line in lines) + { + logger.LogError("{Blueprint}", line); + } } + normalized = string.Empty; return false; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs index d2868b50..53391e0d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. namespace Microsoft.Agents.A365.DevTools.Cli.Constants; - /// /// Well-known Microsoft first-party agent blueprints, so callers can find a blueprint ID by name /// instead of having to know the GUID. Third-party and tenant-specific blueprints are not listed. @@ -45,4 +44,26 @@ public sealed record KnownBlueprint(string BlueprintId, string DisplayName); return null; } + + /// + /// Formats the catalog as a single line for option help text. + /// + public static string FormatForHelp() => + string.Join("; ", FirstPartyBlueprints.Select(b => $"{b.DisplayName} ({b.BlueprintId})")); + + /// + /// Formats the catalog as indented, name-aligned lines for terminal output. + /// + public static IReadOnlyList FormatAsLines() + { + if (FirstPartyBlueprints.Count == 0) + { + return []; + } + + var nameWidth = FirstPartyBlueprints.Max(b => b.DisplayName.Length); + return FirstPartyBlueprints + .Select(b => $" {b.DisplayName.PadRight(nameWidth)} {b.BlueprintId}") + .ToList(); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs index c97af2bb..32d7c3f6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -42,9 +42,7 @@ public void CreateCommand_HasAllExpectedSubcommands() var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); // Assert - command.Subcommands.Should().HaveCount(6, - because: "blueprint discovery (list-agent-blueprints) is registered unconditionally so " + - "users can look up a blueprint ID without the permission service being wired up"); + command.Subcommands.Should().HaveCount(5); var subcommandNames = command.Subcommands.Select(sc => sc.Name).ToList(); subcommandNames.Should().Contain(new[] @@ -53,8 +51,7 @@ public void CreateCommand_HasAllExpectedSubcommands() "list-servers", "publish", "unpublish", - "register-external-mcp-server", - "list-agent-blueprints" + "register-external-mcp-server" }); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 181d0b6f..5d23c939 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -82,6 +82,52 @@ await _permissionService.DidNotReceive().ResolveServerResourceAsync( Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task ListAgentInstances_NonGuidBlueprintId_ListsFirstPartyBlueprintsInError() + { + var capturing = new CapturingLogger(); + var command = McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(capturing, _permissionService); + + await command.InvokeAsync( + ["--agent-blueprint-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + var output = string.Join("\n", capturing.Messages); + foreach (var blueprint in AgentBlueprintCatalog.FirstPartyBlueprints) + { + output.Should().Contain(blueprint.BlueprintId, + because: "a caller who does not know the blueprint ID hits this error first, so the " + + "IDs must be printed here rather than pointing at another command to run"); + output.Should().Contain(blueprint.DisplayName, + because: "the ID is only recognizable when shown next to the product name"); + } + } + + [Fact] + public async Task ListAgentInstances_NonGuidServicePrincipalId_DoesNotListBlueprints() + { + var capturing = new CapturingLogger(); + var command = McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(capturing, _permissionService); + + await command.InvokeAsync( + ["--agent-serviceprincipal-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + string.Join("\n", capturing.Messages).Should().NotContain("First-party blueprints", + because: "the agent service principal ID is a tenant-specific object ID, so listing " + + "blueprint IDs there would offer values that can never be valid for the option"); + } + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Messages.Add(formatter(state, exception)); + } + [Fact] public async Task ListAgentInstances_NonGuidTenantId_ExitsWithOne() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs similarity index 50% rename from src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs rename to src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs index 3cef7a9a..c92279d7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AgentBlueprintCatalogSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs @@ -1,40 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; +using System; using System.Linq; -using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; -namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Constants; -public class AgentBlueprintCatalogSubcommandTests +public class AgentBlueprintCatalogTests { - private readonly ILogger _logger = Substitute.For(); - - [Fact] - public async Task ListAgentBlueprints_Succeeds() - { - var command = AgentBlueprintCatalogSubcommand.CreateCommand(_logger); - - var exitCode = await command.InvokeAsync([]); - - exitCode.Should().Be(0, - because: "listing the static catalog takes no input and cannot fail, so it must not " + - "report an error exit code that a script would treat as a failure"); - } - [Fact] public void Catalog_IsNotEmpty() { AgentBlueprintCatalog.FirstPartyBlueprints.Should().NotBeEmpty( - because: "the discovery command exists solely to surface these IDs - an empty catalog " + - "would make it useless and is the failure branch the command guards against"); + because: "the catalog is the only source of blueprint IDs surfaced in option help and " + + "error output - if it empties, users lose every discovery path"); } [Fact] @@ -52,12 +34,11 @@ public void Catalog_EveryBlueprintIdIsAGuid() [Fact] public void Catalog_BlueprintIdsAreUnique() { - var ids = AgentBlueprintCatalog.FirstPartyBlueprints - .Select(b => b.BlueprintId.ToLowerInvariant()); - - ids.Should().OnlyHaveUniqueItems( - because: "a duplicated ID under two names would make the listing ambiguous about which " + - "blueprint a user is targeting"); + AgentBlueprintCatalog.FirstPartyBlueprints + .Select(b => b.BlueprintId.ToLowerInvariant()) + .Should().OnlyHaveUniqueItems( + because: "a duplicated ID under two names would make the listing ambiguous about " + + "which blueprint a user is targeting"); } [Fact] @@ -70,6 +51,57 @@ public void Catalog_NamesAreNotBlank() } } + [Fact] + public void FormatForHelp_ContainsEveryNameAndId() + { + var help = AgentBlueprintCatalog.FormatForHelp(); + + foreach (var blueprint in AgentBlueprintCatalog.FirstPartyBlueprints) + { + help.Should().Contain(blueprint.DisplayName); + help.Should().Contain(blueprint.BlueprintId, + because: "--help is a primary discovery surface, so every catalog entry must appear " + + "there rather than only in the catalog source"); + } + } + + [Fact] + public void FormatForHelp_IsSingleLine() + { + AgentBlueprintCatalog.FormatForHelp() + .Should().MatchRegex(@"^[^\r\n]*$", + because: "System.CommandLine wraps option descriptions itself, and embedded newlines " + + "break the alignment of the generated help output"); + } + + [Fact] + public void FormatAsLines_ContainsEveryNameAndId() + { + var lines = AgentBlueprintCatalog.FormatAsLines(); + + lines.Should().HaveCount(AgentBlueprintCatalog.FirstPartyBlueprints.Count); + + foreach (var blueprint in AgentBlueprintCatalog.FirstPartyBlueprints) + { + lines.Should().Contain(l => l.Contains(blueprint.BlueprintId) && l.Contains(blueprint.DisplayName), + because: "the invalid-ID error prints these lines so the user can copy an ID without " + + "running anything else"); + } + } + + [Fact] + public void FormatAsLines_AlignsIdsAcrossEntries() + { + var lines = AgentBlueprintCatalog.FormatAsLines(); + + var idColumns = lines + .Select(l => l.IndexOf(l.Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries).Last(), StringComparison.Ordinal)) + .Distinct(); + + idColumns.Should().HaveCount(1, + because: "names are padded to a common width so IDs form a single readable column"); + } + [Fact] public void TryGetDisplayName_KnownId_ReturnsName() { From 662abe4810f02cda20c3bde3855fdee8e24312a1 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Mon, 21 Sep 2026 12:02:02 -0700 Subject: [PATCH 05/20] Name the MCP server and scope explicitly in list-agent-instances output The summary labels said "Scope" and "Instances", which read ambiguously next to the MCP server line, and the grant prompt quoted the scope with no indication of which server it applied to. Name both in full. The prompt uses the server name the caller passed rather than the resolved ' - BYO' display name, so it echoes what they typed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Commands/McpServerPermissionsSubcommands.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index af5628d8..3a933e4f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -122,9 +122,9 @@ public static Command CreateListAgentInstancesSubcommand( } var missing = statuses.Where(s => !s.HasScope).ToList(); - logger.LogInformation("MCP server : {DisplayName} ({AppId})", resource.DisplayName, resource.AppId); - logger.LogInformation("Scope : {Scope}", McpConstants.V2ScopeValue); - logger.LogInformation("Instances : {Total} total, {Missing} missing the permission", statuses.Count, missing.Count); + logger.LogInformation("MCP server : {DisplayName} ({AppId})", resource.DisplayName, resource.AppId); + logger.LogInformation("MCP Server Scope : {Scope}", McpConstants.V2ScopeValue); + logger.LogInformation("Agent Instances : {Total} total, {Missing} missing the permission", statuses.Count, missing.Count); logger.LogInformation(""); if (missing.Count == 0) @@ -274,7 +274,7 @@ private static List ResolveSelection( return []; } - Console.Write($"Grant '{McpConstants.V2ScopeValue}' now? Enter 'all', a comma-separated list of numbers, or press Enter to skip: "); + Console.Write($"Grant MCP Server {resource.ServerName}'s scope {McpConstants.V2ScopeValue} now? Enter 'all', a comma-separated list of numbers, or press Enter to skip: "); var response = ConsoleHelper.ReadLineCancellable(ct)?.Trim(); if (string.IsNullOrWhiteSpace(response)) From ed9f68505064cb4bc5e6736e6b0d924f0416ef47 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 07:46:33 -0700 Subject: [PATCH 06/20] Fix defects found in end-to-end review - list-agent-instances exited 0 when a blueprint had no agent instances, so a script could not distinguish "nothing to do" from "wrong ID". - ResolveMsalClientAppId carried two stacked doc comments, leaving AcquireGraphTokenViaMsalAsync undocumented. - A comment named the wrong fallback client app. - AgentBlueprintCatalog.TryGetDisplayName had no production caller. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../McpServerPermissionsSubcommands.cs | 1 + .../Constants/AgentBlueprintCatalog.cs | 23 +----------- .../Internal/MicrosoftGraphTokenProvider.cs | 16 ++++----- .../McpServerPermissionsSubcommandsTests.cs | 15 ++++++++ .../Constants/AgentBlueprintCatalogTests.cs | 35 ------------------- 5 files changed, 25 insertions(+), 65 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 3a933e4f..1563c36d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -118,6 +118,7 @@ public static Command CreateListAgentInstancesSubcommand( if (statuses.Count == 0) { logger.LogWarning("No agent instances are linked to blueprint {BlueprintId}.", blueprintId); + context.ExitCode = 1; return; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs index 53391e0d..8602a62e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AgentBlueprintCatalog.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. namespace Microsoft.Agents.A365.DevTools.Cli.Constants; + /// /// Well-known Microsoft first-party agent blueprints, so callers can find a blueprint ID by name /// instead of having to know the GUID. Third-party and tenant-specific blueprints are not listed. @@ -23,28 +24,6 @@ public sealed record KnownBlueprint(string BlueprintId, string DisplayName); new("eae28989-4f01-479b-8072-22902e554780", "Sales Development Agent"), ]; - /// - /// Returns the display name for a known first-party blueprint, or null when the ID is not - /// first-party. A null result is expected for tenant-specific blueprints and is not an error. - /// - public static string? TryGetDisplayName(string? blueprintId) - { - if (string.IsNullOrWhiteSpace(blueprintId)) - { - return null; - } - - foreach (var blueprint in FirstPartyBlueprints) - { - if (string.Equals(blueprint.BlueprintId, blueprintId.Trim(), StringComparison.OrdinalIgnoreCase)) - { - return blueprint.DisplayName; - } - } - - return null; - } - /// /// Formats the catalog as a single line for option help text. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index b7ec6add..2d847a06 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -376,13 +376,6 @@ private async Task ExecuteWithFallbackAsync( return result; } - /// - /// Acquires a Microsoft Graph access token via MSAL.NET (primary authentication path). - /// On Windows uses WAM (no browser, CAP-compliant); on Linux/macOS uses device code. - /// Uses MsalBrowserCredential whose token cache is keyed by user identity, preventing - /// cross-user token contamination on shared machines. - /// Returns null if clientAppId is unavailable; caller falls back to PowerShell Connect-MgGraph. - /// /// /// Selects the client app used for in-process MSAL acquisition. Device code has no usable /// PowerShell fallback, so it resolves to the Graph command-line app that Connect-MgGraph @@ -393,6 +386,13 @@ private async Task ExecuteWithFallbackAsync( ? AuthenticationConstants.GraphPowershellClientId : clientAppId; + /// + /// Acquires a Microsoft Graph access token via MSAL.NET (primary authentication path). + /// On Windows uses WAM (no browser, CAP-compliant); on Linux/macOS uses device code. + /// Uses MsalBrowserCredential whose token cache is keyed by user identity, preventing + /// cross-user token contamination on shared machines. + /// Returns null if clientAppId is unavailable; caller falls back to PowerShell Connect-MgGraph. + /// private async Task AcquireGraphTokenViaMsalAsync( string tenantId, string[] scopes, @@ -403,7 +403,7 @@ private async Task ExecuteWithFallbackAsync( bool useDeviceCode = false) { // Device code must run in-process: Connect-MgGraph cannot render its prompt from a - // child process with redirected I/O, so fall back to the well-known PowerShell client + // child process with redirected I/O, so fall back to the Graph command-line client // app rather than the unusable subprocess path. var effectiveClientAppId = ResolveMsalClientAppId(clientAppId, useDeviceCode); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 5d23c939..b012fd56 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -58,6 +58,21 @@ private Command ListCommand() => private Command GrantCommand() => McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(_logger, _permissionService); + [Fact] + public async Task ListAgentInstances_NoInstancesLinkedToBlueprint_ExitsWithOne() + { + SetupResolvedResource(); + SetupInstances(); + + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "a blueprint with no agent instances means the caller passed an ID that cannot " + + "be acted on, and a script must be able to detect that rather than reading a " + + "success code for work that never happened"); + } + [Fact] public void ListAgentInstances_HasExpectedName() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs index c92279d7..35dcd133 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AgentBlueprintCatalogTests.cs @@ -101,39 +101,4 @@ public void FormatAsLines_AlignsIdsAcrossEntries() idColumns.Should().HaveCount(1, because: "names are padded to a common width so IDs form a single readable column"); } - - [Fact] - public void TryGetDisplayName_KnownId_ReturnsName() - { - var known = AgentBlueprintCatalog.FirstPartyBlueprints[0]; - - AgentBlueprintCatalog.TryGetDisplayName(known.BlueprintId) - .Should().Be(known.DisplayName); - } - - [Fact] - public void TryGetDisplayName_IsCaseAndWhitespaceInsensitive() - { - var known = AgentBlueprintCatalog.FirstPartyBlueprints[0]; - - AgentBlueprintCatalog.TryGetDisplayName($" {known.BlueprintId.ToUpperInvariant()} ") - .Should().Be(known.DisplayName, - because: "GUIDs pasted from portals and docs vary in casing and carry stray " + - "whitespace, and none of that changes which blueprint is meant"); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("not-a-guid")] - [InlineData("11111111-2222-3333-4444-555555555555")] - public void TryGetDisplayName_UnknownOrInvalidId_ReturnsNull(string? blueprintId) - { - AgentBlueprintCatalog.TryGetDisplayName(blueprintId) - .Should().BeNull( - because: "tenant-specific blueprints are legitimate and simply absent from the " + - "first-party catalog, so an unknown ID must be reported as unknown rather " + - "than throwing or guessing a name"); - } } From 323e3732bd9430450e6e065351d2f86bff0906f2 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 10:11:58 -0700 Subject: [PATCH 07/20] Rename list-agent-instances to grant-agent-mcpserver-permissions The command's primary effect is granting, not listing -- the listing is a confirmation step before the grant. Leads the description with the grant for the same reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 +++--- .../Commands/McpServerPermissionsSubcommands.cs | 8 ++++---- .../Commands/McpServerPermissionsSubcommandsTests.cs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d58097e..58626e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,9 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added -- `a365 develop-mcp list-agent-instances --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. -- `--device-code` option on `a365 develop-mcp list-agent-instances` and `grant-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. -- `a365 develop-mcp list-agent-instances` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and offers to grant it, while `a365 develop-mcp grant-mcpserver-permissions` grants that permission to a single agent identity. +- `a365 develop-mcp grant-agent-mcpserver-permissions --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. +- `--device-code` option on `a365 develop-mcp grant-agent-mcpserver-permissions` and `grant-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. +- `a365 develop-mcp grant-agent-mcpserver-permissions` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and offers to grant it, while `a365 develop-mcp grant-mcpserver-permissions` grants that permission to a single agent identity. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 1563c36d..88cad7bd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -17,11 +17,11 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; /// public static class McpServerPermissionsSubcommands { - private const string ListCommandName = "list-agent-instances"; + private const string ListCommandName = "grant-agent-mcpserver-permissions"; private const string GrantCommandName = "grant-mcpserver-permissions"; /// - /// Creates the list-agent-instances subcommand, which reports agent instances of a blueprint + /// Creates the grant-agent-mcpserver-permissions subcommand, which reports agent instances of a blueprint /// that are missing the MCP server scope and offers to grant it. /// public static Command CreateListAgentInstancesSubcommand( @@ -29,8 +29,8 @@ public static Command CreateListAgentInstancesSubcommand( McpServerPermissionService permissionService) { var command = new Command(ListCommandName, - $"List agent instances of a blueprint that are missing the '{McpConstants.V2ScopeValue}' permission for an MCP server.\n" + - "Offers to grant the permission interactively; prints the equivalent commands when input is redirected."); + $"Grant the '{McpConstants.V2ScopeValue}' permission for an MCP server to agent instances of a blueprint.\n" + + "Lists the instances missing it and prompts before granting; prints the equivalent commands when input is redirected."); var blueprintIdOption = new Option( "--agent-blueprint-id", diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index b012fd56..33122158 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -76,7 +76,7 @@ public async Task ListAgentInstances_NoInstancesLinkedToBlueprint_ExitsWithOne() [Fact] public void ListAgentInstances_HasExpectedName() { - ListCommand().Name.Should().Be("list-agent-instances"); + ListCommand().Name.Should().Be("grant-agent-mcpserver-permissions"); } [Fact] From 78ce0f363c8a16747af226204434436f7128b270 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 10:15:26 -0700 Subject: [PATCH 08/20] Fold single-identity grant into grant-agent-mcpserver-permissions One command now covers both targets: --agent-blueprint-id reviews every agent instance of a blueprint, --agent-serviceprincipal-id grants one identity directly. Exactly one must be supplied. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +- .../Commands/DevelopMcpCommand.cs | 3 +- .../McpServerPermissionsSubcommands.cs | 169 ++++++++---------- .../McpServerPermissionsSubcommandsTests.cs | 42 +++-- 4 files changed, 109 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58626e45..14689f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g ### Added - `a365 develop-mcp grant-agent-mcpserver-permissions --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. -- `--device-code` option on `a365 develop-mcp grant-agent-mcpserver-permissions` and `grant-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. -- `a365 develop-mcp grant-agent-mcpserver-permissions` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and offers to grant it, while `a365 develop-mcp grant-mcpserver-permissions` grants that permission to a single agent identity. +- `--device-code` option on `a365 develop-mcp grant-agent-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. +- `a365 develop-mcp grant-agent-mcpserver-permissions` grants agent identities the permission to call a BYO MCP server. Pass `--agent-blueprint-id` to review every agent instance of a blueprint that is missing the permission and be prompted before granting, or `--agent-serviceprincipal-id` to grant a single agent identity directly. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 819df8e6..6f52f30e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -43,8 +43,7 @@ public static Command CreateCommand( if (mcpServerPermissionService is not null) { - developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(logger, mcpServerPermissionService)); - developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(logger, mcpServerPermissionService)); + developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(logger, mcpServerPermissionService)); } if (evaluationPipelineService is not null) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 88cad7bd..b206bf9c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -17,28 +17,30 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; /// public static class McpServerPermissionsSubcommands { - private const string ListCommandName = "grant-agent-mcpserver-permissions"; - private const string GrantCommandName = "grant-mcpserver-permissions"; + private const string GrantAgentCommandName = "grant-agent-mcpserver-permissions"; /// - /// Creates the grant-agent-mcpserver-permissions subcommand, which reports agent instances of a blueprint - /// that are missing the MCP server scope and offers to grant it. + /// Creates the grant-agent-mcpserver-permissions subcommand. Given a blueprint it reports the + /// agent instances missing the MCP server scope and offers to grant it; given a single agent + /// identity it grants directly. /// - public static Command CreateListAgentInstancesSubcommand( + public static Command CreateGrantAgentPermissionsSubcommand( ILogger logger, McpServerPermissionService permissionService) { - var command = new Command(ListCommandName, - $"Grant the '{McpConstants.V2ScopeValue}' permission for an MCP server to agent instances of a blueprint.\n" + - "Lists the instances missing it and prompts before granting; prints the equivalent commands when input is redirected."); + var command = new Command(GrantAgentCommandName, + $"Grant the '{McpConstants.V2ScopeValue}' permission for an MCP server to agent identities.\n" + + "With --agent-blueprint-id, lists the blueprint's agent instances missing it and prompts before granting.\n" + + "With --agent-serviceprincipal-id, grants a single agent identity directly."); var blueprintIdOption = new Option( "--agent-blueprint-id", description: "Agent blueprint ID (GUID) whose agent instances should be checked. " + - $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}.") - { - IsRequired = true, - }; + $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}."); + + var agentSpIdOption = new Option( + "--agent-serviceprincipal-id", + description: "Object ID (GUID) of a single agent identity service principal to grant directly, instead of checking a whole blueprint."); var serverNameOption = new Option( ["--mcp-server-name", "-s"], @@ -60,6 +62,7 @@ public static Command CreateListAgentInstancesSubcommand( description: "Use device code authentication instead of the interactive browser flow (the WAM broker on Windows). Use when WAM cannot show a sign-in dialog, such as an embedded or remote terminal. Opens https://microsoft.com/devicelogin in your browser."); command.AddOption(blueprintIdOption); + command.AddOption(agentSpIdOption); command.AddOption(serverNameOption); command.AddOption(tenantIdOption); command.AddOption(yesOption); @@ -69,6 +72,7 @@ public static Command CreateListAgentInstancesSubcommand( command.SetHandler(async (InvocationContext context) => { var blueprintIdRaw = context.ParseResult.GetValueForOption(blueprintIdOption); + var agentSpIdRaw = context.ParseResult.GetValueForOption(agentSpIdOption); var serverName = context.ParseResult.GetValueForOption(serverNameOption); var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); var grantAll = context.ParseResult.GetValueForOption(yesOption); @@ -76,8 +80,33 @@ public static Command CreateListAgentInstancesSubcommand( permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(deviceCodeOption); - if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out var blueprintId, - listBlueprints: true)) + var hasBlueprint = !string.IsNullOrWhiteSpace(blueprintIdRaw); + var hasAgentSp = !string.IsNullOrWhiteSpace(agentSpIdRaw); + + // Neither target is individually required, so the pairing has to be checked here: + // System.CommandLine cannot express "exactly one of these two". + if (hasBlueprint == hasAgentSp) + { + logger.LogError(hasBlueprint + ? "Specify only one of --agent-blueprint-id or --agent-serviceprincipal-id." + : "Specify --agent-blueprint-id to check a blueprint's agent instances, or --agent-serviceprincipal-id to grant a single agent identity."); + context.ExitCode = 1; + return; + } + + string blueprintId = string.Empty; + string agentSpId = string.Empty; + + if (hasBlueprint) + { + if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out blueprintId, + listBlueprints: true)) + { + context.ExitCode = 1; + return; + } + } + else if (!TryValidateGuid(agentSpIdRaw, "--agent-serviceprincipal-id", logger, out agentSpId)) { context.ExitCode = 1; return; @@ -103,6 +132,13 @@ public static Command CreateListAgentInstancesSubcommand( return; } + if (hasAgentSp) + { + context.ExitCode = await GrantToSingleIdentityAsync( + permissionService, tenantId, resource, agentSpId, logger, ct); + return; + } + IReadOnlyList statuses; try { @@ -158,94 +194,39 @@ public static Command CreateListAgentInstancesSubcommand( } /// - /// Creates the grant-mcpserver-permissions subcommand, which grants a single agent identity - /// the MCP server scope. + /// Grants the MCP server scope to one agent identity. Returns the process exit code. /// - public static Command CreateGrantPermissionsSubcommand( + private static async Task GrantToSingleIdentityAsync( + McpServerPermissionService permissionService, + string tenantId, + McpServerResource resource, + string agentSpId, ILogger logger, - McpServerPermissionService permissionService) + CancellationToken ct) { - var command = new Command(GrantCommandName, - $"Grant an agent identity the '{McpConstants.V2ScopeValue}' permission for an MCP server.\n" + - "Creates a tenant-wide (AllPrincipals) delegated permission grant."); - - var agentSpIdOption = new Option( - "--agent-serviceprincipal-id", - description: "Object ID (GUID) of the agent identity service principal receiving the permission.") + bool granted; + try { - IsRequired = true, - }; - - var serverNameOption = new Option( - ["--mcp-server-name", "-s"], - description: "MCP server name. The Entra application is resolved as '{name} - BYO'.") + granted = await permissionService.GrantServerScopeAsync( + tenantId, agentSpId, resource.ServicePrincipalObjectId, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) { - IsRequired = true, - }; - - var tenantIdOption = new Option( - "--tenant-id", - description: "Azure AD tenant ID. Defaults to the current Azure CLI context."); - - var grantDeviceCodeOption = new Option( - "--device-code", - description: "Use device code authentication instead of the interactive browser flow (the WAM broker on Windows). Use when WAM cannot show a sign-in dialog, such as an embedded or remote terminal. Opens https://microsoft.com/devicelogin in your browser."); - - command.AddOption(agentSpIdOption); - command.AddOption(serverNameOption); - command.AddOption(tenantIdOption); - command.AddOption(grantDeviceCodeOption); - command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); + logger.LogError("Failed to grant '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}: {Message}", + McpConstants.V2ScopeValue, resource.DisplayName, agentSpId, ex.Message); + return 1; + } - command.SetHandler(async (InvocationContext context) => + if (!granted) { - var agentSpIdRaw = context.ParseResult.GetValueForOption(agentSpIdOption); - var serverName = context.ParseResult.GetValueForOption(serverNameOption); - var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); - var ct = context.GetCancellationToken(); - - permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(grantDeviceCodeOption); - - if (!TryValidateGuid(agentSpIdRaw, "--agent-serviceprincipal-id", logger, out var agentSpId)) - { - context.ExitCode = 1; - return; - } - - if (!TryValidateServerName(serverName, logger)) - { - context.ExitCode = 1; - return; - } - - var tenantId = await ResolveTenantIdAsync(tenantIdFlag, logger); - if (tenantId is null) - { - context.ExitCode = 1; - return; - } - - var resource = await permissionService.ResolveServerResourceAsync(tenantId, serverName!.Trim(), ct); - if (resource is null) - { - context.ExitCode = 1; - return; - } - - var granted = await permissionService.GrantServerScopeAsync(tenantId, agentSpId, resource.ServicePrincipalObjectId, ct); - if (!granted) - { - logger.LogError("Failed to grant '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", - McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); - context.ExitCode = 1; - return; - } - - logger.LogInformation("Granted '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", + logger.LogError("Failed to grant '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); - }); + return 1; + } - return command; + logger.LogInformation("Granted '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", + McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); + return 0; } /// @@ -270,7 +251,7 @@ private static List ResolveSelection( foreach (var instance in missing) { logger.LogInformation(" a365 develop-mcp {Command} --agent-serviceprincipal-id {SpObjectId} --mcp-server-name {ServerName}", - GrantCommandName, instance.ServicePrincipalObjectId, resource.ServerName); + GrantAgentCommandName, instance.ServicePrincipalObjectId, resource.ServerName); } return []; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 33122158..5d9be210 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -53,10 +53,36 @@ private void SetupInstances(params AgentInstancePermissionStatus[] statuses) => .Returns(Task.FromResult>(statuses)); private Command ListCommand() => - McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(_logger, _permissionService); + McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(_logger, _permissionService); - private Command GrantCommand() => - McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(_logger, _permissionService); + private Command GrantCommand() => ListCommand(); + + [Fact] + public async Task GrantAgentPermissions_NeitherTargetSpecified_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "the command cannot guess whether the caller means a whole blueprint or one " + + "identity, and silently doing nothing would look like success"); + await _permissionService.DidNotReceive().ResolveServerResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GrantAgentPermissions_BothTargetsSpecified_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--agent-serviceprincipal-id", AgentSpId, + "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "the two targets select different behaviours, so accepting both would make " + + "which one wins an invisible implementation detail"); + await _permissionService.DidNotReceive().ResolveServerResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } [Fact] public async Task ListAgentInstances_NoInstancesLinkedToBlueprint_ExitsWithOne() @@ -79,12 +105,6 @@ public void ListAgentInstances_HasExpectedName() ListCommand().Name.Should().Be("grant-agent-mcpserver-permissions"); } - [Fact] - public void GrantPermissions_HasExpectedName() - { - GrantCommand().Name.Should().Be("grant-mcpserver-permissions"); - } - [Fact] public async Task ListAgentInstances_NonGuidBlueprintId_ExitsWithOne() { @@ -101,7 +121,7 @@ await _permissionService.DidNotReceive().ResolveServerResourceAsync( public async Task ListAgentInstances_NonGuidBlueprintId_ListsFirstPartyBlueprintsInError() { var capturing = new CapturingLogger(); - var command = McpServerPermissionsSubcommands.CreateListAgentInstancesSubcommand(capturing, _permissionService); + var command = McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(capturing, _permissionService); await command.InvokeAsync( ["--agent-blueprint-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); @@ -121,7 +141,7 @@ await command.InvokeAsync( public async Task ListAgentInstances_NonGuidServicePrincipalId_DoesNotListBlueprints() { var capturing = new CapturingLogger(); - var command = McpServerPermissionsSubcommands.CreateGrantPermissionsSubcommand(capturing, _permissionService); + var command = McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(capturing, _permissionService); await command.InvokeAsync( ["--agent-serviceprincipal-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); From c510ff37f679acc07ffeed6d17d411628e621cef Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 10:33:07 -0700 Subject: [PATCH 09/20] Take only a blueprint and grant through interactive selection Drops --agent-serviceprincipal-id. The command lists the blueprint's agent instances missing the MCP server scope and the user picks which to grant, so there is no second way to name a target. Adds a test seam for input redirection so the prompt path can be covered under the test runner, which always redirects stdin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../McpServerPermissionsSubcommands.cs | 104 ++------------- .../Helpers/ConsoleHelper.cs | 12 ++ .../McpServerPermissionsSubcommandsTests.cs | 124 +++++++----------- 4 files changed, 72 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14689f42..9d1d6f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g ### Added - `a365 develop-mcp grant-agent-mcpserver-permissions --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. - `--device-code` option on `a365 develop-mcp grant-agent-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. -- `a365 develop-mcp grant-agent-mcpserver-permissions` grants agent identities the permission to call a BYO MCP server. Pass `--agent-blueprint-id` to review every agent instance of a blueprint that is missing the permission and be prompted before granting, or `--agent-serviceprincipal-id` to grant a single agent identity directly. +- `a365 develop-mcp grant-agent-mcpserver-permissions --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index b206bf9c..59964452 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -20,27 +20,24 @@ public static class McpServerPermissionsSubcommands private const string GrantAgentCommandName = "grant-agent-mcpserver-permissions"; /// - /// Creates the grant-agent-mcpserver-permissions subcommand. Given a blueprint it reports the - /// agent instances missing the MCP server scope and offers to grant it; given a single agent - /// identity it grants directly. + /// Creates the grant-agent-mcpserver-permissions subcommand, which reports the agent instances + /// of a blueprint that are missing the MCP server scope and offers to grant it. /// public static Command CreateGrantAgentPermissionsSubcommand( ILogger logger, McpServerPermissionService permissionService) { var command = new Command(GrantAgentCommandName, - $"Grant the '{McpConstants.V2ScopeValue}' permission for an MCP server to agent identities.\n" + - "With --agent-blueprint-id, lists the blueprint's agent instances missing it and prompts before granting.\n" + - "With --agent-serviceprincipal-id, grants a single agent identity directly."); + $"Grant the '{McpConstants.V2ScopeValue}' permission for an MCP server to agent identities. " + + "Lists the blueprint's agent instances missing it and prompts before granting."); var blueprintIdOption = new Option( "--agent-blueprint-id", description: "Agent blueprint ID (GUID) whose agent instances should be checked. " + - $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}."); - - var agentSpIdOption = new Option( - "--agent-serviceprincipal-id", - description: "Object ID (GUID) of a single agent identity service principal to grant directly, instead of checking a whole blueprint."); + $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}.") + { + IsRequired = true, + }; var serverNameOption = new Option( ["--mcp-server-name", "-s"], @@ -62,7 +59,6 @@ public static Command CreateGrantAgentPermissionsSubcommand( description: "Use device code authentication instead of the interactive browser flow (the WAM broker on Windows). Use when WAM cannot show a sign-in dialog, such as an embedded or remote terminal. Opens https://microsoft.com/devicelogin in your browser."); command.AddOption(blueprintIdOption); - command.AddOption(agentSpIdOption); command.AddOption(serverNameOption); command.AddOption(tenantIdOption); command.AddOption(yesOption); @@ -72,7 +68,6 @@ public static Command CreateGrantAgentPermissionsSubcommand( command.SetHandler(async (InvocationContext context) => { var blueprintIdRaw = context.ParseResult.GetValueForOption(blueprintIdOption); - var agentSpIdRaw = context.ParseResult.GetValueForOption(agentSpIdOption); var serverName = context.ParseResult.GetValueForOption(serverNameOption); var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); var grantAll = context.ParseResult.GetValueForOption(yesOption); @@ -80,33 +75,8 @@ public static Command CreateGrantAgentPermissionsSubcommand( permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(deviceCodeOption); - var hasBlueprint = !string.IsNullOrWhiteSpace(blueprintIdRaw); - var hasAgentSp = !string.IsNullOrWhiteSpace(agentSpIdRaw); - - // Neither target is individually required, so the pairing has to be checked here: - // System.CommandLine cannot express "exactly one of these two". - if (hasBlueprint == hasAgentSp) - { - logger.LogError(hasBlueprint - ? "Specify only one of --agent-blueprint-id or --agent-serviceprincipal-id." - : "Specify --agent-blueprint-id to check a blueprint's agent instances, or --agent-serviceprincipal-id to grant a single agent identity."); - context.ExitCode = 1; - return; - } - - string blueprintId = string.Empty; - string agentSpId = string.Empty; - - if (hasBlueprint) - { - if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out blueprintId, - listBlueprints: true)) - { - context.ExitCode = 1; - return; - } - } - else if (!TryValidateGuid(agentSpIdRaw, "--agent-serviceprincipal-id", logger, out agentSpId)) + if (!TryValidateGuid(blueprintIdRaw, "--agent-blueprint-id", logger, out var blueprintId, + listBlueprints: true)) { context.ExitCode = 1; return; @@ -132,13 +102,6 @@ public static Command CreateGrantAgentPermissionsSubcommand( return; } - if (hasAgentSp) - { - context.ExitCode = await GrantToSingleIdentityAsync( - permissionService, tenantId, resource, agentSpId, logger, ct); - return; - } - IReadOnlyList statuses; try { @@ -193,45 +156,9 @@ public static Command CreateGrantAgentPermissionsSubcommand( return command; } - /// - /// Grants the MCP server scope to one agent identity. Returns the process exit code. - /// - private static async Task GrantToSingleIdentityAsync( - McpServerPermissionService permissionService, - string tenantId, - McpServerResource resource, - string agentSpId, - ILogger logger, - CancellationToken ct) - { - bool granted; - try - { - granted = await permissionService.GrantServerScopeAsync( - tenantId, agentSpId, resource.ServicePrincipalObjectId, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError("Failed to grant '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}: {Message}", - McpConstants.V2ScopeValue, resource.DisplayName, agentSpId, ex.Message); - return 1; - } - - if (!granted) - { - logger.LogError("Failed to grant '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", - McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); - return 1; - } - - logger.LogInformation("Granted '{Scope}' on '{DisplayName}' to agent identity {AgentSpId}.", - McpConstants.V2ScopeValue, resource.DisplayName, agentSpId); - return 0; - } - /// /// Determines which instances to grant: all when --yes is set, the user's selection when a - /// terminal is attached, or none (printing the equivalent commands) when input is redirected. + /// terminal is attached, or none when input is redirected and there is nobody to prompt. /// private static List ResolveSelection( List missing, @@ -245,14 +172,9 @@ private static List ResolveSelection( return missing; } - if (Console.IsInputRedirected) + if (ConsoleHelper.IsInputRedirected) { - logger.LogInformation("Input is redirected. Re-run with --yes to grant, or run the commands below:"); - foreach (var instance in missing) - { - logger.LogInformation(" a365 develop-mcp {Command} --agent-serviceprincipal-id {SpObjectId} --mcp-server-name {ServerName}", - GrantAgentCommandName, instance.ServicePrincipalObjectId, resource.ServerName); - } + logger.LogInformation("Input is redirected, so the agent instances above cannot be selected interactively. Re-run with --yes to grant to all of them."); return []; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ConsoleHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ConsoleHelper.cs index 77c23317..632bd47e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ConsoleHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ConsoleHelper.cs @@ -32,6 +32,18 @@ internal static class ConsoleHelper /// internal static System.Threading.AsyncLocal?> ReadLineOverrideForTests { get; } = new(); + /// + /// Test hook: overrides so prompt paths that only run + /// with an attached terminal can be exercised under a test runner, which always redirects stdin. + /// + internal static System.Threading.AsyncLocal IsInputRedirectedOverrideForTests { get; } = new(); + + /// + /// Whether standard input is redirected, honouring the test override. + /// + public static bool IsInputRedirected => + IsInputRedirectedOverrideForTests.Value ?? Console.IsInputRedirected; + /// /// Reads a line from standard input. If the supplied fires while /// the read is blocked, the process exits with code 130 (SIGINT convention). diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 5d9be210..177d055c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -55,31 +55,15 @@ private void SetupInstances(params AgentInstancePermissionStatus[] statuses) => private Command ListCommand() => McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(_logger, _permissionService); - private Command GrantCommand() => ListCommand(); - [Fact] - public async Task GrantAgentPermissions_NeitherTargetSpecified_ExitsWithOne() + public async Task GrantAgentPermissions_NoBlueprintSpecified_ExitsWithOne() { var exitCode = await ListCommand().InvokeAsync( ["--mcp-server-name", ServerName, "--tenant-id", TenantId]); exitCode.Should().Be(1, - because: "the command cannot guess whether the caller means a whole blueprint or one " + - "identity, and silently doing nothing would look like success"); - await _permissionService.DidNotReceive().ResolveServerResourceAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task GrantAgentPermissions_BothTargetsSpecified_ExitsWithOne() - { - var exitCode = await ListCommand().InvokeAsync( - ["--agent-blueprint-id", BlueprintId, "--agent-serviceprincipal-id", AgentSpId, - "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - - exitCode.Should().Be(1, - because: "the two targets select different behaviours, so accepting both would make " + - "which one wins an invisible implementation detail"); + because: "the blueprint selects which agent instances are checked, so without it there " + + "is no work to do and silently succeeding would hide the mistake"); await _permissionService.DidNotReceive().ResolveServerResourceAsync( Arg.Any(), Arg.Any(), Arg.Any()); } @@ -137,20 +121,6 @@ await command.InvokeAsync( } } - [Fact] - public async Task ListAgentInstances_NonGuidServicePrincipalId_DoesNotListBlueprints() - { - var capturing = new CapturingLogger(); - var command = McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(capturing, _permissionService); - - await command.InvokeAsync( - ["--agent-serviceprincipal-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - - string.Join("\n", capturing.Messages).Should().NotContain("First-party blueprints", - because: "the agent service principal ID is a tenant-specific object ID, so listing " + - "blueprint IDs there would offer values that can never be valid for the option"); - } - private sealed class CapturingLogger : ILogger { public List Messages { get; } = []; @@ -213,7 +183,7 @@ public async Task ListAgentInstances_WithoutYes_DoesNotGrantWhenSelectionIsSkipp { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: false)); - // Covers both paths: redirected input prints the equivalent commands, a terminal prompts and gets an empty answer. + // Covers both paths: redirected input skips the prompt, a terminal prompts and gets an empty answer. ConsoleHelper.ReadLineOverrideForTests.Value = () => string.Empty; try { @@ -279,68 +249,66 @@ await ListCommand().InvokeAsync( } [Fact] - public async Task GrantPermissions_WithoutDeviceCodeFlag_UsesDefaultInteractiveSignIn() + public async Task ListAgentInstances_WithoutDeviceCodeFlag_UsesDefaultInteractiveSignIn() { SetupResolvedResource(); - _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) - .Returns(Task.FromResult(true)); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: true)); - await GrantCommand().InvokeAsync( - ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); _permissionService.Received().UseDeviceCodeAuthentication = false; } [Fact] - public async Task GrantPermissions_NonGuidAgentServicePrincipalId_ExitsWithOne() - { - var exitCode = await GrantCommand().InvokeAsync( - ["--agent-serviceprincipal-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - - exitCode.Should().Be(1); - await _permissionService.DidNotReceive().GrantServerScopeAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task GrantPermissions_UnresolvableServer_ExitsWithOne() - { - _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) - .Returns(Task.FromResult(null)); - - var exitCode = await GrantCommand().InvokeAsync( - ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - - exitCode.Should().Be(1); - await _permissionService.DidNotReceive().GrantServerScopeAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task GrantPermissions_Success_ExitsWithZeroAndGrantsAgainstResolvedResource() + public async Task ListAgentInstances_InteractiveSelection_GrantsOnlyTheChosenInstance() { SetupResolvedResource(); + SetupInstances( + new AgentInstancePermissionStatus(AgentSpId, "First", HasScope: false), + new AgentInstancePermissionStatus("sp-second", "Second", HasScope: false)); _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) .Returns(Task.FromResult(true)); + ConsoleHelper.ReadLineOverrideForTests.Value = () => "1"; + ConsoleHelper.IsInputRedirectedOverrideForTests.Value = false; + try + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - var exitCode = await GrantCommand().InvokeAsync( - ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - - exitCode.Should().Be(0); - await _permissionService.Received(1).GrantServerScopeAsync( - TenantId, AgentSpId, ByoSpObjectId, Arg.Any()); + exitCode.Should().Be(0); + await _permissionService.Received(1).GrantServerScopeAsync( + TenantId, AgentSpId, ByoSpObjectId, Arg.Any()); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + TenantId, "sp-second", ByoSpObjectId, Arg.Any()); + } + finally + { + ConsoleHelper.ReadLineOverrideForTests.Value = null; + ConsoleHelper.IsInputRedirectedOverrideForTests.Value = null; + } } [Fact] - public async Task GrantPermissions_GraphRejectsGrant_ExitsWithOne() + public async Task ListAgentInstances_RedirectedInput_DoesNotPromptOrGrant() { SetupResolvedResource(); - _permissionService.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId, Arg.Any()) - .Returns(Task.FromResult(false)); - - var exitCode = await GrantCommand().InvokeAsync( - ["--agent-serviceprincipal-id", AgentSpId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Missing", HasScope: false)); + ConsoleHelper.ReadLineOverrideForTests.Value = () => "all"; + ConsoleHelper.IsInputRedirectedOverrideForTests.Value = true; + try + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); - exitCode.Should().Be(1); + exitCode.Should().Be(0); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + finally + { + ConsoleHelper.ReadLineOverrideForTests.Value = null; + ConsoleHelper.IsInputRedirectedOverrideForTests.Value = null; + } } } From 24160fc6081bc9445baf0be4e796e327399073df Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 10:40:40 -0700 Subject: [PATCH 10/20] Rename the subcommand to grant-agents-access Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 +++--- .../Commands/DevelopMcpCommand.cs | 2 +- .../Commands/McpServerPermissionsSubcommands.cs | 8 ++++---- .../Commands/McpServerPermissionsSubcommandsTests.cs | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1d6f98..814910f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,9 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added -- `a365 develop-mcp grant-agent-mcpserver-permissions --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. -- `--device-code` option on `a365 develop-mcp grant-agent-mcpserver-permissions` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. -- `a365 develop-mcp grant-agent-mcpserver-permissions --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to. +- `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. +- `--device-code` option on `a365 develop-mcp grant-agents-access` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. +- `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 6f52f30e..19712bb0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -43,7 +43,7 @@ public static Command CreateCommand( if (mcpServerPermissionService is not null) { - developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(logger, mcpServerPermissionService)); + developMcpCommand.AddCommand(McpServerPermissionsSubcommands.CreateGrantAgentsAccessSubcommand(logger, mcpServerPermissionService)); } if (evaluationPipelineService is not null) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 59964452..e7679c01 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -17,17 +17,17 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; /// public static class McpServerPermissionsSubcommands { - private const string GrantAgentCommandName = "grant-agent-mcpserver-permissions"; + private const string GrantAgentsAccessCommandName = "grant-agents-access"; /// - /// Creates the grant-agent-mcpserver-permissions subcommand, which reports the agent instances + /// Creates the grant-agents-access subcommand, which reports the agent instances /// of a blueprint that are missing the MCP server scope and offers to grant it. /// - public static Command CreateGrantAgentPermissionsSubcommand( + public static Command CreateGrantAgentsAccessSubcommand( ILogger logger, McpServerPermissionService permissionService) { - var command = new Command(GrantAgentCommandName, + var command = new Command(GrantAgentsAccessCommandName, $"Grant the '{McpConstants.V2ScopeValue}' permission for an MCP server to agent identities. " + "Lists the blueprint's agent instances missing it and prompts before granting."); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 177d055c..8b3eef2d 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -53,7 +53,7 @@ private void SetupInstances(params AgentInstancePermissionStatus[] statuses) => .Returns(Task.FromResult>(statuses)); private Command ListCommand() => - McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(_logger, _permissionService); + McpServerPermissionsSubcommands.CreateGrantAgentsAccessSubcommand(_logger, _permissionService); [Fact] public async Task GrantAgentPermissions_NoBlueprintSpecified_ExitsWithOne() @@ -86,7 +86,7 @@ public async Task ListAgentInstances_NoInstancesLinkedToBlueprint_ExitsWithOne() [Fact] public void ListAgentInstances_HasExpectedName() { - ListCommand().Name.Should().Be("grant-agent-mcpserver-permissions"); + ListCommand().Name.Should().Be("grant-agents-access"); } [Fact] @@ -105,7 +105,7 @@ await _permissionService.DidNotReceive().ResolveServerResourceAsync( public async Task ListAgentInstances_NonGuidBlueprintId_ListsFirstPartyBlueprintsInError() { var capturing = new CapturingLogger(); - var command = McpServerPermissionsSubcommands.CreateGrantAgentPermissionsSubcommand(capturing, _permissionService); + var command = McpServerPermissionsSubcommands.CreateGrantAgentsAccessSubcommand(capturing, _permissionService); await command.InvokeAsync( ["--agent-blueprint-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); From df496dfe9c4bf517498cd69b6b060c65f8bee802 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 10:50:06 -0700 Subject: [PATCH 11/20] Address PR review feedback on grant-agents-access - Add --dry-run so the command can report missing grants without writing. - Let the handler reject a missing --agent-blueprint-id so the blueprint catalog is printed instead of a bare parser error. - Treat an explicitly blank --tenant-id as an error rather than silently falling back to the Azure CLI context. - Stop falling back to Connect-MgGraph when --device-code is requested; the PowerShell prompt cannot work with redirected stdio. - Distinguish a failed oauth2PermissionGrants read from an empty one so the command no longer reports every instance as missing after a Graph failure. - Fail when more than one application shares the BYO MCP server display name instead of silently picking the first match. - Validate --mcp-server-name against the shared input allowlist. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../McpServerPermissionsSubcommands.cs | 56 ++++++++++++--- .../Services/GraphApiService.cs | 48 ++++++++++--- .../Internal/MicrosoftGraphTokenProvider.cs | 11 ++- .../Services/McpServerPermissionService.cs | 27 +++++++- .../Commands/DevelopMcpCommandTests.cs | 15 +++- .../McpServerPermissionsSubcommandsTests.cs | 62 +++++++++++++++++ .../McpServerPermissionServiceTests.cs | 69 ++++++++++++++----- .../MicrosoftGraphTokenProviderTests.cs | 12 ++-- 9 files changed, 253 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 814910f7..8cc4f7c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g ### Added - `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. - `--device-code` option on `a365 develop-mcp grant-agents-access` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. +- `--dry-run` option on `a365 develop-mcp grant-agents-access` — lists the agent instances that are missing the permission without granting it. - `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index e7679c01..61a61321 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -34,10 +34,7 @@ public static Command CreateGrantAgentsAccessSubcommand( var blueprintIdOption = new Option( "--agent-blueprint-id", description: "Agent blueprint ID (GUID) whose agent instances should be checked. " + - $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}.") - { - IsRequired = true, - }; + $"First-party blueprints: {AgentBlueprintCatalog.FormatForHelp()}."); var serverNameOption = new Option( ["--mcp-server-name", "-s"], @@ -58,19 +55,25 @@ public static Command CreateGrantAgentsAccessSubcommand( "--device-code", description: "Use device code authentication instead of the interactive browser flow (the WAM broker on Windows). Use when WAM cannot show a sign-in dialog, such as an embedded or remote terminal. Opens https://microsoft.com/devicelogin in your browser."); + var dryRunOption = new Option( + "--dry-run", + description: "Report which agent instances are missing the permission without granting it."); + command.AddOption(blueprintIdOption); command.AddOption(serverNameOption); command.AddOption(tenantIdOption); command.AddOption(yesOption); command.AddOption(deviceCodeOption); + command.AddOption(dryRunOption); command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); command.SetHandler(async (InvocationContext context) => { var blueprintIdRaw = context.ParseResult.GetValueForOption(blueprintIdOption); - var serverName = context.ParseResult.GetValueForOption(serverNameOption); + var serverNameRaw = context.ParseResult.GetValueForOption(serverNameOption); var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); var grantAll = context.ParseResult.GetValueForOption(yesOption); + var dryRun = context.ParseResult.GetValueForOption(dryRunOption); var ct = context.GetCancellationToken(); permissionService.UseDeviceCodeAuthentication = context.ParseResult.GetValueForOption(deviceCodeOption); @@ -82,7 +85,7 @@ public static Command CreateGrantAgentsAccessSubcommand( return; } - if (!TryValidateServerName(serverName, logger)) + if (!TryValidateServerName(serverNameRaw, logger, out var serverName)) { context.ExitCode = 1; return; @@ -95,7 +98,7 @@ public static Command CreateGrantAgentsAccessSubcommand( return; } - var resource = await permissionService.ResolveServerResourceAsync(tenantId, serverName!.Trim(), ct); + var resource = await permissionService.ResolveServerResourceAsync(tenantId, serverName, ct); if (resource is null) { context.ExitCode = 1; @@ -140,6 +143,13 @@ public static Command CreateGrantAgentsAccessSubcommand( } logger.LogInformation(""); + if (dryRun) + { + logger.LogInformation("[DRY RUN] Would grant '{Scope}' on '{DisplayName}' to the {Count} agent instance(s) above.", + McpConstants.V2ScopeValue, resource.DisplayName, missing.Count); + return; + } + var selected = ResolveSelection(missing, grantAll, resource, logger, ct); if (selected.Count == 0) { @@ -266,7 +276,9 @@ private static bool TryValidateGuid(string? value, string optionName, ILogger lo { if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value.Trim(), out var guid)) { - logger.LogError("{OptionName} must be a GUID.", optionName); + logger.LogError(value is null + ? $"{optionName} is required and must be a GUID." + : $"{optionName} must be a GUID."); // List the IDs here rather than pointing elsewhere: this is the moment the caller // needs one, and a redirect costs them another command. @@ -288,20 +300,42 @@ private static bool TryValidateGuid(string? value, string optionName, ILogger lo return true; } - private static bool TryValidateServerName(string? serverName, ILogger logger) + private static bool TryValidateServerName(string? serverName, ILogger logger, out string normalized) { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(serverName)) { logger.LogError("--mcp-server-name must not be empty."); return false; } - return true; + // The name is interpolated into a Graph OData filter, so reuse the same allowlist + // register-external-mcp-server applies rather than accepting any non-blank string. + try + { + var validated = DevelopMcpCommand.InputValidator.ValidateInput(serverName, "Server name"); + if (string.IsNullOrWhiteSpace(validated)) + { + logger.LogError("--mcp-server-name must not be empty."); + return false; + } + + normalized = validated; + return true; + } + catch (ArgumentException ex) + { + logger.LogError("Invalid --mcp-server-name: {Message}", ex.Message); + return false; + } } private static async Task ResolveTenantIdAsync(string? tenantIdFlag, ILogger logger) { - if (!string.IsNullOrWhiteSpace(tenantIdFlag)) + // Only a missing option falls back to the Azure CLI context. An explicitly blank value is + // a mistake, and silently detecting a tenant could act on a different one than intended. + if (tenantIdFlag is not null) { if (!Guid.TryParse(tenantIdFlag.Trim(), out var tenantGuid)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 3e31ee04..21ada444 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -889,12 +889,24 @@ public virtual async Task ApplicationExistsByAppIdAsync( public virtual async Task FindApplicationByDisplayNameAsync( string tenantId, string displayName, CancellationToken ct = default) { - if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return null; + var appIds = await FindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct); + return appIds.Count > 0 ? appIds[0] : null; + } + + /// + /// Finds every application whose display name matches exactly. Display names are not unique in + /// Entra, so callers that act on the result must decide what an ambiguous match means rather + /// than silently taking the first. Returns an empty list if none match or on error. + /// + public virtual async Task> FindApplicationAppIdsByDisplayNameAsync( + string tenantId, string displayName, CancellationToken ct = default) + { + if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return []; // OData requires single quotes to be escaped by doubling them: ' → '' var escaped = displayName.Replace("'", "''", StringComparison.Ordinal); var url = GraphApiConstants.BuildUrl(_graphBaseUrl, - $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top=1&$count=true"); + $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top=10&$count=true"); try { @@ -909,19 +921,28 @@ public virtual async Task ApplicationExistsByAppIdAsync( if (!resp.IsSuccessStatusCode) { _logger.LogDebug("FindApplicationByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); - return null; + return []; } using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); - if (!doc.RootElement.TryGetProperty("value", out var value) || value.GetArrayLength() == 0) - return null; + if (!doc.RootElement.TryGetProperty("value", out var value)) + return []; - return value[0].TryGetProperty("appId", out var appId) ? appId.GetString() : null; + var appIds = new List(value.GetArrayLength()); + foreach (var app in value.EnumerateArray()) + { + if (app.TryGetProperty("appId", out var appId) && appId.GetString() is { Length: > 0 } id) + { + appIds.Add(id); + } + } + + return appIds; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "Failed to find application by display name {Name}", displayName); - return null; + return []; } } @@ -1243,6 +1264,17 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( string tenantId, string clientSpObjectId, CancellationToken ct = default) + => await TryGetOauth2PermissionGrantsAsync(tenantId, clientSpObjectId, ct) ?? []; + + /// + /// Same as but returns null when the read itself + /// failed, so callers that decide whether a permission is missing can tell "no grants" apart + /// from "we could not find out". + /// + public virtual async Task?> TryGetOauth2PermissionGrantsAsync( + string tenantId, + string clientSpObjectId, + CancellationToken ct = default) { var grants = new List<(string resourceId, string scope, string consentType)>(); @@ -1251,7 +1283,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}'", ct); - if (doc == null) return grants; + if (doc == null) return null; if (doc.RootElement.TryGetProperty("value", out var arr)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index 2d847a06..750e451d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -152,7 +152,16 @@ public MicrosoftGraphTokenProvider( : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh, useDeviceCode); // Fall back to PowerShell Connect-MgGraph if MSAL is unavailable (e.g. no clientAppId) - // or fails for any reason. + // or fails for any reason. Explicit device code is excluded: Connect-MgGraph -UseDeviceCode + // cannot render its prompt as a child process with redirected stdio, so the fallback + // would return no context and hide the real MSAL failure. + if (string.IsNullOrWhiteSpace(token) && useDeviceCode) + { + _logger.LogError( + "Device code sign-in did not return a token. Re-run without --device-code to use the browser or Windows sign-in dialog."); + return null; + } + if (string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("MSAL token acquisition failed, falling back to PowerShell Connect-MgGraph..."); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index eb78a379..c04c977e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -52,8 +52,8 @@ public McpServerPermissionService( var displayName = McpConstants.BuildByoAppDisplayName(serverName); - var appId = await _graphApiService.FindApplicationByDisplayNameAsync(tenantId, displayName, ct); - if (string.IsNullOrWhiteSpace(appId)) + var appIds = await _graphApiService.FindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct); + if (appIds.Count == 0) { // A failed sign-in also yields a null lookup result, which would otherwise be reported // as "application not found" and send the user off to create an app that may exist. @@ -67,6 +67,18 @@ public McpServerPermissionService( return null; } + // Display names are not unique, so granting against an arbitrary match could hand the + // agent access to a different MCP server than the caller named. + if (appIds.Count > 1) + { + _logger.LogError( + "Tenant {TenantId} has {Count} applications named '{DisplayName}' ({AppIds}). Rename or remove the duplicates so the MCP server resolves to one application.", + tenantId, appIds.Count, displayName, string.Join(", ", appIds)); + return null; + } + + var appId = appIds[0]; + var spObjectId = await _graphApiService.LookupServicePrincipalByAppIdAsync( tenantId, appId, ct, AuthenticationConstants.RequiredPermissionGrantScopes); if (string.IsNullOrWhiteSpace(spObjectId)) @@ -104,7 +116,16 @@ public virtual async Task> GetAgent { ct.ThrowIfCancellationRequested(); - var grants = await _graphApiService.GetOauth2PermissionGrantsAsync(tenantId, instance.IdentitySpId, ct); + var grants = await _graphApiService.TryGetOauth2PermissionGrantsAsync(tenantId, instance.IdentitySpId, ct); + if (grants is null) + { + // An empty list is also what a failed read returns, and reporting that as "missing" + // would let --yes grant on the strength of a lookup that never succeeded. + throw new InvalidOperationException( + $"Could not read the existing permission grants for agent identity {instance.IdentitySpId}. " + + "The permission state is unknown, so no grants were made."); + } + var hasScope = grants.Any(g => string.Equals(g.resourceId, resourceSpObjectId, StringComparison.OrdinalIgnoreCase) && ScopeStringContains(g.scope, McpConstants.V2ScopeValue)); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs index 32d7c3f6..0f4efc91 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -185,9 +185,13 @@ public void UnpublishSubcommand_HasCorrectOptionsWithAliases() public void AllSubcommands_SupportDryRunOption() { // Act - var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService, + mcpServerPermissionService: CreatePermissionService()); // Assert - All subcommands should have dry-run option for safety + command.Subcommands.Should().Contain(sc => sc.Name == "grant-agents-access", + because: "building the command without a permission service would let the permission " + + "subcommands go unregistered and silently escape this dry-run contract"); foreach (var subcommand in command.Subcommands) { var dryRunOption = subcommand.Options.FirstOrDefault(o => o.Name == "dry-run"); @@ -195,6 +199,15 @@ public void AllSubcommands_SupportDryRunOption() } } + private static McpServerPermissionService CreatePermissionService() + { + var graph = Substitute.For(); + var blueprintService = Substitute.For( + Substitute.For>(), graph); + return Substitute.For( + graph, blueprintService, Substitute.For>()); + } + [Fact] public void RegisterExternalMcpServerSubcommand_HasAllExpectedOptions() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 8b3eef2d..14c83222 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -121,6 +121,68 @@ await command.InvokeAsync( } } + [Fact] + public async Task GrantAgentsAccess_WhitespaceTenantId_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", " "]); + + exitCode.Should().Be(1, + because: "an explicitly blank --tenant-id must not fall through to Azure CLI detection, " + + "which would grant tenant-wide access in whatever tenant az happens to be signed into"); + await _permissionService.DidNotReceive().ResolveServerResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GrantAgentsAccess_ServerNameOutsideAllowlist_ExitsWithOne() + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", "Foo' or displayName ne '", + "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "the server name is interpolated into a Graph OData filter, so it must pass the " + + "same allowlist register-external-mcp-server applies rather than any non-blank string"); + await _permissionService.DidNotReceive().ResolveServerResourceAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GrantAgentsAccess_DryRun_ReportsWithoutGranting() + { + SetupResolvedResource(); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Missing", HasScope: false)); + + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId, + "--dry-run", "--yes"]); + + exitCode.Should().Be(0); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GrantAgentsAccess_NoBlueprintSpecified_ListsFirstPartyBlueprintsInError() + { + var capturing = new CapturingLogger(); + var command = McpServerPermissionsSubcommands.CreateGrantAgentsAccessSubcommand(capturing, _permissionService); + + var exitCode = await command.InvokeAsync( + ["--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1); + var output = string.Join("\n", capturing.Messages); + foreach (var blueprint in AgentBlueprintCatalog.FirstPartyBlueprints) + { + output.Should().Contain(blueprint.BlueprintId, + because: "a caller who omits the option needs the IDs at that moment; marking the " + + "option IsRequired would let System.CommandLine reject the call before the " + + "handler could print them"); + } + } + private sealed class CapturingLogger : ILogger { public List Messages { get; } = []; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index 0269ec67..0d4b1d3a 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -45,8 +45,8 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin { _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); - _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult(null)); + _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult>([])); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); @@ -56,8 +56,8 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin [Fact] public async Task ResolveServerResourceAsync_DoesNotAcquireATokenOnTheSuccessPath() { - _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult(ByoAppId)); + _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult>([ByoAppId])); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(ByoSpObjectId)); @@ -70,8 +70,8 @@ await _graph.DidNotReceive().GetGraphAccessTokenAsync( [Fact] public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() { - _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult(ByoAppId)); + _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult>([ByoAppId])); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(ByoSpObjectId)); @@ -88,8 +88,8 @@ public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenApplicationNotFound() { - _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult(null)); + _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult>([])); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); @@ -101,8 +101,8 @@ await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMissing() { - _graph.FindApplicationByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult(ByoAppId)); + _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult>([ByoAppId])); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(null)); @@ -112,6 +112,39 @@ public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMis because: "a grant cannot be created without a resource service principal, so the caller must fail rather than proceed"); } + [Fact] + public async Task ResolveServerResourceAsync_ReturnsNull_WhenMultipleApplicationsShareTheDisplayName() + { + _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult>([ByoAppId, "99999999-9999-9999-9999-999999999999"])); + + var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + + result.Should().BeNull( + because: "Entra display names are not unique, and picking an arbitrary match would grant " + + "the agent tenant-wide access to a different MCP server than the caller named"); + await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + } + + [Fact] + public async Task GetAgentInstanceStatusesAsync_Throws_WhenTheGrantReadFails() + { + _blueprintService.GetAgentInstancesForBlueprintAsync(TenantId, BlueprintId, Arg.Any()) + .Returns(Task.FromResult>( + [ + new AgentInstanceInfo { IdentitySpId = AgentSpId, DisplayName = "Agent" }, + ])); + _graph.TryGetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) + .Returns(Task.FromResult?>(null)); + + var act = async () => await _service.GetAgentInstanceStatusesAsync(TenantId, BlueprintId, ByoSpObjectId); + + await act.Should().ThrowAsync( + because: "a failed grant read is indistinguishable from an empty one, so reporting it as " + + "'missing the permission' would let --yes grant on the strength of a lookup that never succeeded"); + } + [Fact] public async Task GetAgentInstanceStatusesAsync_FlagsInstancesWithAndWithoutTheScope() { @@ -122,13 +155,13 @@ public async Task GetAgentInstanceStatusesAsync_FlagsInstancesWithAndWithoutTheS new AgentInstanceInfo { IdentitySpId = "sp-missing", DisplayName = "Missing" }, ])); - _graph.GetOauth2PermissionGrantsAsync(TenantId, "sp-granted", Arg.Any()) - .Returns(Task.FromResult(new List<(string, string, string)> + _graph.TryGetOauth2PermissionGrantsAsync(TenantId, "sp-granted", Arg.Any()) + .Returns(Task.FromResult?>(new List<(string, string, string)> { (ByoSpObjectId, $"User.Read {McpConstants.V2ScopeValue}", "AllPrincipals"), })); - _graph.GetOauth2PermissionGrantsAsync(TenantId, "sp-missing", Arg.Any()) - .Returns(Task.FromResult(new List<(string, string, string)> + _graph.TryGetOauth2PermissionGrantsAsync(TenantId, "sp-missing", Arg.Any()) + .Returns(Task.FromResult?>(new List<(string, string, string)> { (ByoSpObjectId, "User.Read", "AllPrincipals"), })); @@ -149,8 +182,8 @@ public async Task GetAgentInstanceStatusesAsync_IgnoresGrantsAgainstOtherResourc new AgentInstanceInfo { IdentitySpId = AgentSpId, DisplayName = "Agent" }, ])); - _graph.GetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) - .Returns(Task.FromResult(new List<(string, string, string)> + _graph.TryGetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) + .Returns(Task.FromResult?>(new List<(string, string, string)> { (OtherResourceSpId, McpConstants.V2ScopeValue, "AllPrincipals"), })); @@ -170,8 +203,8 @@ public async Task GetAgentInstanceStatusesAsync_DoesNotMatchScopeByPrefix() new AgentInstanceInfo { IdentitySpId = AgentSpId, DisplayName = "Agent" }, ])); - _graph.GetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) - .Returns(Task.FromResult(new List<(string, string, string)> + _graph.TryGetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) + .Returns(Task.FromResult?>(new List<(string, string, string)> { (ByoSpObjectId, $"{McpConstants.V2ScopeValue}.Extra", "AllPrincipals"), })); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 413fba62..9c1ac280 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -512,9 +512,9 @@ public async Task GetMgGraphAccessTokenAsync_WhenPowerShellBrowserAuthFails_Devi } [Fact] - public async Task GetMgGraphAccessTokenAsync_WhenUseDeviceCodeAlreadyTrue_DoesNotRetryAgain() + public async Task GetMgGraphAccessTokenAsync_WhenUseDeviceCode_DoesNotFallBackToPowerShell() { - // Arrange — ensures no double-retry when the caller already requested device code + // Arrange var tenantId = "12345678-1234-1234-1234-123456789abc"; var scopes = new[] { "User.Read" }; var browserFailureError = "InteractiveBrowserCredential authentication failed"; @@ -535,14 +535,14 @@ public async Task GetMgGraphAccessTokenAsync_WhenUseDeviceCodeAlreadyTrue_DoesNo // Assert token.Should().BeNull( - because: "when useDeviceCode is already true the retry guard (!useDeviceCode) prevents an infinite loop"); - // Only one PowerShell call — no retry - await _executor.Received(1).ExecuteWithStreamingAsync( + because: "Connect-MgGraph -UseDeviceCode cannot render its prompt as a child process " + + "with redirected stdio, so falling back to it would return no context and hide " + + "the real MSAL failure; explicit device code must surface the failure instead"); + await _executor.DidNotReceive().ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()); } - // ── WAM wrong-tenant self-heal (issue #430) ─────────────────────────────── private static string BuildJwt(object payload) From cb5a1a9e329fca1f04b8db7c482573bc63927b54 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 10:54:30 -0700 Subject: [PATCH 12/20] Fix identity, client, and consent-type gaps found in PR review - Thread the login hint and forceRefresh through the device-code credential so it targets the requested account instead of whichever account MSAL has cached, and honors an explicit refresh. - Request Graph tokens under the Graph Command Line Tools client on the device-code path; the Azure PowerShell client is not preauthorized for Graph delegated scopes (AADSTS65002). - Constrain the AllPrincipals grant lookup to AllPrincipals rows so an existing user-scoped grant for the same client and resource is no longer patched in place of creating the tenant-wide grant, and match the same consent type when reporting whether an agent instance already has access. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Services/AuthenticationService.cs | 8 +- .../Services/GraphApiService.cs | 25 ++++-- .../Services/McpServerPermissionService.cs | 1 + .../Services/AuthenticationServiceTests.cs | 37 ++++++++- .../Services/GraphApiServiceTests.cs | 83 +++++++++++++++++++ .../McpServerPermissionServiceTests.cs | 21 +++++ 7 files changed, 163 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc4f7c6..7bde6ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- A tenant-wide permission grant is no longer silently skipped when a user-scoped grant already exists for the same application and resource. - Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed. - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 180110f7..5f77b000 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -281,7 +281,7 @@ private async Task AuthenticateInteractivelyAsync( // Device code flow - works in all environments including SSH/remote sessions _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - credential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); + credential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId, loginHint, forceRefresh); } var tokenRequestContext = new TokenRequestContext(scopes); @@ -295,7 +295,7 @@ private async Task AuthenticateInteractivelyAsync( _logger.LogWarning("Browser authentication is not supported on this platform, falling back to device code flow..."); _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - var deviceCodeCredential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); + var deviceCodeCredential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId, loginHint, forceRefresh); tokenResult = await deviceCodeCredential.GetTokenAsync(tokenRequestContext, ct); } _logger.LogDebug("Authentication successful!"); @@ -551,11 +551,11 @@ protected virtual TokenCredential CreateBrowserCredential(string clientId, strin /// browser-based authentication is unavailable. /// Protected virtual to allow substitution in tests. /// - protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) + protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) // Routed through MsalBrowserCredential so device code shares the OS-protected MSAL // persistent cache and acquires silently when an account is already signed in. A bare // DeviceCodeCredential has no AuthenticationRecord, so each new instance re-prompts. - => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, useWam: false, useDeviceCode: true); + => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, useWam: false, loginHint: loginHint, forceRefresh: forceRefresh, useDeviceCode: true); /// /// Resolves the login hint (UPN) from the OS-protected MSAL persistent cache by reading the diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 21ada444..5fe5a0ee 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -176,7 +176,10 @@ public GraphApiService(ILogger logger, CommandExecutor executor { var resource = GraphApiConstants.GetResource(_graphBaseUrl); var loginHint = await _loginHintResolver(); - var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, useInteractiveBrowser: !UseDeviceCodeAuthentication, userId: loginHint, ct: ct); + // The Azure PowerShell client is not preauthorized for Graph delegated scopes + // (AADSTS65002), so the device-code path must use the Graph CLI client. + var clientId = UseDeviceCodeAuthentication ? AuthenticationConstants.GraphPowershellClientId : null; + var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, clientId: clientId, useInteractiveBrowser: !UseDeviceCodeAuthentication, userId: loginHint, ct: ct); if (!string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("Graph API access token acquired successfully"); @@ -1140,7 +1143,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( var existingFilter = principalId is not null ? $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}' and consentType eq 'Principal' and principalId eq '{principalId}'" - : $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'"; + : $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}' and consentType eq 'AllPrincipals'"; using (var listDoc = await GraphGetAsync( tenantId, @@ -1169,12 +1172,20 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( } } } - else if (arr.GetArrayLength() > 0) + else { - // AllPrincipals grants: the server-side filter is precise enough. - var grant = arr[0]; - existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; - existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; + // AllPrincipals grants: match consentType in code as well, so a Principal row + // is never patched in place of the tenant-wide grant being requested. + foreach (var grant in arr.EnumerateArray()) + { + var grantConsentType = grant.TryGetProperty("consentType", out var ctp) ? ctp.GetString() : null; + if (!string.Equals(grantConsentType, "AllPrincipals", StringComparison.OrdinalIgnoreCase)) + continue; + + existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; + break; + } } } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index c04c977e..97beb480 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -128,6 +128,7 @@ public virtual async Task> GetAgent var hasScope = grants.Any(g => string.Equals(g.resourceId, resourceSpObjectId, StringComparison.OrdinalIgnoreCase) && + string.Equals(g.consentType, "AllPrincipals", StringComparison.OrdinalIgnoreCase) && ScopeStringContains(g.scope, McpConstants.V2ScopeValue)); statuses.Add(new AgentInstancePermissionStatus(instance.IdentitySpId, instance.DisplayName, hasScope)); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs index 26049859..31f4f2b7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs @@ -802,11 +802,44 @@ public TestableAuthenticationService( _deviceCodeCredential = deviceCodeCredential; } + public string? LastDeviceCodeLoginHint { get; private set; } + + public bool LastDeviceCodeForceRefresh { get; private set; } + protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) => _browserCredential; - protected override TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) - => _deviceCodeCredential; + protected override TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) + { + LastDeviceCodeLoginHint = loginHint; + LastDeviceCodeForceRefresh = forceRefresh; + return _deviceCodeCredential; + } + } + + [Fact] + public async Task GetAccessTokenAsync_DeviceCode_PassesLoginHintAndForceRefreshToTheCredential() + { + // Device code must preserve the same identity and refresh semantics as the browser flow: + // without the login hint MSAL can silently return a different cached account's token, and + // without forceRefresh the caller's explicit refresh request is ignored. + var browserCredential = new StubTokenCredential("unused", DateTimeOffset.UtcNow.AddHours(1)); + var deviceCodeCredential = new StubTokenCredential("device-token", DateTimeOffset.UtcNow.AddHours(1)); + var logger = Substitute.For>(); + var sut = new TestableAuthenticationService(logger, browserCredential, deviceCodeCredential); + + await sut.GetAccessTokenAsync( + "https://graph.microsoft.com", + "11111111-1111-1111-1111-111111111111", + forceRefresh: true, + useInteractiveBrowser: false, + userId: "user@contoso.com"); + + sut.LastDeviceCodeLoginHint.Should().Be( + "user@contoso.com", + because: "device code must target the requested account, not whichever account MSAL happens to have cached"); + sut.LastDeviceCodeForceRefresh.Should().BeTrue( + because: "an explicit forceRefresh must bypass the MSAL silent cache on the device-code path too"); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index 3c060b99..e270b0da 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -917,6 +917,89 @@ private static GraphApiService CreateServiceWithNullAuth(TestHttpMessageHandler retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); } + [Fact] + public async Task GetGraphAccessTokenAsync_WithDeviceCode_UsesTheGraphCommandLineToolsClient() + { + // The Azure PowerShell client is not preauthorized for Graph delegated scopes, so requesting + // a Graph token under it fails with AADSTS65002. The device-code path must request the + // Graph Command Line Tools client instead. + using var handler = new TestHttpMessageHandler(); + var auth = Substitute.For(); + auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + + var service = new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler, loginHintResolver: () => Task.FromResult(null)) + { + UseDeviceCodeAuthentication = true + }; + + await service.GetGraphAccessTokenAsync("tenant-123"); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + AuthenticationConstants.GraphPowershellClientId, + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetGraphAccessTokenAsync_WithoutDeviceCode_LeavesTheClientIdAtItsDefault() + { + using var handler = new TestHttpMessageHandler(); + var auth = Substitute.For(); + auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + + var service = new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler, loginHintResolver: () => Task.FromResult(null)); + + await service.GetGraphAccessTokenAsync("tenant-123"); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + null, + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateOrUpdateOauth2PermissionGrantAsync_AllPrincipals_DoesNotPatchAPrincipalScopedGrant() + { + // A Principal-scoped grant for the same client and resource is what 'setup --authmode obo' + // creates. Patching it would report success while the requested tenant-wide grant never + // exists, so the lookup must be constrained to AllPrincipals and a new grant POSTed. + var requests = new List<(string Method, string Uri)>(); + using var handler = new CapturingHttpMessageHandler(r => requests.Add((r.Method.Method, r.RequestUri!.ToString()))); + + // Graph returns a Principal row even though AllPrincipals was requested. + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[{\"id\":\"principal-grant-id\",\"consentType\":\"Principal\",\"scope\":\"User.Read\"}]}") + }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Created) { Content = new StringContent("{}") }); + + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + var service = new GraphApiService(logger, executor, FakeAuthReturning("fake-token"), handler, + loginHintResolver: () => Task.FromResult(null)); + + var result = await service.CreateOrUpdateOauth2PermissionGrantAsync( + "tenant-123", "client-sp", "resource-sp", ["Tools.ListInvoke.All"]); + + result.Should().BeTrue(); + requests[0].Uri.Should().Contain( + "consentType eq 'AllPrincipals'", + because: "the lookup must not match a Principal-scoped grant for the same client and resource"); + requests[1].Method.Should().Be( + "POST", + because: "an unrelated Principal grant must not be patched in place of creating the tenant-wide grant"); + } + [Fact] public async Task IsCurrentUserAdminAsync_UserWithGlobalAdminRole_ReturnsHasRole() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index 0d4b1d3a..21cb8010 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -173,6 +173,27 @@ public async Task GetAgentInstanceStatusesAsync_FlagsInstancesWithAndWithoutTheS statuses.Single(s => s.ServicePrincipalObjectId == "sp-missing").HasScope.Should().BeFalse(); } + [Fact] + public async Task GetAgentInstanceStatusesAsync_IgnoresPrincipalScopedGrants() + { + _blueprintService.GetAgentInstancesForBlueprintAsync(TenantId, BlueprintId, Arg.Any()) + .Returns(Task.FromResult>( + [ + new AgentInstanceInfo { IdentitySpId = AgentSpId, DisplayName = "Agent" }, + ])); + + _graph.TryGetOauth2PermissionGrantsAsync(TenantId, AgentSpId, Arg.Any()) + .Returns(Task.FromResult?>(new List<(string, string, string)> + { + (ByoSpObjectId, McpConstants.V2ScopeValue, "Principal"), + })); + + var statuses = await _service.GetAgentInstanceStatusesAsync(TenantId, BlueprintId, ByoSpObjectId); + + statuses.Single().HasScope.Should().BeFalse( + because: "the command writes a tenant-wide AllPrincipals grant, so a grant scoped to a single user does not satisfy it and the read must agree with the write"); + } + [Fact] public async Task GetAgentInstanceStatusesAsync_IgnoresGrantsAgainstOtherResources() { From 6244f3d86f1f261e239130eec19cb6ff59e966c3 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 11:02:56 -0700 Subject: [PATCH 13/20] Reference PR #500 in the changelog entries for this change Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bde6ab6..3006c3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,10 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added -- `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere. -- `--device-code` option on `a365 develop-mcp grant-agents-access` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals. -- `--dry-run` option on `a365 develop-mcp grant-agents-access` — lists the agent instances that are missing the permission without granting it. -- `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to. +- `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere (#500). +- `--device-code` option on `a365 develop-mcp grant-agents-access` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals (#500). +- `--dry-run` option on `a365 develop-mcp grant-agents-access` — lists the agent instances that are missing the permission without granting it (#500). +- `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. @@ -63,8 +63,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed -- A tenant-wide permission grant is no longer silently skipped when a user-scoped grant already exists for the same application and resource. -- Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed. +- A tenant-wide permission grant is no longer silently skipped when a user-scoped grant already exists for the same application and resource (#500). +- Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed (#500). - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). From b470e13329d5eb3772ae3b4133b19b2a89baadc4 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Tue, 22 Sep 2026 12:28:41 -0700 Subject: [PATCH 14/20] Address review findings on grant-agents-access - Resolve the cached login hint against the client ID actually used for the token request; MSAL partitions its account cache per client, so the hardcoded PowerShell client could never find a device-code account. - Distinguish a failed application read from an absent application, so a Graph authorization failure no longer tells the user to create an app that may already exist. - Exit non-zero when an interactive selection cannot be parsed; an unparseable answer is a failure, not a decision to grant nothing. - Correct a stale comment about oauth2PermissionGrants $filter support. - Note in the CHANGELOG that setup and create-instance now surface a failure where they previously reported a false success. - Add HTTP-level coverage for multi-match, read-failure, and empty-result application lookups, login-hint client wiring, and the invalid-selection exit code; rename the stale ListAgentInstances_* tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +- .../McpServerPermissionsSubcommands.cs | 13 +- .../Services/AuthenticationService.cs | 11 +- .../Services/GraphApiService.cs | 42 +++-- .../Services/McpServerPermissionService.cs | 21 ++- .../McpServerPermissionsSubcommandsTests.cs | 61 ++++++-- .../Services/GraphApiServiceTests.cs | 144 ++++++++++++++++++ .../McpServerPermissionServiceTests.cs | 42 +++-- .../MicrosoftGraphTokenProviderTests.cs | 4 +- 9 files changed, 288 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3006c3c6..ffd3b440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,10 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500). - `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere (#500). - `--device-code` option on `a365 develop-mcp grant-agents-access` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals (#500). - `--dry-run` option on `a365 develop-mcp grant-agents-access` — lists the agent instances that are missing the permission without granting it (#500). -- `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. @@ -63,7 +63,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed -- A tenant-wide permission grant is no longer silently skipped when a user-scoped grant already exists for the same application and resource (#500). +- A tenant-wide permission grant is no longer silently skipped when a user-scoped grant already exists for the same application and resource; `setup` and `create-instance` now report this as a failure instead of reporting success, and completing the grant may require a Global Administrator (#500). - Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed (#500). - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 61a61321..695ad1e0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -151,6 +151,13 @@ public static Command CreateGrantAgentsAccessSubcommand( } var selected = ResolveSelection(missing, grantAll, resource, logger, ct); + if (selected is null) + { + // Invalid input is a failure, not a decision to skip. + context.ExitCode = 1; + return; + } + if (selected.Count == 0) { return; @@ -169,8 +176,10 @@ public static Command CreateGrantAgentsAccessSubcommand( /// /// Determines which instances to grant: all when --yes is set, the user's selection when a /// terminal is attached, or none when input is redirected and there is nobody to prompt. + /// Returns null when the response could not be parsed, which is a failure rather than a + /// decision to grant nothing. /// - private static List ResolveSelection( + private static List? ResolveSelection( List missing, bool grantAll, McpServerResource resource, @@ -208,7 +217,7 @@ private static List ResolveSelection( if (!int.TryParse(token, out var index) || index < 1 || index > missing.Count) { logger.LogError("Invalid selection '{Token}'. Enter numbers between 1 and {Max}, or 'all'.", token, missing.Count); - return []; + return null; } var instance = missing[index - 1]; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 5f77b000..ee36f3fb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -29,7 +29,7 @@ Task GetAccessTokenAsync( string? userId = null, CancellationToken ct = default); - Task ResolveLoginHintFromCacheAsync(); + Task ResolveLoginHintFromCacheAsync(string? clientId = null); Task ClearTokenCacheAsync(); } @@ -562,9 +562,14 @@ protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, st /// first cached account's username. Used to pre-select the correct account for WAM/MSAL when /// the Azure CLI is not available. Returns null if no account is cached or the lookup fails. /// - public Task ResolveLoginHintFromCacheAsync() + /// + /// Client app whose cached accounts to read. MSAL partitions the cache per client, so callers + /// that authenticate as a different app must pass it or the lookup finds nothing. + /// Defaults to . + /// + public Task ResolveLoginHintFromCacheAsync(string? clientId = null) => MsalBrowserCredential.TryGetCachedAccountUsernameAsync( - AuthenticationConstants.PowershellClientId, _logger); + string.IsNullOrWhiteSpace(clientId) ? AuthenticationConstants.PowershellClientId : clientId, _logger); private static string? TryExtractUpnFromJwt(string? jwt) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 5fe5a0ee..2276c8ae 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -117,7 +117,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor _tokenProvider = tokenProvider; _retryHelper = retryHelper ?? new RetryHelper(_logger); // Default: try az CLI first (if present), fall back to JWT cache in AuthenticationService. - _loginHintResolver = loginHintResolver ?? (() => ResolveLoginHintWithFallbackAsync(authService)); + _loginHintResolver = loginHintResolver ?? (() => ResolveLoginHintWithFallbackAsync(authService, EffectiveGraphClientId)); _graphBaseUrl = string.IsNullOrWhiteSpace(graphBaseUrl) ? GraphApiConstants.BaseUrl : graphBaseUrl; _agentRegistryRetryDelay = agentRegistryRetryDelay ?? TimeSpan.FromSeconds(30); } @@ -146,16 +146,25 @@ public GraphApiService(ILogger logger, CommandExecutor executor { } - private static async Task ResolveLoginHintWithFallbackAsync(IAuthenticationService authService) + private static async Task ResolveLoginHintWithFallbackAsync(IAuthenticationService authService, string? clientId) { // Try az CLI first — most reliable when the user has run 'az login'. var hint = await AzCliHelper.ResolveLoginHintAsync(); if (!string.IsNullOrWhiteSpace(hint)) return hint; - // Fall back to the UPN embedded in a previously cached MSAL JWT. - return await authService.ResolveLoginHintFromCacheAsync(); + // Fall back to the UPN embedded in a previously cached MSAL JWT. MSAL partitions its cache + // per client, so this must read the same app the token is acquired as (issue #500). + return await authService.ResolveLoginHintFromCacheAsync(clientId); } + /// + /// Client app used for in-process MSAL acquisition. The Azure PowerShell client is not + /// preauthorized for Graph delegated scopes (AADSTS65002), so the device-code path must use + /// the Graph CLI client. Null leaves the default in place for the normal path. + /// + private string? EffectiveGraphClientId => + UseDeviceCodeAuthentication ? AuthenticationConstants.GraphPowershellClientId : null; + /// /// Clears the persistent MSAL token cache. Passthrough to /// so callers that only hold a reference (e.g. ClientAppValidator) @@ -176,9 +185,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor { var resource = GraphApiConstants.GetResource(_graphBaseUrl); var loginHint = await _loginHintResolver(); - // The Azure PowerShell client is not preauthorized for Graph delegated scopes - // (AADSTS65002), so the device-code path must use the Graph CLI client. - var clientId = UseDeviceCodeAuthentication ? AuthenticationConstants.GraphPowershellClientId : null; + var clientId = EffectiveGraphClientId; var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, clientId: clientId, useInteractiveBrowser: !UseDeviceCodeAuthentication, userId: loginHint, ct: ct); if (!string.IsNullOrWhiteSpace(token)) { @@ -903,8 +910,18 @@ public virtual async Task ApplicationExistsByAppIdAsync( /// public virtual async Task> FindApplicationAppIdsByDisplayNameAsync( string tenantId, string displayName, CancellationToken ct = default) + => await TryFindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct) ?? []; + + /// + /// Same as but returns null when the read + /// itself failed (no token, 403, or a transport error), so callers can tell "no such + /// application" apart from "we could not find out" instead of sending the user off to create an + /// application that may already exist. + /// + public virtual async Task?> TryFindApplicationAppIdsByDisplayNameAsync( + string tenantId, string displayName, CancellationToken ct = default) { - if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return []; + if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return null; // OData requires single quotes to be escaped by doubling them: ' → '' var escaped = displayName.Replace("'", "''", StringComparison.Ordinal); @@ -924,7 +941,7 @@ public virtual async Task> FindApplicationAppIdsByDisplayN if (!resp.IsSuccessStatusCode) { _logger.LogDebug("FindApplicationByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); - return []; + return null; } using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); @@ -945,7 +962,7 @@ public virtual async Task> FindApplicationAppIdsByDisplayN catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "Failed to find application by display name {Name}", displayName); - return []; + return null; } } @@ -1135,9 +1152,8 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( var isPrincipal = string.Equals(consentType, "Principal", StringComparison.OrdinalIgnoreCase); // Read existing — extract string values immediately so JsonDocument can be disposed. - // AllPrincipals grants can filter by clientId+resourceId server-side. - // Principal grants must filter by clientId only, then match resourceId/consentType/principalId in code - // because the Graph API oauth2PermissionGrants endpoint has limited $filter support. + // Both shapes filter server-side; the in-code re-check guards against a tenant whose Graph + // ignores part of the $filter and returns rows of the other consentType. string? existingId = null; string existingScopes = ""; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index 97beb480..feb3732b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -52,17 +52,28 @@ public McpServerPermissionService( var displayName = McpConstants.BuildByoAppDisplayName(serverName); - var appIds = await _graphApiService.FindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct); - if (appIds.Count == 0) + var appIds = await _graphApiService.TryFindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct); + if (appIds is null) { - // A failed sign-in also yields a null lookup result, which would otherwise be reported - // as "application not found" and send the user off to create an app that may exist. + // A failed read is not an absent application — reporting "not found" would send the + // user off to create an application that may already exist (issue #500). if (string.IsNullOrWhiteSpace(await _graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct))) { _logger.LogError("Could not sign in to tenant {TenantId}, so '{DisplayName}' could not be looked up.", tenantId, displayName); - return null; } + else + { + _logger.LogError( + "Could not read application registrations in tenant {TenantId}, so '{DisplayName}' could not be looked up. " + + "This usually means the signed-in account lacks permission to read applications.", + tenantId, displayName); + } + + return null; + } + if (appIds.Count == 0) + { _logger.LogError("No Entra application named '{DisplayName}' was found in tenant {TenantId}.", displayName, tenantId); return null; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 14c83222..7e8f8ed2 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -56,7 +56,7 @@ private Command ListCommand() => McpServerPermissionsSubcommands.CreateGrantAgentsAccessSubcommand(_logger, _permissionService); [Fact] - public async Task GrantAgentPermissions_NoBlueprintSpecified_ExitsWithOne() + public async Task GrantAgentsAccess_NoBlueprintSpecified_ExitsWithOne() { var exitCode = await ListCommand().InvokeAsync( ["--mcp-server-name", ServerName, "--tenant-id", TenantId]); @@ -69,7 +69,7 @@ await _permissionService.DidNotReceive().ResolveServerResourceAsync( } [Fact] - public async Task ListAgentInstances_NoInstancesLinkedToBlueprint_ExitsWithOne() + public async Task GrantAgentsAccess_NoInstancesLinkedToBlueprint_ExitsWithOne() { SetupResolvedResource(); SetupInstances(); @@ -90,7 +90,7 @@ public void ListAgentInstances_HasExpectedName() } [Fact] - public async Task ListAgentInstances_NonGuidBlueprintId_ExitsWithOne() + public async Task GrantAgentsAccess_NonGuidBlueprintId_ExitsWithOne() { var exitCode = await ListCommand().InvokeAsync( ["--agent-blueprint-id", "not-a-guid", "--mcp-server-name", ServerName, "--tenant-id", TenantId]); @@ -102,7 +102,7 @@ await _permissionService.DidNotReceive().ResolveServerResourceAsync( } [Fact] - public async Task ListAgentInstances_NonGuidBlueprintId_ListsFirstPartyBlueprintsInError() + public async Task GrantAgentsAccess_NonGuidBlueprintId_ListsFirstPartyBlueprintsInError() { var capturing = new CapturingLogger(); var command = McpServerPermissionsSubcommands.CreateGrantAgentsAccessSubcommand(capturing, _permissionService); @@ -196,7 +196,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } [Fact] - public async Task ListAgentInstances_NonGuidTenantId_ExitsWithOne() + public async Task GrantAgentsAccess_NonGuidTenantId_ExitsWithOne() { var exitCode = await ListCommand().InvokeAsync( ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", "nope"]); @@ -205,7 +205,7 @@ public async Task ListAgentInstances_NonGuidTenantId_ExitsWithOne() } [Fact] - public async Task ListAgentInstances_WhitespaceServerName_ExitsWithOne() + public async Task GrantAgentsAccess_WhitespaceServerName_ExitsWithOne() { var exitCode = await ListCommand().InvokeAsync( ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", " ", "--tenant-id", TenantId]); @@ -215,7 +215,7 @@ public async Task ListAgentInstances_WhitespaceServerName_ExitsWithOne() } [Fact] - public async Task ListAgentInstances_UnresolvableServer_ExitsWithOne() + public async Task GrantAgentsAccess_UnresolvableServer_ExitsWithOne() { _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) .Returns(Task.FromResult(null)); @@ -227,7 +227,7 @@ public async Task ListAgentInstances_UnresolvableServer_ExitsWithOne() } [Fact] - public async Task ListAgentInstances_AllInstancesHaveScope_ExitsWithZeroAndGrantsNothing() + public async Task GrantAgentsAccess_AllInstancesHaveScope_ExitsWithZeroAndGrantsNothing() { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: true)); @@ -241,11 +241,11 @@ await _permissionService.DidNotReceive().GrantServerScopeAsync( } [Fact] - public async Task ListAgentInstances_WithoutYes_DoesNotGrantWhenSelectionIsSkipped() + public async Task GrantAgentsAccess_WithoutYes_DoesNotGrantWhenSelectionIsSkipped() { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: false)); - // Covers both paths: redirected input skips the prompt, a terminal prompts and gets an empty answer. + // Under the test runner stdin is always redirected, so this exercises the no-terminal path. ConsoleHelper.ReadLineOverrideForTests.Value = () => string.Empty; try { @@ -263,8 +263,37 @@ await _permissionService.DidNotReceive().GrantServerScopeAsync( } } + [Theory] + [InlineData("2x")] + [InlineData("abc")] + [InlineData("0")] + [InlineData("99")] + public async Task GrantAgentsAccess_InvalidSelection_ExitsWithOneAndGrantsNothing(string response) + { + SetupResolvedResource(); + SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: false)); + // A terminal must be simulated: the runner redirects stdin, which skips the prompt entirely. + ConsoleHelper.IsInputRedirectedOverrideForTests.Value = false; + ConsoleHelper.ReadLineOverrideForTests.Value = () => response; + try + { + var exitCode = await ListCommand().InvokeAsync( + ["--agent-blueprint-id", BlueprintId, "--mcp-server-name", ServerName, "--tenant-id", TenantId]); + + exitCode.Should().Be(1, + because: "an unparseable selection is a failure, not a decision to grant nothing - exiting 0 would let a typo pass silently in a script that checks the exit code"); + await _permissionService.DidNotReceive().GrantServerScopeAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + finally + { + ConsoleHelper.ReadLineOverrideForTests.Value = null; + ConsoleHelper.IsInputRedirectedOverrideForTests.Value = null; + } + } + [Fact] - public async Task ListAgentInstances_WithYes_GrantsOnlyInstancesMissingTheScope() + public async Task GrantAgentsAccess_WithYes_GrantsOnlyInstancesMissingTheScope() { SetupResolvedResource(); SetupInstances( @@ -284,7 +313,7 @@ await _permissionService.DidNotReceive().GrantServerScopeAsync( } [Fact] - public async Task ListAgentInstances_WithYes_FailedGrantExitsWithOne() + public async Task GrantAgentsAccess_WithYes_FailedGrantExitsWithOne() { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Missing", HasScope: false)); @@ -299,7 +328,7 @@ public async Task ListAgentInstances_WithYes_FailedGrantExitsWithOne() } [Fact] - public async Task ListAgentInstances_DeviceCodeFlag_RoutesSignInThroughDeviceCode() + public async Task GrantAgentsAccess_DeviceCodeFlag_RoutesSignInThroughDeviceCode() { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: true)); @@ -311,7 +340,7 @@ await ListCommand().InvokeAsync( } [Fact] - public async Task ListAgentInstances_WithoutDeviceCodeFlag_UsesDefaultInteractiveSignIn() + public async Task GrantAgentsAccess_WithoutDeviceCodeFlag_UsesDefaultInteractiveSignIn() { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Agent", HasScope: true)); @@ -323,7 +352,7 @@ await ListCommand().InvokeAsync( } [Fact] - public async Task ListAgentInstances_InteractiveSelection_GrantsOnlyTheChosenInstance() + public async Task GrantAgentsAccess_InteractiveSelection_GrantsOnlyTheChosenInstance() { SetupResolvedResource(); SetupInstances( @@ -352,7 +381,7 @@ await _permissionService.DidNotReceive().GrantServerScopeAsync( } [Fact] - public async Task ListAgentInstances_RedirectedInput_DoesNotPromptOrGrant() + public async Task GrantAgentsAccess_RedirectedInput_DoesNotPromptOrGrant() { SetupResolvedResource(); SetupInstances(new AgentInstancePermissionStatus(AgentSpId, "Missing", HasScope: false)); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index e270b0da..85551773 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -967,6 +967,82 @@ await auth.Received(1).GetAccessTokenAsync( Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task TryFindApplicationAppIdsByDisplayNameAsync_MultipleMatches_ReturnsEveryAppId() + { + // The duplicate-name safety check in McpServerPermissionService refuses to grant when more + // than one application shares a name. That guard is only as good as this parser: if it kept + // just the first entry, the check would silently pass and grant against an arbitrary app. + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[{\"appId\":\"app-id-1\"},{\"appId\":\"app-id-2\"}]}") + }); + + var service = CreateServiceWithToken(handler); + + var appIds = await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + + appIds.Should().BeEquivalentTo(["app-id-1", "app-id-2"], + because: "every match must be returned so callers can detect an ambiguous display name rather than granting against whichever app Graph happened to list first"); + } + + [Fact] + public async Task TryFindApplicationAppIdsByDisplayNameAsync_WhenTheReadFails_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StringContent("{}") }); + + var service = CreateServiceWithToken(handler); + + var appIds = await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + + appIds.Should().BeNull( + because: "a failed read is not an absent application - conflating them tells the user to create an app that may already exist"); + } + + [Fact] + public async Task TryFindApplicationAppIdsByDisplayNameAsync_WhenNothingMatches_ReturnsEmptyNotNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"value\":[]}") }); + + var service = CreateServiceWithToken(handler); + + var appIds = await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + + appIds.Should().NotBeNull( + because: "a successful read that found nothing is a conclusive answer and must be distinguishable from a failed read"); + appIds.Should().BeEmpty(); + } + + [Fact] + public async Task FindApplicationAppIdsByDisplayNameAsync_WhenTheReadFails_ReturnsEmptyForLegacyCallers() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StringContent("{}") }); + + var service = CreateServiceWithToken(handler); + + var appIds = await service.FindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + + appIds.Should().BeEmpty( + because: "existing callers treat a failed lookup as 'no application' and must keep that behaviour unchanged"); + } + + private static GraphApiService CreateServiceWithToken(HttpMessageHandler handler) + { + var auth = Substitute.For(); + auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + + return new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler, loginHintResolver: () => Task.FromResult(null)); + } + [Fact] public async Task CreateOrUpdateOauth2PermissionGrantAsync_AllPrincipals_DoesNotPatchAPrincipalScopedGrant() { @@ -1395,6 +1471,74 @@ await graph.DidNotReceive().EnsureServicePrincipalForAppIdAsync( #endregion } +/// +/// Login-hint fallback wiring. A separate class so it can join the AzCliHelper collection: the az +/// resolver and its cache are process-wide static state that must not be mutated concurrently. +/// +[Collection("AzCliHelperTests")] +public class GraphApiServiceLoginHintTests : IDisposable +{ + public GraphApiServiceLoginHintTests() + { + // Force the az branch to miss so the MSAL-cache fallback is the path under test. + AzCliHelper.LoginHintResolverOverride = () => Task.FromResult(null); + AzCliHelper.ResetLoginHintCacheForTesting(); + } + + public void Dispose() + { + AzCliHelper.LoginHintResolverOverride = null; + AzCliHelper.ResetLoginHintCacheForTesting(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetGraphAccessTokenAsync_DeviceCodeWithoutAzCli_ReadsTheCacheForTheGraphClient() + { + using var handler = new TestHttpMessageHandler(); + var auth = CreateAuth(); + + var service = new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler) + { + UseDeviceCodeAuthentication = true + }; + + await service.GetGraphAccessTokenAsync("tenant-123"); + + await auth.Received(1).ResolveLoginHintFromCacheAsync(AuthenticationConstants.GraphPowershellClientId); + } + + [Fact] + public async Task GetGraphAccessTokenAsync_WithoutDeviceCode_ReadsTheCacheForTheDefaultClient() + { + using var handler = new TestHttpMessageHandler(); + var auth = CreateAuth(); + + var service = new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler); + + await service.GetGraphAccessTokenAsync("tenant-123"); + + await auth.Received(1).ResolveLoginHintFromCacheAsync(null); + } + + private static IAuthenticationService CreateAuth() + { + var auth = Substitute.For(); + auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + auth.ResolveLoginHintFromCacheAsync(Arg.Any()) + .Returns(Task.FromResult("user@contoso.com")); + return auth; + } +} + // Handler that throws the supplied exception instead of sending a request. internal class ExceptionThrowingHttpMessageHandler : HttpMessageHandler { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index 21cb8010..806f55da 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -45,8 +45,8 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin { _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); - _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult>([])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>([])); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); @@ -56,8 +56,8 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin [Fact] public async Task ResolveServerResourceAsync_DoesNotAcquireATokenOnTheSuccessPath() { - _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult>([ByoAppId])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>([ByoAppId])); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(ByoSpObjectId)); @@ -70,8 +70,8 @@ await _graph.DidNotReceive().GetGraphAccessTokenAsync( [Fact] public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() { - _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult>([ByoAppId])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>([ByoAppId])); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(ByoSpObjectId)); @@ -88,21 +88,39 @@ public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenApplicationNotFound() { - _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult>([])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>([])); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); result.Should().BeNull(); await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + await _graph.DidNotReceive().GetGraphAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ResolveServerResourceAsync_WhenTheReadFails_DoesNotReportTheApplicationAsAbsent() + { + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>(null)); + _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("token")); + + var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + + result.Should().BeNull(); + await _graph.Received(1).GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()); + await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); } [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMissing() { - _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult>([ByoAppId])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>([ByoAppId])); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(null)); @@ -115,8 +133,8 @@ public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMis [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenMultipleApplicationsShareTheDisplayName() { - _graph.FindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult>([ByoAppId, "99999999-9999-9999-9999-999999999999"])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>([ByoAppId, "99999999-9999-9999-9999-999999999999"])); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 9c1ac280..326e2e61 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -105,7 +105,8 @@ public void ResolveMsalClientAppId_WithConfiguredClientApp_IsNeverOverridden() } [Fact] - public async Task GetMgGraphAccessTokenAsync_WithoutClientAppId_OmitsClientIdParameter() { + public async Task GetMgGraphAccessTokenAsync_WithoutClientAppId_OmitsClientIdParameter() + { // Arrange var tenantId = "12345678-1234-1234-1234-123456789abc"; var scopes = new[] { "User.Read" }; @@ -543,6 +544,7 @@ await _executor.DidNotReceive().ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()); } + // ── WAM wrong-tenant self-heal (issue #430) ─────────────────────────────── private static string BuildJwt(object payload) From b5d87808d02b49bf4ec8fbbe59bf745b295c6606 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Wed, 23 Sep 2026 09:06:10 -0700 Subject: [PATCH 15/20] Reject empty selection tokens and strengthen resolve-failure tests The interactive prompt split on ',' with RemoveEmptyEntries, so input like "," or "1," produced zero tokens and was treated as "skip everything" with exit code 0. A typo therefore left agents without the permission while the command reported success. Empty tokens are now rejected with exit code 1. The sign-in-failure test mocked the lookup as an empty list, which takes the "application not found" branch and never reaches the token probe, so it would have passed with the auth-failure handling deleted. Both resolve-failure tests now mock a failed read and assert on the logged diagnostic. Corrects the CHANGELOG entry for the consentType fix: setup and create-instance create the missing tenant-wide grant rather than reporting a failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../McpServerPermissionsSubcommands.cs | 10 +++++- .../McpServerPermissionsSubcommandsTests.cs | 4 +++ .../McpServerPermissionServiceTests.cs | 35 ++++++++++++++++--- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd3b440..508ece2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed -- A tenant-wide permission grant is no longer silently skipped when a user-scoped grant already exists for the same application and resource; `setup` and `create-instance` now report this as a failure instead of reporting success, and completing the grant may require a Global Administrator (#500). +- `setup` and `create-instance` now create the tenant-wide permission grant when only a user-scoped grant exists, instead of reporting success without it; this may require a Global Administrator (#500). - Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed (#500). - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 695ad1e0..33478cf9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -212,7 +212,9 @@ public static Command CreateGrantAgentsAccessSubcommand( } var selected = new List(); - foreach (var token in response.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + // Not RemoveEmptyEntries: input like "," or "1," must be rejected, not silently treated + // as a skip, or a typo would leave agents without the permission and still exit 0. + foreach (var token in response.Split(',', StringSplitOptions.TrimEntries)) { if (!int.TryParse(token, out var index) || index < 1 || index > missing.Count) { @@ -227,6 +229,12 @@ public static Command CreateGrantAgentsAccessSubcommand( } } + if (selected.Count == 0) + { + logger.LogError("Invalid selection '{Response}'. Enter numbers between 1 and {Max}, or 'all'.", response, missing.Count); + return null; + } + return selected; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 7e8f8ed2..2c360338 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -268,6 +268,10 @@ await _permissionService.DidNotReceive().GrantServerScopeAsync( [InlineData("abc")] [InlineData("0")] [InlineData("99")] + [InlineData(",")] + [InlineData(", ,")] + [InlineData("1,")] + [InlineData(",1")] public async Task GrantAgentsAccess_InvalidSelection_ExitsWithOneAndGrantsNothing(string response) { SetupResolvedResource(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index 806f55da..fad70365 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -43,14 +43,22 @@ public McpServerPermissionServiceTests() [Fact] public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimingTheAppIsMissing() { + var logger = new CapturingLogger(); + var service = new McpServerPermissionService(_graph, _blueprintService, logger); + // Null means the read itself failed, which is the only way to reach the sign-in probe. + // An empty list is "no such application" and would never exercise this branch. + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) + .Returns(Task.FromResult?>(null)); _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>([])); - var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + var result = await service.ResolveServerResourceAsync(TenantId, ServerName); result.Should().BeNull(); + logger.Messages.Should().Contain(m => m.Contains("Could not sign in"), + because: "a failed sign-in must be reported as such, not as a missing application"); + logger.Messages.Should().NotContain(m => m.Contains("was found"), + because: "telling the user the application does not exist would send them off to create one that may already exist"); } [Fact] @@ -103,15 +111,20 @@ await _graph.DidNotReceive().GetGraphAccessTokenAsync( [Fact] public async Task ResolveServerResourceAsync_WhenTheReadFails_DoesNotReportTheApplicationAsAbsent() { + var logger = new CapturingLogger(); + var service = new McpServerPermissionService(_graph, _blueprintService, logger); _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) .Returns(Task.FromResult?>(null)); _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult("token")); - var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); + var result = await service.ResolveServerResourceAsync(TenantId, ServerName); result.Should().BeNull(); - await _graph.Received(1).GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()); + logger.Messages.Should().Contain(m => m.Contains("Could not read application registrations"), + because: "a directory read the caller is not authorized for must be reported as a permission problem"); + logger.Messages.Should().NotContain(m => m.Contains("was found"), + because: "an unreadable directory is not evidence the application is missing"); await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); } @@ -290,4 +303,16 @@ public void BuildByoAppDisplayName_AppendsTheByoSuffix(string serverName, string McpConstants.BuildByoAppDisplayName(serverName).Should().Be(expected, because: "the CLI must derive the same display name the BYO registration flow created, or the resource lookup fails"); } + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Messages.Add(formatter(state, exception)); + } } From d304aee4060d21d5c12740b92d3c2e57a7a1617e Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Wed, 23 Sep 2026 09:26:18 -0700 Subject: [PATCH 16/20] Scope grant-agents-access changes to the new command only Address review feedback that the PR altered shared infrastructure used by setup, create-instance, cleanup and develop get-token. - Revert CreateDeviceCodeCredential to its original DeviceCodeCredential implementation. The new command never used it; it reaches device code through MicrosoftGraphTokenProvider, which already took useDeviceCode. - Replace the mutable UseDeviceCodeAuthentication flag on the GraphApiService singleton with an optional per-call parameter. The flag now lives on McpServerPermissionService, which passes it explicitly. - Make the oauth2PermissionGrants consentType lookup opt-in via requireMatchingConsentType. Existing callers keep the original behaviour; only grant-agents-access opts in. The shared setup-path fix lands separately. - Restore FindApplicationByDisplayNameAsync to its original standalone \=1 query instead of delegating to the new multi-match lookup. - Query one past ApplicationDisplayNameMatchLimit so an over-limit result is reported as ambiguous rather than silently truncated. - Return why an application lookup failed instead of re-acquiring a token to decide which error to show, which could prompt the user a second time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/AgentBlueprintService.cs | 15 +- .../Services/AuthenticationService.cs | 36 +++- .../Services/GraphApiService.cs | 187 ++++++++++++------ .../Services/McpServerPermissionService.cs | 39 ++-- .../Services/AgentBlueprintServiceTests.cs | 3 +- .../Services/AuthenticationServiceTests.cs | 37 +--- .../Services/GraphApiServiceTests.cs | 79 ++++++-- .../McpServerPermissionServiceTests.cs | 51 ++--- 8 files changed, 277 insertions(+), 170 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs index 80db28d5..3d773f58 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs @@ -179,7 +179,8 @@ public virtual async Task DeleteAgentIdentityAsync( public virtual async Task> GetAgentInstancesForBlueprintAsync( string tenantId, string blueprintId, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool useDeviceCode = false) { var spScopes = new[] { AuthenticationConstants.AgentIdentityReadAllScope }; var encodedId = Uri.EscapeDataString(blueprintId); @@ -189,7 +190,8 @@ public virtual async Task> GetAgentInstancesFor tenantId, $"/beta/servicePrincipals/microsoft.graph.agentIdentity?$filter=agentIdentityBlueprintId eq '{encodedId}'&$select=id,displayName", spScopes, - cancellationToken); + cancellationToken, + useDeviceCode); // Agent user query requires AgentIdUser.ReadWrite.All, which is intentionally absent from // RequiredClientAppPermissions until create-instance is re-enabled. This means agent user @@ -202,7 +204,8 @@ public virtual async Task> GetAgentInstancesFor tenantId, $"/beta/users/microsoft.graph.agentUser?$filter=agentIdentityBlueprintId eq '{encodedId}'&$select=id,identityParentId", userScopes, - cancellationToken); + cancellationToken, + useDeviceCode); } else { @@ -282,7 +285,8 @@ private async Task> FetchAllPagesAsync( string tenantId, string initialPath, string[] requiredScopes, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool useDeviceCode = false) { var items = new List(); string? nextPageUrl = null; @@ -297,7 +301,8 @@ private async Task> FetchAllPagesAsync( tenantId, requestPath, cancellationToken, - requiredScopes); + requiredScopes, + useDeviceCode); if (doc is null) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index ee36f3fb..364e95be 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -281,7 +281,7 @@ private async Task AuthenticateInteractivelyAsync( // Device code flow - works in all environments including SSH/remote sessions _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - credential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId, loginHint, forceRefresh); + credential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); } var tokenRequestContext = new TokenRequestContext(scopes); @@ -295,7 +295,7 @@ private async Task AuthenticateInteractivelyAsync( _logger.LogWarning("Browser authentication is not supported on this platform, falling back to device code flow..."); _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - var deviceCodeCredential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId, loginHint, forceRefresh); + var deviceCodeCredential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); tokenResult = await deviceCodeCredential.GetTokenAsync(tokenRequestContext, ct); } _logger.LogDebug("Authentication successful!"); @@ -546,16 +546,36 @@ protected virtual TokenCredential CreateBrowserCredential(string clientId, strin => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, loginHint: loginHint, forceRefresh: forceRefresh); /// - /// Creates a credential configured for interactive device code authentication. + /// Creates a DeviceCodeCredential configured for interactive device code authentication. /// This flow works in all environments including SSH, remote sessions, and platforms where /// browser-based authentication is unavailable. /// Protected virtual to allow substitution in tests. /// - protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) - // Routed through MsalBrowserCredential so device code shares the OS-protected MSAL - // persistent cache and acquires silently when an account is already signed in. A bare - // DeviceCodeCredential has no AuthenticationRecord, so each new instance re-prompts. - => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, useWam: false, loginHint: loginHint, forceRefresh: forceRefresh, useDeviceCode: true); + protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) + { + return new DeviceCodeCredential(new DeviceCodeCredentialOptions + { + TenantId = tenantId, + ClientId = clientId, + AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions + { + Name = AuthenticationConstants.ApplicationName + }, + DeviceCodeCallback = (code, cancellation) => + { + _logger.LogInformation(""); + _logger.LogInformation("=========================================================================="); + _logger.LogInformation("To sign in, use a web browser to open the page:"); + _logger.LogInformation(" {VerificationUri}", code.VerificationUri); + _logger.LogInformation(""); + _logger.LogInformation("And enter the code: {UserCode}", code.UserCode); + _logger.LogInformation("=========================================================================="); + _logger.LogInformation(""); + return Task.CompletedTask; + } + }); + } /// /// Resolves the login hint (UPN) from the OS-protected MSAL persistent cache by reading the diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 2276c8ae..8547164d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -38,7 +38,7 @@ public class GraphApiService // Resolver delegate for the login hint. // Defaults to az CLI first, then AuthenticationService JWT cache as fallback. // Injectable via constructor so unit tests can bypass the real az process. - private readonly Func> _loginHintResolver; + private readonly Func> _loginHintResolver; // Delay before retrying a 403 from the agent registry (role propagation lag). // Injectable so unit tests can pass TimeSpan.Zero and avoid the real 30s wait. @@ -55,10 +55,10 @@ public class GraphApiService public string? CustomClientAppId { get; set; } /// - /// Routes interactive sign-in through the device code flow instead of the WAM broker. - /// Set when the caller knows WAM cannot present a dialog (headless or embedded terminals). + /// Most matches reports. Anything + /// beyond this is already ambiguous, so paging further would not change the caller's decision. /// - public bool UseDeviceCodeAuthentication { get; set; } + public const int ApplicationDisplayNameMatchLimit = 10; /// /// Override the Microsoft Graph base URL for sovereign / government cloud tenants. @@ -117,7 +117,9 @@ public GraphApiService(ILogger logger, CommandExecutor executor _tokenProvider = tokenProvider; _retryHelper = retryHelper ?? new RetryHelper(_logger); // Default: try az CLI first (if present), fall back to JWT cache in AuthenticationService. - _loginHintResolver = loginHintResolver ?? (() => ResolveLoginHintWithFallbackAsync(authService, EffectiveGraphClientId)); + _loginHintResolver = loginHintResolver is not null + ? _ => loginHintResolver() + : clientId => ResolveLoginHintWithFallbackAsync(authService, clientId); _graphBaseUrl = string.IsNullOrWhiteSpace(graphBaseUrl) ? GraphApiConstants.BaseUrl : graphBaseUrl; _agentRegistryRetryDelay = agentRegistryRetryDelay ?? TimeSpan.FromSeconds(30); } @@ -162,8 +164,8 @@ public GraphApiService(ILogger logger, CommandExecutor executor /// preauthorized for Graph delegated scopes (AADSTS65002), so the device-code path must use /// the Graph CLI client. Null leaves the default in place for the normal path. /// - private string? EffectiveGraphClientId => - UseDeviceCodeAuthentication ? AuthenticationConstants.GraphPowershellClientId : null; + private static string? GetEffectiveGraphClientId(bool useDeviceCode) => + useDeviceCode ? AuthenticationConstants.GraphPowershellClientId : null; /// /// Clears the persistent MSAL token cache. Passthrough to @@ -178,15 +180,19 @@ public GraphApiService(ILogger logger, CommandExecutor executor /// browser/device-code on macOS/Linux). Token is cached persistently by /// AuthenticationService — no az CLI subprocess involved. /// - public virtual async Task GetGraphAccessTokenAsync(string tenantId, bool forceRefresh = false, CancellationToken ct = default) + /// + /// Routes interactive sign-in through the device code flow instead of the WAM broker. Passed + /// per call rather than held on this singleton so it cannot leak into unrelated Graph calls. + /// + public virtual async Task GetGraphAccessTokenAsync(string tenantId, bool forceRefresh = false, CancellationToken ct = default, bool useDeviceCode = false) { _logger.LogDebug("Acquiring Graph API access token for tenant {TenantId}", tenantId); try { var resource = GraphApiConstants.GetResource(_graphBaseUrl); - var loginHint = await _loginHintResolver(); - var clientId = EffectiveGraphClientId; - var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, clientId: clientId, useInteractiveBrowser: !UseDeviceCodeAuthentication, userId: loginHint, ct: ct); + var clientId = GetEffectiveGraphClientId(useDeviceCode); + var loginHint = await _loginHintResolver(clientId); + var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, clientId: clientId, useInteractiveBrowser: !useDeviceCode, userId: loginHint, ct: ct); if (!string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("Graph API access token acquired successfully"); @@ -239,7 +245,8 @@ private async Task EnsureGraphHeadersAsync( bool forceRefresh = false, IEnumerable? scopes = null, CancellationToken ct = default, - GraphAuthenticationMode authenticationMode = GraphAuthenticationMode.ResolvedClientApp) + GraphAuthenticationMode authenticationMode = GraphAuthenticationMode.ResolvedClientApp, + bool useDeviceCode = false) { // Authentication strategy: // @@ -283,9 +290,9 @@ private async Task EnsureGraphHeadersAsync( _logger.LogDebug( "Acquiring Graph token via token provider (clientId: {AppId}, scopes: {Scopes})", CustomClientAppId, string.Join(", ", effectiveScopes)); - var loginHint = await ResolveLoginHintAsync(); + var loginHint = await ResolveLoginHintAsync(useDeviceCode); token = await _tokenProvider.GetMgGraphAccessTokenAsync( - tenantId, effectiveScopes, UseDeviceCodeAuthentication, CustomClientAppId, ct, loginHint, forceRefresh); + tenantId, effectiveScopes, useDeviceCode, CustomClientAppId, ct, loginHint, forceRefresh); if (string.IsNullOrWhiteSpace(token)) { @@ -379,9 +386,9 @@ public virtual async Task ServicePrincipalExistsAsync(string tenantId, str /// Executes a GET request to Microsoft Graph API. /// Virtual to allow mocking in unit tests using Moq. /// - public virtual async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null) + public virtual async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null, bool useDeviceCode = false) { - if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return null; + if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct, useDeviceCode: useDeviceCode)) return null; var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); try { @@ -525,9 +532,9 @@ public virtual async Task GraphGetWithResponseAsync( /// /// POST to Graph but always return HTTP response details (status, body, parsed JSON) /// - public virtual async Task GraphPostWithResponseAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null, bool forceRefresh = false) + public virtual async Task GraphPostWithResponseAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null, bool forceRefresh = false, bool useDeviceCode = false) { - if (!await EnsureGraphHeadersAsync(tenantId, forceRefresh: forceRefresh, scopes: scopes, ct: ct)) + if (!await EnsureGraphHeadersAsync(tenantId, forceRefresh: forceRefresh, scopes: scopes, ct: ct, useDeviceCode: useDeviceCode)) { return new GraphResponse { IsSuccess = false, StatusCode = 0, ReasonPhrase = "NoAuth", Body = "Failed to acquire token" }; } @@ -569,9 +576,9 @@ public virtual async Task GraphPostWithResponseAsync(string tenan /// Executes a PATCH request to Microsoft Graph API. /// Virtual to allow mocking in unit tests using Moq. /// - public virtual async Task GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null) + public virtual async Task GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null, bool useDeviceCode = false) { - if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return false; + if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct, useDeviceCode: useDeviceCode)) return false; var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); try @@ -650,7 +657,7 @@ public async Task GraphDeleteAsync( /// Virtual to allow mocking in unit tests using Moq. /// public virtual async Task LookupServicePrincipalByAppIdAsync( - string tenantId, string appId, CancellationToken ct = default, IEnumerable? scopes = null) + string tenantId, string appId, CancellationToken ct = default, IEnumerable? scopes = null, bool useDeviceCode = false) { // $filter=appId eq is "Default+Advanced" per Graph docs - no ConsistencyLevel header required. // The token must have Application.Read.All; pass scopes to ensure MSAL token is used when needed. @@ -658,7 +665,8 @@ public async Task GraphDeleteAsync( tenantId, $"/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id", ct, - scopes); + scopes, + useDeviceCode); if (doc == null) return null; if (!doc.RootElement.TryGetProperty("value", out var value) || value.GetArrayLength() == 0) return null; return value[0].GetProperty("id").GetString(); @@ -899,34 +907,72 @@ public virtual async Task ApplicationExistsByAppIdAsync( public virtual async Task FindApplicationByDisplayNameAsync( string tenantId, string displayName, CancellationToken ct = default) { - var appIds = await FindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct); - return appIds.Count > 0 ? appIds[0] : null; + if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return null; + + // OData requires single quotes to be escaped by doubling them: ' → '' + var escaped = displayName.Replace("'", "''", StringComparison.Ordinal); + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, + $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top=1&$count=true"); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + // Copy auth header set by EnsureGraphHeadersAsync onto the shared _httpClient + if (_httpClient.DefaultRequestHeaders.Authorization is { } auth) + request.Headers.Authorization = auth; + // Required for advanced query filters (displayName eq) + request.Headers.TryAddWithoutValidation("ConsistencyLevel", "eventual"); + + using var resp = await _httpClient.SendAsync(request, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("FindApplicationByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); + return null; + } + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + if (!doc.RootElement.TryGetProperty("value", out var value) || value.GetArrayLength() == 0) + return null; + + return value[0].TryGetProperty("appId", out var appId) ? appId.GetString() : null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Failed to find application by display name {Name}", displayName); + return null; + } } /// - /// Finds every application whose display name matches exactly. Display names are not unique in - /// Entra, so callers that act on the result must decide what an ambiguous match means rather - /// than silently taking the first. Returns an empty list if none match or on error. + /// Outcome of an application search by display name. Distinguishes a sign-in failure from an + /// authorized-but-failed read so callers can report the right cause without acquiring a second + /// token, which would prompt the user again after a cancelled sign-in (issue #500). /// - public virtual async Task> FindApplicationAppIdsByDisplayNameAsync( - string tenantId, string displayName, CancellationToken ct = default) - => await TryFindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct) ?? []; + /// Matching appIds, or null when the read did not succeed. + /// True when no token could be acquired for the tenant. + public sealed record ApplicationDisplayNameSearchResult(IReadOnlyList? AppIds, bool SignInFailed); /// - /// Same as but returns null when the read - /// itself failed (no token, 403, or a transport error), so callers can tell "no such - /// application" apart from "we could not find out" instead of sending the user off to create an - /// application that may already exist. + /// Finds applications whose display name matches exactly, up to + /// . Display names are not unique in Entra, so + /// callers that act on the result must decide what an ambiguous match means rather than + /// silently taking the first. A null AppIds means the read itself failed (no token, + /// 403, or a transport error), so callers can tell "no such application" apart from "we could + /// not find out" instead of sending the user off to create an application that may already + /// exist. /// - public virtual async Task?> TryFindApplicationAppIdsByDisplayNameAsync( - string tenantId, string displayName, CancellationToken ct = default) + public virtual async Task TryFindApplicationAppIdsByDisplayNameAsync( + string tenantId, string displayName, CancellationToken ct = default, bool useDeviceCode = false) { - if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return null; + if (!await EnsureGraphHeadersAsync(tenantId, ct: ct, useDeviceCode: useDeviceCode)) + return new ApplicationDisplayNameSearchResult(null, SignInFailed: true); // OData requires single quotes to be escaped by doubling them: ' → '' var escaped = displayName.Replace("'", "''", StringComparison.Ordinal); + // One past the limit so a truncated page is detectable: callers only need to distinguish + // none / exactly one / more than one, and any overflow is reported as ambiguous. var url = GraphApiConstants.BuildUrl(_graphBaseUrl, - $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top=10&$count=true"); + $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top={ApplicationDisplayNameMatchLimit + 1}&$count=true"); try { @@ -940,13 +986,13 @@ public virtual async Task> FindApplicationAppIdsByDisplayN using var resp = await _httpClient.SendAsync(request, ct); if (!resp.IsSuccessStatusCode) { - _logger.LogDebug("FindApplicationByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); - return null; + _logger.LogDebug("FindApplicationAppIdsByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); + return new ApplicationDisplayNameSearchResult(null, SignInFailed: false); } using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); if (!doc.RootElement.TryGetProperty("value", out var value)) - return []; + return new ApplicationDisplayNameSearchResult([], SignInFailed: false); var appIds = new List(value.GetArrayLength()); foreach (var app in value.EnumerateArray()) @@ -957,12 +1003,12 @@ public virtual async Task> FindApplicationAppIdsByDisplayN } } - return appIds; + return new ApplicationDisplayNameSearchResult(appIds, SignInFailed: false); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "Failed to find application by display name {Name}", displayName); - return null; + return new ApplicationDisplayNameSearchResult(null, SignInFailed: false); } } @@ -1056,7 +1102,9 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( string resourceSpObjectId, IEnumerable scopes, CancellationToken ct = default, - IEnumerable? permissionGrantScopes = null) + IEnumerable? permissionGrantScopes = null, + bool requireMatchingConsentType = false, + bool useDeviceCode = false) { var (success, _, _) = await CreateOrUpdateOauth2PermissionGrantCoreAsync( tenantId, @@ -1066,7 +1114,9 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( consentType: "AllPrincipals", scopes, ct, - permissionGrantScopes); + permissionGrantScopes, + requireMatchingConsentType, + useDeviceCode); return success; } @@ -1144,7 +1194,9 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( string consentType, IEnumerable scopes, CancellationToken ct, - IEnumerable? permissionGrantScopes) + IEnumerable? permissionGrantScopes, + bool requireMatchingConsentType = false, + bool useDeviceCode = false) { int lastStatusCode = 0; string? lastErrorCode = null; @@ -1152,20 +1204,24 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( var isPrincipal = string.Equals(consentType, "Principal", StringComparison.OrdinalIgnoreCase); // Read existing — extract string values immediately so JsonDocument can be disposed. - // Both shapes filter server-side; the in-code re-check guards against a tenant whose Graph - // ignores part of the $filter and returns rows of the other consentType. + // AllPrincipals grants can filter by clientId+resourceId server-side. + // Principal grants must filter by clientId only, then match resourceId/consentType/principalId in code + // because the Graph API oauth2PermissionGrants endpoint has limited $filter support. string? existingId = null; string existingScopes = ""; var existingFilter = principalId is not null ? $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}' and consentType eq 'Principal' and principalId eq '{principalId}'" - : $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}' and consentType eq 'AllPrincipals'"; + : requireMatchingConsentType + ? $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}' and consentType eq 'AllPrincipals'" + : $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'"; using (var listDoc = await GraphGetAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter={existingFilter}", ct, - permissionGrantScopes)) + permissionGrantScopes, + useDeviceCode)) { if (listDoc?.RootElement.TryGetProperty("value", out var arr) == true) { @@ -1188,15 +1244,20 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( } } } - else + else if (arr.GetArrayLength() > 0) { - // AllPrincipals grants: match consentType in code as well, so a Principal row - // is never patched in place of the tenant-wide grant being requested. + // AllPrincipals grants: the server-side filter is precise enough. foreach (var grant in arr.EnumerateArray()) { - var grantConsentType = grant.TryGetProperty("consentType", out var ctp) ? ctp.GetString() : null; - if (!string.Equals(grantConsentType, "AllPrincipals", StringComparison.OrdinalIgnoreCase)) - continue; + // Opt-in re-check so a Principal row is never patched in place of the + // tenant-wide grant being requested (issue #500). Off by default because + // changing it for the setup path is a separate behavior change. + if (requireMatchingConsentType) + { + var grantConsentType = grant.TryGetProperty("consentType", out var ctp) ? ctp.GetString() : null; + if (!string.Equals(grantConsentType, "AllPrincipals", StringComparison.OrdinalIgnoreCase)) + continue; + } existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; @@ -1223,7 +1284,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( const int baseDelaySeconds = 5; for (int attempt = 0; attempt < maxRetries; attempt++) { - var grantResponse = await GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes); + var grantResponse = await GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes, useDeviceCode: useDeviceCode); // Dispose the error JSON immediately — only IsSuccess and Body are needed below. grantResponse.Json?.Dispose(); lastStatusCode = grantResponse.StatusCode; @@ -1275,7 +1336,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( currentSet.UnionWith(desiredSet); var merged = string.Join(' ', currentSet); - var patchOk = await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{existingId}", new { scope = merged }, ct, permissionGrantScopes); + var patchOk = await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{existingId}", new { scope = merged }, ct, permissionGrantScopes, useDeviceCode); return (patchOk, 0, null); } @@ -1301,14 +1362,16 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( public virtual async Task?> TryGetOauth2PermissionGrantsAsync( string tenantId, string clientSpObjectId, - CancellationToken ct = default) + CancellationToken ct = default, + bool useDeviceCode = false) { var grants = new List<(string resourceId, string scope, string consentType)>(); using var doc = await GraphGetAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}'", - ct); + ct, + useDeviceCode: useDeviceCode); if (doc == null) return null; @@ -1535,13 +1598,13 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( /// Azure CLI identity instead of the Windows default account. /// Returns null if az account show fails or the user field is absent. /// - private async Task ResolveLoginHintAsync() + private async Task ResolveLoginHintAsync(bool useDeviceCode = false) { if (_loginHintResolved) return _loginHint; _loginHintResolved = true; - _loginHint = await _loginHintResolver(); + _loginHint = await _loginHintResolver(GetEffectiveGraphClientId(useDeviceCode)); return _loginHint; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index feb3732b..a7dac727 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -19,13 +19,11 @@ public class McpServerPermissionService /// /// Routes sign-in through the device code flow instead of the WAM broker, for terminals - /// where WAM cannot present a dialog. + /// where WAM cannot present a dialog. Held here rather than on the shared + /// singleton so it cannot affect unrelated Graph calls, and + /// passed explicitly on every call this service makes. /// - public virtual bool UseDeviceCodeAuthentication - { - get => _graphApiService.UseDeviceCodeAuthentication; - set => _graphApiService.UseDeviceCodeAuthentication = value; - } + public virtual bool UseDeviceCodeAuthentication { get; set; } public McpServerPermissionService( GraphApiService graphApiService, @@ -52,12 +50,14 @@ public McpServerPermissionService( var displayName = McpConstants.BuildByoAppDisplayName(serverName); - var appIds = await _graphApiService.TryFindApplicationAppIdsByDisplayNameAsync(tenantId, displayName, ct); + var lookup = await _graphApiService.TryFindApplicationAppIdsByDisplayNameAsync( + tenantId, displayName, ct, UseDeviceCodeAuthentication); + var appIds = lookup.AppIds; if (appIds is null) { // A failed read is not an absent application — reporting "not found" would send the // user off to create an application that may already exist (issue #500). - if (string.IsNullOrWhiteSpace(await _graphApiService.GetGraphAccessTokenAsync(tenantId, ct: ct))) + if (lookup.SignInFailed) { _logger.LogError("Could not sign in to tenant {TenantId}, so '{DisplayName}' could not be looked up.", tenantId, displayName); } @@ -79,19 +79,22 @@ public McpServerPermissionService( } // Display names are not unique, so granting against an arbitrary match could hand the - // agent access to a different MCP server than the caller named. + // agent access to a different MCP server than the caller named. The lookup fetches one + // past its limit, so an overflow is reported as ambiguous rather than silently truncated. if (appIds.Count > 1) { + var shown = appIds.Take(GraphApiService.ApplicationDisplayNameMatchLimit).ToList(); + var suffix = appIds.Count > GraphApiService.ApplicationDisplayNameMatchLimit ? ", ..." : ""; _logger.LogError( - "Tenant {TenantId} has {Count} applications named '{DisplayName}' ({AppIds}). Rename or remove the duplicates so the MCP server resolves to one application.", - tenantId, appIds.Count, displayName, string.Join(", ", appIds)); + "Tenant {TenantId} has {Count} applications named '{DisplayName}' ({AppIds}{Suffix}). Rename or remove the duplicates so the MCP server resolves to one application.", + tenantId, appIds.Count, displayName, string.Join(", ", shown), suffix); return null; } var appId = appIds[0]; var spObjectId = await _graphApiService.LookupServicePrincipalByAppIdAsync( - tenantId, appId, ct, AuthenticationConstants.RequiredPermissionGrantScopes); + tenantId, appId, ct, AuthenticationConstants.RequiredPermissionGrantScopes, UseDeviceCodeAuthentication); if (string.IsNullOrWhiteSpace(spObjectId)) { _logger.LogError( @@ -120,14 +123,16 @@ public virtual async Task> GetAgent ArgumentException.ThrowIfNullOrWhiteSpace(blueprintId); ArgumentException.ThrowIfNullOrWhiteSpace(resourceSpObjectId); - var instances = await _blueprintService.GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, ct); + var instances = await _blueprintService.GetAgentInstancesForBlueprintAsync( + tenantId, blueprintId, ct, UseDeviceCodeAuthentication); var statuses = new List(instances.Count); foreach (var instance in instances) { ct.ThrowIfCancellationRequested(); - var grants = await _graphApiService.TryGetOauth2PermissionGrantsAsync(tenantId, instance.IdentitySpId, ct); + var grants = await _graphApiService.TryGetOauth2PermissionGrantsAsync( + tenantId, instance.IdentitySpId, ct, UseDeviceCodeAuthentication); if (grants is null) { // An empty list is also what a failed read returns, and reporting that as "missing" @@ -168,7 +173,11 @@ public virtual async Task GrantServerScopeAsync( resourceSpObjectId, [McpConstants.V2ScopeValue], ct, - AuthenticationConstants.RequiredPermissionGrantScopes); + AuthenticationConstants.RequiredPermissionGrantScopes, + // Never patch a user-scoped grant in place of the tenant-wide one this command + // reports on, or the agent would still be missing the permission (issue #500). + requireMatchingConsentType: true, + UseDeviceCodeAuthentication); } /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs index a2ee275f..562c3243 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs @@ -798,7 +798,8 @@ public override Task GraphPatchAsync( string relativePath, object payload, CancellationToken ct = default, - IEnumerable? scopes = null) + IEnumerable? scopes = null, + bool useDeviceCode = false) => Task.FromException(new HttpRequestException("Network error during PATCH")); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs index 31f4f2b7..26049859 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs @@ -802,44 +802,11 @@ public TestableAuthenticationService( _deviceCodeCredential = deviceCodeCredential; } - public string? LastDeviceCodeLoginHint { get; private set; } - - public bool LastDeviceCodeForceRefresh { get; private set; } - protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) => _browserCredential; - protected override TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) - { - LastDeviceCodeLoginHint = loginHint; - LastDeviceCodeForceRefresh = forceRefresh; - return _deviceCodeCredential; - } - } - - [Fact] - public async Task GetAccessTokenAsync_DeviceCode_PassesLoginHintAndForceRefreshToTheCredential() - { - // Device code must preserve the same identity and refresh semantics as the browser flow: - // without the login hint MSAL can silently return a different cached account's token, and - // without forceRefresh the caller's explicit refresh request is ignored. - var browserCredential = new StubTokenCredential("unused", DateTimeOffset.UtcNow.AddHours(1)); - var deviceCodeCredential = new StubTokenCredential("device-token", DateTimeOffset.UtcNow.AddHours(1)); - var logger = Substitute.For>(); - var sut = new TestableAuthenticationService(logger, browserCredential, deviceCodeCredential); - - await sut.GetAccessTokenAsync( - "https://graph.microsoft.com", - "11111111-1111-1111-1111-111111111111", - forceRefresh: true, - useInteractiveBrowser: false, - userId: "user@contoso.com"); - - sut.LastDeviceCodeLoginHint.Should().Be( - "user@contoso.com", - because: "device code must target the requested account, not whichever account MSAL happens to have cached"); - sut.LastDeviceCodeForceRefresh.Should().BeTrue( - because: "an explicit forceRefresh must bypass the MSAL silent cache on the device-code path too"); + protected override TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) + => _deviceCodeCredential; } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index 85551773..fd83d9a1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -932,12 +932,9 @@ public async Task GetGraphAccessTokenAsync_WithDeviceCode_UsesTheGraphCommandLin var service = new GraphApiService( Substitute.For>(), Substitute.For(Substitute.For>()), - auth, handler, loginHintResolver: () => Task.FromResult(null)) - { - UseDeviceCodeAuthentication = true - }; + auth, handler, loginHintResolver: () => Task.FromResult(null)); - await service.GetGraphAccessTokenAsync("tenant-123"); + await service.GetGraphAccessTokenAsync("tenant-123", useDeviceCode: true); await auth.Received(1).GetAccessTokenAsync( Arg.Any(), Arg.Any(), Arg.Any(), @@ -981,7 +978,7 @@ public async Task TryFindApplicationAppIdsByDisplayNameAsync_MultipleMatches_Ret var service = CreateServiceWithToken(handler); - var appIds = await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + var appIds = (await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO")).AppIds; appIds.Should().BeEquivalentTo(["app-id-1", "app-id-2"], because: "every match must be returned so callers can detect an ambiguous display name rather than granting against whichever app Graph happened to list first"); @@ -995,7 +992,7 @@ public async Task TryFindApplicationAppIdsByDisplayNameAsync_WhenTheReadFails_Re var service = CreateServiceWithToken(handler); - var appIds = await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + var appIds = (await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO")).AppIds; appIds.Should().BeNull( because: "a failed read is not an absent application - conflating them tells the user to create an app that may already exist"); @@ -1009,7 +1006,7 @@ public async Task TryFindApplicationAppIdsByDisplayNameAsync_WhenNothingMatches_ var service = CreateServiceWithToken(handler); - var appIds = await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + var appIds = (await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO")).AppIds; appIds.Should().NotBeNull( because: "a successful read that found nothing is a conclusive answer and must be distinguishable from a failed read"); @@ -1017,17 +1014,22 @@ public async Task TryFindApplicationAppIdsByDisplayNameAsync_WhenNothingMatches_ } [Fact] - public async Task FindApplicationAppIdsByDisplayNameAsync_WhenTheReadFails_ReturnsEmptyForLegacyCallers() + public async Task TryFindApplicationAppIdsByDisplayNameAsync_RequestsOnePastTheLimit_SoTruncationIsDetectable() { + // $top must exceed the reported limit, or a tenant with exactly one more duplicate than the + // page size would come back looking unambiguous and the caller would grant against the + // wrong application. using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StringContent("{}") }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"value\":[]}") }); var service = CreateServiceWithToken(handler); - var appIds = await service.FindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); - appIds.Should().BeEmpty( - because: "existing callers treat a failed lookup as 'no application' and must keep that behaviour unchanged"); + handler.RequestUris.Should().ContainSingle() + .Which.Query.Should().Contain( + $"$top={GraphApiService.ApplicationDisplayNameMatchLimit + 1}", + because: "fetching one past the limit is what makes an over-limit result detectable as ambiguous instead of silently truncated"); } private static GraphApiService CreateServiceWithToken(HttpMessageHandler handler) @@ -1044,11 +1046,11 @@ private static GraphApiService CreateServiceWithToken(HttpMessageHandler handler } [Fact] - public async Task CreateOrUpdateOauth2PermissionGrantAsync_AllPrincipals_DoesNotPatchAPrincipalScopedGrant() + public async Task CreateOrUpdateOauth2PermissionGrantAsync_WhenConsentTypeIsRequired_DoesNotPatchAPrincipalScopedGrant() { // A Principal-scoped grant for the same client and resource is what 'setup --authmode obo' // creates. Patching it would report success while the requested tenant-wide grant never - // exists, so the lookup must be constrained to AllPrincipals and a new grant POSTed. + // exists, so callers that opt in constrain the lookup to AllPrincipals and POST a new grant. var requests = new List<(string Method, string Uri)>(); using var handler = new CapturingHttpMessageHandler(r => requests.Add((r.Method.Method, r.RequestUri!.ToString()))); @@ -1065,7 +1067,8 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync_AllPrincipals_DoesNot loginHintResolver: () => Task.FromResult(null)); var result = await service.CreateOrUpdateOauth2PermissionGrantAsync( - "tenant-123", "client-sp", "resource-sp", ["Tools.ListInvoke.All"]); + "tenant-123", "client-sp", "resource-sp", ["Tools.ListInvoke.All"], + requireMatchingConsentType: true); result.Should().BeTrue(); requests[0].Uri.Should().Contain( @@ -1076,6 +1079,38 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync_AllPrincipals_DoesNot because: "an unrelated Principal grant must not be patched in place of creating the tenant-wide grant"); } + [Fact] + public async Task CreateOrUpdateOauth2PermissionGrantAsync_ByDefault_StillPatchesAnExistingGrant() + { + // setup and create-instance rely on the historical behaviour of patching whatever grant row + // exists for the client/resource pair. That must stay untouched: only callers that opt in + // to requireMatchingConsentType get the stricter AllPrincipals lookup. + var requests = new List<(string Method, string Uri)>(); + using var handler = new CapturingHttpMessageHandler(r => requests.Add((r.Method.Method, r.RequestUri!.ToString()))); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[{\"id\":\"principal-grant-id\",\"consentType\":\"Principal\",\"scope\":\"User.Read\"}]}") + }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); + + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + var service = new GraphApiService(logger, executor, FakeAuthReturning("fake-token"), handler, + loginHintResolver: () => Task.FromResult(null)); + + var result = await service.CreateOrUpdateOauth2PermissionGrantAsync( + "tenant-123", "client-sp", "resource-sp", ["Tools.ListInvoke.All"]); + + result.Should().BeTrue(); + requests[0].Uri.Should().NotContain( + "consentType", + because: "the default lookup must keep its original filter so existing setup flows are unaffected"); + requests[1].Method.Should().Be( + "PATCH", + because: "existing callers must keep patching the grant row they find rather than creating a second one"); + } + [Fact] public async Task IsCurrentUserAdminAsync_UserWithGlobalAdminRole_ReturnsHasRole() { @@ -1501,12 +1536,9 @@ public async Task GetGraphAccessTokenAsync_DeviceCodeWithoutAzCli_ReadsTheCacheF var service = new GraphApiService( Substitute.For>(), Substitute.For(Substitute.For>()), - auth, handler) - { - UseDeviceCodeAuthentication = true - }; + auth, handler); - await service.GetGraphAccessTokenAsync("tenant-123"); + await service.GetGraphAccessTokenAsync("tenant-123", useDeviceCode: true); await auth.Received(1).ResolveLoginHintFromCacheAsync(AuthenticationConstants.GraphPowershellClientId); } @@ -1557,11 +1589,16 @@ internal class TestHttpMessageHandler : HttpMessageHandler public int RequestCount { get; private set; } + /// Request URIs seen, so tests can assert on the query string the service built. + public List RequestUris { get; } = []; + public void QueueResponse(HttpResponseMessage resp) => _responses.Enqueue(resp); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { RequestCount++; + if (request.RequestUri is { } uri) + RequestUris.Add(uri); if (_responses.Count == 0) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("") }); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index fad70365..8a106b81 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -45,12 +45,8 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin { var logger = new CapturingLogger(); var service = new McpServerPermissionService(_graph, _blueprintService, logger); - // Null means the read itself failed, which is the only way to reach the sign-in probe. - // An empty list is "no such application" and would never exercise this branch. - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>(null)); - _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult(null, SignInFailed: true))); var result = await service.ResolveServerResourceAsync(TenantId, ServerName); @@ -59,13 +55,15 @@ public async Task ResolveServerResourceAsync_ReportsSignInFailure_WithoutClaimin because: "a failed sign-in must be reported as such, not as a missing application"); logger.Messages.Should().NotContain(m => m.Contains("was found"), because: "telling the user the application does not exist would send them off to create one that may already exist"); + await _graph.DidNotReceive().GetGraphAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ResolveServerResourceAsync_DoesNotAcquireATokenOnTheSuccessPath() { - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>([ByoAppId])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([ByoAppId], SignInFailed: false))); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(ByoSpObjectId)); @@ -78,8 +76,8 @@ await _graph.DidNotReceive().GetGraphAccessTokenAsync( [Fact] public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() { - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>([ByoAppId])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([ByoAppId], SignInFailed: false))); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(ByoSpObjectId)); @@ -96,8 +94,8 @@ public async Task ResolveServerResourceAsync_LooksUpTheByoSuffixedApplication() [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenApplicationNotFound() { - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>([])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([], SignInFailed: false))); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); @@ -113,10 +111,8 @@ public async Task ResolveServerResourceAsync_WhenTheReadFails_DoesNotReportTheAp { var logger = new CapturingLogger(); var service = new McpServerPermissionService(_graph, _blueprintService, logger); - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>(null)); - _graph.GetGraphAccessTokenAsync(TenantId, Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("token")); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult(null, SignInFailed: false))); var result = await service.ResolveServerResourceAsync(TenantId, ServerName); @@ -125,15 +121,17 @@ public async Task ResolveServerResourceAsync_WhenTheReadFails_DoesNotReportTheAp because: "a directory read the caller is not authorized for must be reported as a permission problem"); logger.Messages.Should().NotContain(m => m.Contains("was found"), because: "an unreadable directory is not evidence the application is missing"); + await _graph.DidNotReceive().GetGraphAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any()); } [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMissing() { - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>([ByoAppId])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([ByoAppId], SignInFailed: false))); _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(null)); @@ -146,8 +144,8 @@ public async Task ResolveServerResourceAsync_ReturnsNull_WhenServicePrincipalMis [Fact] public async Task ResolveServerResourceAsync_ReturnsNull_WhenMultipleApplicationsShareTheDisplayName() { - _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any()) - .Returns(Task.FromResult?>([ByoAppId, "99999999-9999-9999-9999-999999999999"])); + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([ByoAppId, "99999999-9999-9999-9999-999999999999"], SignInFailed: false))); var result = await _service.ResolveServerResourceAsync(TenantId, ServerName); @@ -273,12 +271,18 @@ public async Task GrantServerScopeAsync_RequestsTheMcpServerScope() _graph.CreateOrUpdateOauth2PermissionGrantAsync( TenantId, AgentSpId, ByoSpObjectId, Arg.Is>(s => s.SequenceEqual(new[] { McpConstants.V2ScopeValue })), - Arg.Any(), Arg.Any?>()) + Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); var granted = await _service.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId); granted.Should().BeTrue(); + + await _graph.Received(1).CreateOrUpdateOauth2PermissionGrantAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any(), Arg.Any?>(), + true, Arg.Any()); } [Fact] @@ -286,7 +290,8 @@ public async Task GrantServerScopeAsync_ReturnsFalse_WhenGraphRejectsTheGrant() { _graph.CreateOrUpdateOauth2PermissionGrantAsync( Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any>(), Arg.Any(), Arg.Any?>()) + Arg.Any>(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); var granted = await _service.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId); From b033d2aed741cd096123d65263977e3da0a59064 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Wed, 23 Sep 2026 09:28:32 -0700 Subject: [PATCH 17/20] Correct CHANGELOG for the scoped consent-type and device-code changes The tenant-wide grant fix is now opt-in and no longer alters setup or create-instance, and the device-code fix applies only to the new command. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 508ece2e..c482da1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,8 +63,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed -- `setup` and `create-instance` now create the tenant-wide permission grant when only a user-scoped grant exists, instead of reporting success without it; this may require a Global Administrator (#500). -- Device code sign-in no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed (#500). +- `a365 develop-mcp grant-agents-access --device-code` no longer prompts repeatedly within a single command, and no longer fails in embedded or remote terminals where the sign-in prompt could not be displayed (#500). - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). From a389d29c5e1416cb23dad60a6a61451a0e0503d6 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Wed, 23 Sep 2026 09:42:44 -0700 Subject: [PATCH 18/20] Return every matching application and let the user choose Drop the \ cap on the MCP server application lookup and page through @odata.nextLink instead, so no match is hidden from the user. When more than one application shares the MCP server's display name, list them all and prompt for which to use rather than failing outright. With input redirected there is nobody to ask, so that remains an error: display names are not unique and guessing could grant against the wrong server. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../McpServerPermissionsSubcommands.cs | 46 +++++++++++++- .../Services/GraphApiService.cs | 63 ++++++++++--------- .../Services/McpServerPermissionService.cs | 40 ++++++++---- .../McpServerPermissionsSubcommandsTests.cs | 12 ++-- .../Services/GraphApiServiceTests.cs | 47 +++++++++++--- .../McpServerPermissionServiceTests.cs | 36 +++++++++++ 7 files changed, 187 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c482da1c..d6ab957a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g ### Added - `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500). +- When more than one Entra application shares the MCP server's name, `a365 develop-mcp grant-agents-access` now lists them all and asks which one to use instead of failing (#500). - `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere (#500). - `--device-code` option on `a365 develop-mcp grant-agents-access` — signs in with a device code instead of the browser or Windows sign-in dialog, for embedded and remote terminals (#500). - `--dry-run` option on `a365 develop-mcp grant-agents-access` — lists the agent instances that are missing the permission without granting it (#500). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs index 33478cf9..d409963e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/McpServerPermissionsSubcommands.cs @@ -98,7 +98,9 @@ public static Command CreateGrantAgentsAccessSubcommand( return; } - var resource = await permissionService.ResolveServerResourceAsync(tenantId, serverName, ct); + var resource = await permissionService.ResolveServerResourceAsync( + tenantId, serverName, ct, + selectAppId: candidates => SelectServerApplication(candidates, serverName, logger, ct)); if (resource is null) { context.ExitCode = 1; @@ -173,6 +175,48 @@ public static Command CreateGrantAgentsAccessSubcommand( return command; } + /// + /// Prompts for which application to use when several share the MCP server's display name. + /// Returns null when there is nobody to ask or the response is not a valid choice, so an + /// ambiguous name is never resolved by guessing. + /// + private static string? SelectServerApplication( + IReadOnlyList candidates, + string serverName, + ILogger logger, + CancellationToken ct) + { + logger.LogWarning("MCP server '{ServerName}' matches {Count} Entra applications:", serverName, candidates.Count); + for (int i = 0; i < candidates.Count; i++) + { + logger.LogInformation(" [{Index}] {AppId}", i + 1, candidates[i]); + } + logger.LogInformation(""); + + if (ConsoleHelper.IsInputRedirected) + { + logger.LogError("Input is redirected, so the application cannot be selected interactively. Rename or remove the duplicates so '{ServerName}' resolves to one application.", serverName); + return null; + } + + Console.Write($"Enter the number of the application to use (1-{candidates.Count}), or press Enter to cancel: "); + var response = ConsoleHelper.ReadLineCancellable(ct)?.Trim(); + + if (string.IsNullOrWhiteSpace(response)) + { + logger.LogError("No application selected, so no permissions were granted."); + return null; + } + + if (!int.TryParse(response, out var index) || index < 1 || index > candidates.Count) + { + logger.LogError("Invalid selection '{Response}'. Enter a number between 1 and {Max}.", response, candidates.Count); + return null; + } + + return candidates[index - 1]; + } + /// /// Determines which instances to grant: all when --yes is set, the user's selection when a /// terminal is attached, or none when input is redirected and there is nobody to prompt. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 8547164d..0d59a4a9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -54,12 +54,6 @@ public class GraphApiService /// public string? CustomClientAppId { get; set; } - /// - /// Most matches reports. Anything - /// beyond this is already ambiguous, so paging further would not change the caller's decision. - /// - public const int ApplicationDisplayNameMatchLimit = 10; - /// /// Override the Microsoft Graph base URL for sovereign / government cloud tenants. /// Defaults to (commercial cloud). @@ -953,8 +947,8 @@ public virtual async Task ApplicationExistsByAppIdAsync( public sealed record ApplicationDisplayNameSearchResult(IReadOnlyList? AppIds, bool SignInFailed); /// - /// Finds applications whose display name matches exactly, up to - /// . Display names are not unique in Entra, so + /// Finds every application whose display name matches exactly, following + /// @odata.nextLink so no match is omitted. Display names are not unique in Entra, so /// callers that act on the result must decide what an ambiguous match means rather than /// silently taking the first. A null AppIds means the read itself failed (no token, /// 403, or a transport error), so callers can tell "no such application" apart from "we could @@ -969,38 +963,47 @@ public virtual async Task TryFindApplication // OData requires single quotes to be escaped by doubling them: ' → '' var escaped = displayName.Replace("'", "''", StringComparison.Ordinal); - // One past the limit so a truncated page is detectable: callers only need to distinguish - // none / exactly one / more than one, and any overflow is reported as ambiguous. var url = GraphApiConstants.BuildUrl(_graphBaseUrl, - $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top={ApplicationDisplayNameMatchLimit + 1}&$count=true"); + $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$count=true"); try { - using var request = new HttpRequestMessage(HttpMethod.Get, url); - // Copy auth header set by EnsureGraphHeadersAsync onto the shared _httpClient - if (_httpClient.DefaultRequestHeaders.Authorization is { } auth) - request.Headers.Authorization = auth; - // Required for advanced query filters (displayName eq) - request.Headers.TryAddWithoutValidation("ConsistencyLevel", "eventual"); + var appIds = new List(); + string? next = url; + // Guards against a cycle if Graph ever echoes a nextLink back. + var visited = new HashSet(StringComparer.Ordinal); - using var resp = await _httpClient.SendAsync(request, ct); - if (!resp.IsSuccessStatusCode) + while (next is not null && visited.Add(next)) { - _logger.LogDebug("FindApplicationAppIdsByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); - return new ApplicationDisplayNameSearchResult(null, SignInFailed: false); - } + using var request = new HttpRequestMessage(HttpMethod.Get, next); + // Copy auth header set by EnsureGraphHeadersAsync onto the shared _httpClient + if (_httpClient.DefaultRequestHeaders.Authorization is { } auth) + request.Headers.Authorization = auth; + // Required for advanced query filters (displayName eq) + request.Headers.TryAddWithoutValidation("ConsistencyLevel", "eventual"); - using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); - if (!doc.RootElement.TryGetProperty("value", out var value)) - return new ApplicationDisplayNameSearchResult([], SignInFailed: false); + using var resp = await _httpClient.SendAsync(request, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("FindApplicationAppIdsByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); + return new ApplicationDisplayNameSearchResult(null, SignInFailed: false); + } - var appIds = new List(value.GetArrayLength()); - foreach (var app in value.EnumerateArray()) - { - if (app.TryGetProperty("appId", out var appId) && appId.GetString() is { Length: > 0 } id) + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + if (!doc.RootElement.TryGetProperty("value", out var value)) + break; + + foreach (var app in value.EnumerateArray()) { - appIds.Add(id); + if (app.TryGetProperty("appId", out var appId) && appId.GetString() is { Length: > 0 } id) + { + appIds.Add(id); + } } + + next = doc.RootElement.TryGetProperty("@odata.nextLink", out var link) + ? link.GetString() + : null; } return new ApplicationDisplayNameSearchResult(appIds, SignInFailed: false); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index a7dac727..80ebd6d5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -40,10 +40,16 @@ public McpServerPermissionService( /// which is the resourceId of the permission grant. Returns null when either the application /// or its service principal cannot be found. /// + /// + /// Invoked when more than one application shares the display name, to choose between them. + /// Returning null aborts. When not supplied, an ambiguous name is an error: display names are + /// not unique, so picking one unattended could grant against a different MCP server. + /// public virtual async Task ResolveServerResourceAsync( string tenantId, string serverName, - CancellationToken ct = default) + CancellationToken ct = default, + Func, string?>? selectAppId = null) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(serverName); @@ -79,19 +85,31 @@ public McpServerPermissionService( } // Display names are not unique, so granting against an arbitrary match could hand the - // agent access to a different MCP server than the caller named. The lookup fetches one - // past its limit, so an overflow is reported as ambiguous rather than silently truncated. + // agent access to a different MCP server than the caller named. Every match is listed for + // the caller to choose from; with nobody to ask, this is an error rather than a guess. + string appId; if (appIds.Count > 1) { - var shown = appIds.Take(GraphApiService.ApplicationDisplayNameMatchLimit).ToList(); - var suffix = appIds.Count > GraphApiService.ApplicationDisplayNameMatchLimit ? ", ..." : ""; - _logger.LogError( - "Tenant {TenantId} has {Count} applications named '{DisplayName}' ({AppIds}{Suffix}). Rename or remove the duplicates so the MCP server resolves to one application.", - tenantId, appIds.Count, displayName, string.Join(", ", shown), suffix); - return null; - } + if (selectAppId is null) + { + _logger.LogError( + "Tenant {TenantId} has {Count} applications named '{DisplayName}' ({AppIds}). Rename or remove the duplicates so the MCP server resolves to one application.", + tenantId, appIds.Count, displayName, string.Join(", ", appIds)); + return null; + } - var appId = appIds[0]; + var chosen = selectAppId(appIds); + if (chosen is null) + { + return null; + } + + appId = chosen; + } + else + { + appId = appIds[0]; + } var spObjectId = await _graphApiService.LookupServicePrincipalByAppIdAsync( tenantId, appId, ct, AuthenticationConstants.RequiredPermissionGrantScopes, UseDeviceCodeAuthentication); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs index 2c360338..98fe6723 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/McpServerPermissionsSubcommandsTests.cs @@ -45,7 +45,7 @@ private static McpServerResource Resource() => new(ServerName, McpConstants.BuildByoAppDisplayName(ServerName), ByoAppId, ByoSpObjectId); private void SetupResolvedResource() => - _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) + _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any(), Arg.Any, string?>?>()) .Returns(Task.FromResult(Resource())); private void SetupInstances(params AgentInstancePermissionStatus[] statuses) => @@ -65,7 +65,7 @@ public async Task GrantAgentsAccess_NoBlueprintSpecified_ExitsWithOne() because: "the blueprint selects which agent instances are checked, so without it there " + "is no work to do and silently succeeding would hide the mistake"); await _permissionService.DidNotReceive().ResolveServerResourceAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any, string?>?>()); } [Fact] @@ -98,7 +98,7 @@ public async Task GrantAgentsAccess_NonGuidBlueprintId_ExitsWithOne() exitCode.Should().Be(1, because: "the blueprint ID is interpolated into a Graph OData filter, so a non-GUID must be rejected before any request is made"); await _permissionService.DidNotReceive().ResolveServerResourceAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any, string?>?>()); } [Fact] @@ -131,7 +131,7 @@ public async Task GrantAgentsAccess_WhitespaceTenantId_ExitsWithOne() because: "an explicitly blank --tenant-id must not fall through to Azure CLI detection, " + "which would grant tenant-wide access in whatever tenant az happens to be signed into"); await _permissionService.DidNotReceive().ResolveServerResourceAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any, string?>?>()); } [Fact] @@ -145,7 +145,7 @@ public async Task GrantAgentsAccess_ServerNameOutsideAllowlist_ExitsWithOne() because: "the server name is interpolated into a Graph OData filter, so it must pass the " + "same allowlist register-external-mcp-server applies rather than any non-blank string"); await _permissionService.DidNotReceive().ResolveServerResourceAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any, string?>?>()); } [Fact] @@ -217,7 +217,7 @@ public async Task GrantAgentsAccess_WhitespaceServerName_ExitsWithOne() [Fact] public async Task GrantAgentsAccess_UnresolvableServer_ExitsWithOne() { - _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any()) + _permissionService.ResolveServerResourceAsync(TenantId, ServerName, Arg.Any(), Arg.Any, string?>?>()) .Returns(Task.FromResult(null)); var exitCode = await ListCommand().InvokeAsync( diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index fd83d9a1..8b463ea6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -1014,22 +1014,49 @@ public async Task TryFindApplicationAppIdsByDisplayNameAsync_WhenNothingMatches_ } [Fact] - public async Task TryFindApplicationAppIdsByDisplayNameAsync_RequestsOnePastTheLimit_SoTruncationIsDetectable() + public async Task TryFindApplicationAppIdsByDisplayNameAsync_FollowsNextLink_SoNoMatchIsOmitted() { - // $top must exceed the reported limit, or a tenant with exactly one more duplicate than the - // page size would come back looking unambiguous and the caller would grant against the - // wrong application. + // The caller lists every match for the user to choose from, so a truncated result would + // hide the application they meant and let them grant against the wrong MCP server. using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"value\":[]}") }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[{\"appId\":\"app-1\"},{\"appId\":\"app-2\"}],\"@odata.nextLink\":\"https://graph.microsoft.com/v1.0/applications?$skiptoken=page2\"}") + }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[{\"appId\":\"app-3\"}]}") + }); + + var service = CreateServiceWithToken(handler); + + var appIds = (await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO")).AppIds; + + appIds.Should().BeEquivalentTo(["app-1", "app-2", "app-3"], + because: "every page of matches must be returned so the user can choose between duplicates"); + handler.RequestUris.Should().HaveCount(2); + handler.RequestUris[0].Query.Should().NotContain( + "$top", + because: "capping the query would silently drop matches the user needs to choose from"); + } + + [Fact] + public async Task TryFindApplicationAppIdsByDisplayNameAsync_StopsWhenNextLinkRepeats() + { + // A nextLink echoing itself back would otherwise page forever. + using var handler = new TestHttpMessageHandler(); + var selfReferencing = "{\"value\":[{\"appId\":\"app-1\"}],\"@odata.nextLink\":\"https://graph.microsoft.com/v1.0/applications?$skiptoken=loop\"}"; + for (int i = 0; i < 5; i++) + { + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(selfReferencing) }); + } var service = CreateServiceWithToken(handler); - await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO"); + var appIds = (await service.TryFindApplicationAppIdsByDisplayNameAsync("tenant-123", "Foo - BYO")).AppIds; - handler.RequestUris.Should().ContainSingle() - .Which.Query.Should().Contain( - $"$top={GraphApiService.ApplicationDisplayNameMatchLimit + 1}", - because: "fetching one past the limit is what makes an over-limit result detectable as ambiguous instead of silently truncated"); + appIds.Should().HaveCount(2, because: "the seed page and the repeated page are each read once before the cycle is detected"); + handler.RequestUris.Should().HaveCount(2, because: "a repeated nextLink must terminate paging rather than loop"); } private static GraphApiService CreateServiceWithToken(HttpMessageHandler handler) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index 8a106b81..720a5718 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -156,6 +156,42 @@ await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); } + [Fact] + public async Task ResolveServerResourceAsync_OffersEveryMatch_WhenADisplayNameIsAmbiguous() + { + const string OtherAppId = "99999999-9999-9999-9999-999999999999"; + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([OtherAppId, ByoAppId], SignInFailed: false))); + _graph.LookupServicePrincipalByAppIdAsync(TenantId, ByoAppId, Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(ByoSpObjectId)); + + IReadOnlyList? offered = null; + + var result = await _service.ResolveServerResourceAsync( + TenantId, ServerName, CancellationToken.None, + selectAppId: candidates => { offered = candidates; return ByoAppId; }); + + offered.Should().BeEquivalentTo([OtherAppId, ByoAppId], + because: "the user can only choose correctly if every matching application is offered, not a truncated subset"); + result!.AppId.Should().Be(ByoAppId, + because: "the resolved resource must be the application the user picked, not the first match"); + } + + [Fact] + public async Task ResolveServerResourceAsync_ReturnsNull_WhenTheAmbiguousSelectionIsCancelled() + { + _graph.TryFindApplicationAppIdsByDisplayNameAsync(TenantId, ByoDisplayName, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GraphApiService.ApplicationDisplayNameSearchResult([ByoAppId, "99999999-9999-9999-9999-999999999999"], SignInFailed: false))); + + var result = await _service.ResolveServerResourceAsync( + TenantId, ServerName, CancellationToken.None, selectAppId: _ => null); + + result.Should().BeNull( + because: "declining to choose between duplicates must abort rather than fall back to an arbitrary match"); + await _graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + } + [Fact] public async Task GetAgentInstanceStatusesAsync_Throws_WhenTheGrantReadFails() { From f85290f17ab34c04fe466ea42c1715a988793442 Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Wed, 23 Sep 2026 10:07:42 -0700 Subject: [PATCH 19/20] Honor --device-code on the fallback token path and abort on unreadable grant state EnsureGraphHeadersAsync honored useDeviceCode only in the token-provider branch. The legacy fallback dropped it, so grant-agents-access could launch WAM despite --device-code whenever CustomClientAppId was unresolved. Existing callers all pass false, so the forwarded flag is a no-op for them. A failed grant lookup left existingId null and fell through to POST, where "Permission entry already exists" is reported as success even though the scope was never merged. Gated behind abortWhenLookupFails so only the new command opts in and setup/create-instance keep their current behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/GraphApiService.cs | 23 +++++-- .../Services/McpServerPermissionService.cs | 5 +- .../Services/GraphApiServiceTests.cs | 60 +++++++++++++++++++ .../McpServerPermissionServiceTests.cs | 6 +- 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 0d59a4a9..5d34c2e6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -310,7 +310,7 @@ private async Task EnsureGraphHeadersAsync( // yet (initial app lookup) or when no token provider is configured (tests). Token // does NOT carry custom-app optional claims — callers that depend on those must // not reach this branch. - token = await GetGraphAccessTokenAsync(tenantId, forceRefresh: forceRefresh, ct: ct); + token = await GetGraphAccessTokenAsync(tenantId, forceRefresh: forceRefresh, ct: ct, useDeviceCode: useDeviceCode); if (string.IsNullOrWhiteSpace(token)) { @@ -1107,7 +1107,8 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( CancellationToken ct = default, IEnumerable? permissionGrantScopes = null, bool requireMatchingConsentType = false, - bool useDeviceCode = false) + bool useDeviceCode = false, + bool abortWhenLookupFails = false) { var (success, _, _) = await CreateOrUpdateOauth2PermissionGrantCoreAsync( tenantId, @@ -1119,7 +1120,8 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( ct, permissionGrantScopes, requireMatchingConsentType, - useDeviceCode); + useDeviceCode, + abortWhenLookupFails); return success; } @@ -1199,7 +1201,8 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( CancellationToken ct, IEnumerable? permissionGrantScopes, bool requireMatchingConsentType = false, - bool useDeviceCode = false) + bool useDeviceCode = false, + bool abortWhenLookupFails = false) { int lastStatusCode = 0; string? lastErrorCode = null; @@ -1226,6 +1229,18 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( permissionGrantScopes, useDeviceCode)) { + if (listDoc is null && abortWhenLookupFails) + { + // A failed read is not "no grant". Creating from unknown state can return + // "Permission entry already exists", which the POST path below reports as + // success even though the desired scope was never merged (issue #500). + _logger.LogError( + "Could not read the existing permission grants for client {ClientSpId} on resource {ResourceSpId}. " + + "No grant was attempted because the current state is unknown.", + clientSpObjectId, resourceSpObjectId); + return (false, 0, null); + } + if (listDoc?.RootElement.TryGetProperty("value", out var arr) == true) { if (isPrincipal) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs index 80ebd6d5..7a5e4c63 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/McpServerPermissionService.cs @@ -195,7 +195,10 @@ public virtual async Task GrantServerScopeAsync( // Never patch a user-scoped grant in place of the tenant-wide one this command // reports on, or the agent would still be missing the permission (issue #500). requireMatchingConsentType: true, - UseDeviceCodeAuthentication); + UseDeviceCodeAuthentication, + // A failed read is not "no grant" — creating from unknown state can report success + // on "Permission entry already exists" without merging the scope (issue #500). + abortWhenLookupFails: true); } /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index 8b463ea6..b8555256 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -1138,6 +1138,66 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync_ByDefault_StillPatche because: "existing callers must keep patching the grant row they find rather than creating a second one"); } + [Fact] + public async Task CreateOrUpdateOauth2PermissionGrantAsync_WhenAbortWhenLookupFails_DoesNotPostAfterAFailedRead() + { + // A failed read is not "no grant". Posting from unknown state can return + // "Permission entry already exists", which the POST path reports as success even though + // the desired scope was never merged (issue #500). + var requests = new List<(string Method, string Uri)>(); + using var handler = new CapturingHttpMessageHandler(r => requests.Add((r.Method.Method, r.RequestUri!.ToString()))); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("{\"error\":{\"code\":\"Authorization_RequestDenied\"}}") + }); + + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + var service = new GraphApiService(logger, executor, FakeAuthReturning("fake-token"), handler, + loginHintResolver: () => Task.FromResult(null)); + + var result = await service.CreateOrUpdateOauth2PermissionGrantAsync( + "tenant-123", "client-sp", "resource-sp", ["Tools.ListInvoke.All"], + abortWhenLookupFails: true); + + result.Should().BeFalse( + because: "an unreadable grant state must be reported as a failure, not silently treated as success"); + requests.Should().ContainSingle( + because: "no grant may be attempted when the current state is unknown"); + } + + [Fact] + public async Task CreateOrUpdateOauth2PermissionGrantAsync_ByDefault_StillPostsAfterAFailedRead() + { + // setup and create-instance must keep their historical create-on-empty-read behaviour: + // only callers that opt in to abortWhenLookupFails get the stricter handling. + var requests = new List<(string Method, string Uri)>(); + using var handler = new CapturingHttpMessageHandler(r => requests.Add((r.Method.Method, r.RequestUri!.ToString()))); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("{\"error\":{\"code\":\"Authorization_RequestDenied\"}}") + }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent("{\"id\":\"new-grant\"}") + }); + + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + var service = new GraphApiService(logger, executor, FakeAuthReturning("fake-token"), handler, + loginHintResolver: () => Task.FromResult(null)); + + var result = await service.CreateOrUpdateOauth2PermissionGrantAsync( + "tenant-123", "client-sp", "resource-sp", ["Tools.ListInvoke.All"]); + + result.Should().BeTrue(); + requests[1].Method.Should().Be( + "POST", + because: "existing setup flows must keep creating the grant when the read returns nothing"); + } + [Fact] public async Task IsCurrentUserAdminAsync_UserWithGlobalAdminRole_ReturnsHasRole() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs index 720a5718..bc2133f4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/McpServerPermissionServiceTests.cs @@ -308,7 +308,7 @@ public async Task GrantServerScopeAsync_RequestsTheMcpServerScope() TenantId, AgentSpId, ByoSpObjectId, Arg.Is>(s => s.SequenceEqual(new[] { McpConstants.V2ScopeValue })), Arg.Any(), Arg.Any?>(), - Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); var granted = await _service.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId); @@ -318,7 +318,7 @@ public async Task GrantServerScopeAsync_RequestsTheMcpServerScope() await _graph.Received(1).CreateOrUpdateOauth2PermissionGrantAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any?>(), - true, Arg.Any()); + true, Arg.Any(), true); } [Fact] @@ -327,7 +327,7 @@ public async Task GrantServerScopeAsync_ReturnsFalse_WhenGraphRejectsTheGrant() _graph.CreateOrUpdateOauth2PermissionGrantAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any?>(), - Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); var granted = await _service.GrantServerScopeAsync(TenantId, AgentSpId, ByoSpObjectId); From ca26cca15e32e49f92369b969ee9790d44fe65ed Mon Sep 17 00:00:00 2001 From: Rance Lammers Date: Wed, 23 Sep 2026 12:40:05 -0700 Subject: [PATCH 20/20] Route device-code Graph tokens through the cache-aware credential The legacy fallback built a fresh Azure.Identity DeviceCodeCredential per call with no AuthenticationRecord, so every Graph call under --device-code re-prompted for a new code, and it wrote to a different cache store than the scoped calls. Route the device-code path through the token provider, which reads the cache before prompting and shares one store with every other call. Gated on useDeviceCode, which only the new grant-agents-access path sets, so all pre-existing callers keep the AuthenticationService path unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/GraphApiService.cs | 9 +++- .../Services/GraphApiServiceTests.cs | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 5d34c2e6..6e963a3b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -186,7 +186,14 @@ public GraphApiService(ILogger logger, CommandExecutor executor var resource = GraphApiConstants.GetResource(_graphBaseUrl); var clientId = GetEffectiveGraphClientId(useDeviceCode); var loginHint = await _loginHintResolver(clientId); - var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, clientId: clientId, useInteractiveBrowser: !useDeviceCode, userId: loginHint, ct: ct); + + // AuthenticationService builds a fresh DeviceCodeCredential per call with no + // AuthenticationRecord, so it re-prompts for a device code on every acquisition. The + // token provider attempts a silent cache read first, so route device code through it. + var token = useDeviceCode && _tokenProvider != null + ? await _tokenProvider.GetMgGraphAccessTokenAsync( + tenantId, [AuthenticationConstants.UserReadScope], true, null, ct, loginHint, forceRefresh) + : await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, clientId: clientId, useInteractiveBrowser: !useDeviceCode, userId: loginHint, ct: ct); if (!string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("Graph API access token acquired successfully"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index b8555256..5aa769c9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -917,6 +917,58 @@ private static GraphApiService CreateServiceWithNullAuth(TestHttpMessageHandler retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); } + [Fact] + public async Task GetGraphAccessTokenAsync_WithDeviceCodeAndTokenProvider_RoutesThroughTheProviderSoTheCacheIsReadFirst() + { + using var handler = new TestHttpMessageHandler(); + var auth = Substitute.For(); + var tokenProvider = Substitute.For(); + tokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + + var service = new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler, tokenProvider, loginHintResolver: () => Task.FromResult(null)); + + var token = await service.GetGraphAccessTokenAsync("tenant-123", useDeviceCode: true); + + token.Should().Be("fake-token"); + await tokenProvider.Received(1).GetMgGraphAccessTokenAsync( + "tenant-123", Arg.Any>(), true, null, + Arg.Any(), Arg.Any(), Arg.Any()); + await auth.DidNotReceiveWithAnyArgs().GetAccessTokenAsync( + default!, default, default, default, default, default, default, default); + } + + [Fact] + public async Task GetGraphAccessTokenAsync_WithoutDeviceCode_StillUsesTheAuthenticationServiceEvenWhenAProviderExists() + { + // Every pre-existing caller uses the default useDeviceCode: false. This pins that the + // device-code routing cannot divert them onto the token-provider path. + using var handler = new TestHttpMessageHandler(); + var auth = Substitute.For(); + auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("fake-token")); + var tokenProvider = Substitute.For(); + + var service = new GraphApiService( + Substitute.For>(), + Substitute.For(Substitute.For>()), + auth, handler, tokenProvider, loginHintResolver: () => Task.FromResult(null)); + + await service.GetGraphAccessTokenAsync("tenant-123"); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), Arg.Any(), Arg.Any(), null, + Arg.Any?>(), true, Arg.Any(), Arg.Any()); + await tokenProvider.DidNotReceiveWithAnyArgs().GetMgGraphAccessTokenAsync( + default!, default!, default, default, default, default, default); + } + [Fact] public async Task GetGraphAccessTokenAsync_WithDeviceCode_UsesTheGraphCommandLineToolsClient() {