diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2b2eb14..6b1454db 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
+- `develop-mcp get-agent-instances` command lists agent instance service principals for a given blueprint ID.
+- `develop-mcp add-byo-scopes` command grants oauth2 delegated permissions for BYO MCP servers to agent instances.
- 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.
- `a365 develop-mcp evaluate` command for evaluating MCP server tool schema quality — runs deterministic and semantic checks (via GitHub Copilot or Claude Code CLIs), computes maturity scoring, and generates an interactive HTML report
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
index 941b1d10..06f43f78 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands;
using Microsoft.Agents.A365.DevTools.Cli.Models;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Agents.A365.DevTools.Cli.Services.Evaluate;
@@ -22,7 +23,8 @@ public static Command CreateCommand(
ILogger logger,
IAgent365ToolingService toolingService,
IEvaluationPipelineService? evaluationPipelineService = null,
- GraphApiService? graphApiService = null)
+ GraphApiService? graphApiService = null,
+ AgentBlueprintService? agentBlueprintService = null)
{
var developMcpCommand = new Command("develop-mcp", "Manage MCP servers in Dataverse environments");
@@ -40,6 +42,16 @@ public static Command CreateCommand(
developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreateRegisterExternalMcpServerSubcommand(logger, toolingService, graphApiService));
+ if (agentBlueprintService is not null)
+ {
+ developMcpCommand.AddCommand(CreateGetAgentInstancesSubcommand(logger, agentBlueprintService));
+
+ if (graphApiService is not null)
+ {
+ developMcpCommand.AddCommand(CreateAddByoScopesSubcommand(logger, toolingService, agentBlueprintService, graphApiService));
+ }
+ }
+
if (evaluationPipelineService is not null)
{
developMcpCommand.AddCommand(CreateEvaluateSubcommand(logger, evaluationPipelineService));
@@ -635,6 +647,108 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
return command;
}
+ ///
+ /// Creates the get-agent-instances subcommand.
+ ///
+ private static Command CreateGetAgentInstancesSubcommand(
+ ILogger logger,
+ AgentBlueprintService agentBlueprintService)
+ {
+ var command = new Command("get-agent-instances", "List agent instance service principals for a given blueprint");
+
+ var blueprintIdOption = new Option(
+ ["--blueprint-id", "-b"],
+ description: "Agent Identity Blueprint ID (GUID)")
+ {
+ IsRequired = true,
+ };
+ command.AddOption(blueprintIdOption);
+
+ var tenantIdOption = new Option(
+ "--tenant-id",
+ description: "Azure AD tenant ID. Auto-detected from 'az account show' if not provided.");
+ command.AddOption(tenantIdOption);
+
+ command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging"));
+
+ command.SetHandler(async (context) =>
+ {
+ var blueprintId = context.ParseResult.GetValueForOption(blueprintIdOption)!;
+ var tenantId = context.ParseResult.GetValueForOption(tenantIdOption);
+ var ct = context.GetCancellationToken();
+
+ var executor = new GetAgentInstancesExecutor(logger, agentBlueprintService);
+ var success = await executor.ExecuteAsync(blueprintId, tenantId, ct);
+ if (!success)
+ {
+ context.ExitCode = 1;
+ }
+ });
+
+ return command;
+ }
+
+ ///
+ /// Creates the add-byo-scopes subcommand.
+ ///
+ private static Command CreateAddByoScopesSubcommand(
+ ILogger logger,
+ IAgent365ToolingService toolingService,
+ AgentBlueprintService agentBlueprintService,
+ GraphApiService graphApiService)
+ {
+ var command = new Command("add-byo-scopes", "Grant oauth2 delegated permissions for BYO MCP servers to agent instances");
+
+ var serverNamesOption = new Option(
+ ["--server-names", "-s"],
+ description: "Comma-separated list of BYO server names (e.g. ext_server1,ext_server2)")
+ {
+ IsRequired = true,
+ };
+ command.AddOption(serverNamesOption);
+
+ var blueprintIdOption = new Option(
+ ["--blueprint-id", "-b"],
+ description: "Blueprint ID to auto-resolve all agent instances");
+ command.AddOption(blueprintIdOption);
+
+ var agentInstancesOption = new Option(
+ ["--agent-instances", "-i"],
+ description: "Comma-separated list of agent instance SP IDs (alternative to --blueprint-id, or to filter within blueprint)");
+ command.AddOption(agentInstancesOption);
+
+ var tenantIdOption = new Option(
+ "--tenant-id",
+ description: "Azure AD tenant ID. Auto-detected from 'az account show' if not provided.");
+ command.AddOption(tenantIdOption);
+
+ var dryRunOption = new Option(
+ "--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(async (context) =>
+ {
+ var serverNames = context.ParseResult.GetValueForOption(serverNamesOption)!;
+ var blueprintId = context.ParseResult.GetValueForOption(blueprintIdOption);
+ var agentInstances = context.ParseResult.GetValueForOption(agentInstancesOption);
+ var tenantId = context.ParseResult.GetValueForOption(tenantIdOption);
+ var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
+ var ct = context.GetCancellationToken();
+
+ var executor = new AddByoScopesExecutor(logger, toolingService, agentBlueprintService, graphApiService);
+ var success = await executor.ExecuteAsync(serverNames, blueprintId, agentInstances, tenantId, dryRun, ct);
+ if (!success)
+ {
+ context.ExitCode = 1;
+ }
+ });
+
+ return command;
+ }
+
internal static void WriteLabel(string label)
{
var prevColor = Console.ForegroundColor;
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddByoScopesExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddByoScopesExecutor.cs
new file mode 100644
index 00000000..26207d1c
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddByoScopesExecutor.cs
@@ -0,0 +1,433 @@
+// 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.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using System.Text;
+using System.Text.Json;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands;
+
+///
+/// Executor for the add-byo-scopes subcommand.
+/// Grants oauth2PermissionGrants so agent instances can access BYO MCP server PPMI apps.
+///
+internal sealed class AddByoScopesExecutor
+{
+ private readonly ILogger _logger;
+ private readonly IAgent365ToolingService _toolingService;
+ private readonly AgentBlueprintService _blueprintService;
+ private readonly GraphApiService _graphApiService;
+
+ private const string AllPrincipalsConsentType = "AllPrincipals";
+ private const string DefaultScope = "user_impersonation";
+
+ public AddByoScopesExecutor(
+ ILogger logger,
+ IAgent365ToolingService toolingService,
+ AgentBlueprintService blueprintService,
+ GraphApiService graphApiService)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _toolingService = toolingService ?? throw new ArgumentNullException(nameof(toolingService));
+ _blueprintService = blueprintService ?? throw new ArgumentNullException(nameof(blueprintService));
+ _graphApiService = graphApiService ?? throw new ArgumentNullException(nameof(graphApiService));
+ }
+
+ ///
+ /// Executes the add-byo-scopes command.
+ ///
+ public async Task ExecuteAsync(
+ string serverNamesRaw,
+ string? blueprintId,
+ string? agentInstancesRaw,
+ string? tenantId,
+ bool dryRun,
+ CancellationToken cancellationToken = default)
+ {
+ // Validate at least one of --blueprint-id or --agent-instances
+ if (string.IsNullOrWhiteSpace(blueprintId) && string.IsNullOrWhiteSpace(agentInstancesRaw))
+ {
+ _logger.LogError("At least one of --blueprint-id or --agent-instances is required");
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(serverNamesRaw))
+ {
+ _logger.LogError("--server-names is required");
+ return false;
+ }
+
+ // Parse server names
+ var serverNames = serverNamesRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (serverNames.Length == 0)
+ {
+ _logger.LogError("No valid server names provided");
+ return false;
+ }
+
+ // Validate GUID formats
+ if (!string.IsNullOrWhiteSpace(blueprintId) && !Guid.TryParse(blueprintId, out _))
+ {
+ _logger.LogError("Invalid blueprint ID format: {BlueprintId}. Must be a valid GUID.", blueprintId);
+ return false;
+ }
+
+ // Resolve tenant ID
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ tenantId = await ResolveTenantIdAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ _logger.LogError("Could not determine tenant ID. Provide --tenant-id or sign in with 'az login'.");
+ return false;
+ }
+ }
+ else if (!Guid.TryParse(tenantId, out _))
+ {
+ _logger.LogError("Invalid tenant ID format: {TenantId}. Must be a valid GUID.", tenantId);
+ return false;
+ }
+
+ // Resolve agent instances
+ var instanceSpIds = await ResolveAgentInstancesAsync(blueprintId, agentInstancesRaw, tenantId, cancellationToken);
+ if (instanceSpIds == null || instanceSpIds.Count == 0)
+ {
+ _logger.LogError("No agent instances resolved. Verify your --blueprint-id or --agent-instances values.");
+ return false;
+ }
+
+ _logger.LogInformation("Resolved {Count} agent instance(s)", instanceSpIds.Count);
+
+ if (dryRun)
+ {
+ _logger.LogInformation("[DRY RUN] Would create oauth2PermissionGrants for:");
+ foreach (var serverName in serverNames)
+ {
+ _logger.LogInformation(" Server: {ServerName}", serverName);
+ }
+ _logger.LogInformation(" Against {Count} agent instance(s):", instanceSpIds.Count);
+ foreach (var spId in instanceSpIds)
+ {
+ _logger.LogInformation(" {SpId}", spId);
+ }
+ return true;
+ }
+
+ // Process each server
+ var totalGranted = 0;
+ var totalSkipped = 0;
+ var totalFailed = 0;
+
+ foreach (var serverName in serverNames)
+ {
+ _logger.LogInformation("Processing server: {ServerName}...", serverName);
+
+ // Step 1: Get the PPMI app ID from MCP Platform
+ var appIdResponse = await _toolingService.GetMcpServerAppIdByNameAsync(serverName, cancellationToken);
+ if (appIdResponse == null || string.IsNullOrWhiteSpace(appIdResponse.McpServerAppId))
+ {
+ _logger.LogError("Failed to get app ID for server '{ServerName}'. Skipping.", serverName);
+ totalFailed += instanceSpIds.Count;
+ continue;
+ }
+
+ var mcpServerAppId = appIdResponse.McpServerAppId;
+ _logger.LogDebug("MCP server '{ServerName}' PPMI app ID: {AppId}", serverName, mcpServerAppId);
+
+ // Step 2: Resolve the PPMI app's service principal in the tenant
+ var ppmiSpId = await ResolveServicePrincipalIdAsync(tenantId, mcpServerAppId, cancellationToken);
+ if (string.IsNullOrWhiteSpace(ppmiSpId))
+ {
+ _logger.LogError("Could not find service principal for PPMI app {AppId} in tenant {TenantId}. Skipping server '{ServerName}'.",
+ mcpServerAppId, tenantId, serverName);
+ totalFailed += instanceSpIds.Count;
+ continue;
+ }
+
+ _logger.LogDebug("PPMI service principal ID: {SpId}", ppmiSpId);
+
+ // Step 3: Create/update oauth2PermissionGrant for each agent instance
+ foreach (var instanceSpId in instanceSpIds)
+ {
+ var result = await UpsertDelegatedGrantAsync(tenantId, instanceSpId, ppmiSpId, DefaultScope, cancellationToken);
+ switch (result)
+ {
+ case GrantResult.Created:
+ case GrantResult.Updated:
+ totalGranted++;
+ _logger.LogInformation(" Granted scope '{Scope}' on server '{ServerName}' to instance {InstanceSpId}",
+ DefaultScope, serverName, instanceSpId);
+ break;
+ case GrantResult.AlreadyExists:
+ totalSkipped++;
+ _logger.LogDebug(" Scope '{Scope}' already granted on server '{ServerName}' to instance {InstanceSpId}",
+ DefaultScope, serverName, instanceSpId);
+ break;
+ case GrantResult.Failed:
+ totalFailed++;
+ _logger.LogError(" Failed to grant scope on server '{ServerName}' to instance {InstanceSpId}",
+ serverName, instanceSpId);
+ break;
+ }
+ }
+ }
+
+ // Summary
+ _logger.LogInformation("");
+ _logger.LogInformation("Summary: {Granted} granted, {Skipped} already existed, {Failed} failed",
+ totalGranted, totalSkipped, totalFailed);
+
+ return totalFailed == 0;
+ }
+
+ private async Task?> ResolveAgentInstancesAsync(
+ string? blueprintId,
+ string? agentInstancesRaw,
+ string tenantId,
+ CancellationToken cancellationToken)
+ {
+ List? blueprintInstances = null;
+ HashSet? filterSet = null;
+
+ // Parse explicit agent instance list
+ if (!string.IsNullOrWhiteSpace(agentInstancesRaw))
+ {
+ var parsed = agentInstancesRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ foreach (var id in parsed)
+ {
+ if (!Guid.TryParse(id, out _))
+ {
+ _logger.LogError("Invalid agent instance SP ID format: {Id}. Must be a valid GUID.", id);
+ return null;
+ }
+ }
+ filterSet = new HashSet(parsed, StringComparer.OrdinalIgnoreCase);
+ }
+
+ // Resolve from blueprint if provided
+ if (!string.IsNullOrWhiteSpace(blueprintId))
+ {
+ try
+ {
+ var instances = await _blueprintService.GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, cancellationToken);
+ blueprintInstances = instances.Select(i => i.IdentitySpId).ToList();
+ _logger.LogDebug("Blueprint {BlueprintId} has {Count} instance(s)", blueprintId, blueprintInstances.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to resolve agent instances for blueprint {BlueprintId}", blueprintId);
+ return null;
+ }
+ }
+
+ // Determine final list
+ if (blueprintInstances != null && filterSet != null)
+ {
+ // Both: filter blueprint instances by the explicit list
+ return blueprintInstances.Where(id => filterSet.Contains(id)).ToList();
+ }
+ else if (blueprintInstances != null)
+ {
+ return blueprintInstances;
+ }
+ else if (filterSet != null)
+ {
+ return filterSet.ToList();
+ }
+
+ return null;
+ }
+
+ private async Task ResolveServicePrincipalIdAsync(
+ string tenantId,
+ string appId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var path = $"/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id";
+ using var doc = await _graphApiService.GraphGetAsync(tenantId, path, cancellationToken);
+
+ if (doc == null)
+ {
+ _logger.LogDebug("Graph query for SP with appId {AppId} returned null", appId);
+ return null;
+ }
+
+ if (doc.RootElement.TryGetProperty("value", out var value) &&
+ value.ValueKind == JsonValueKind.Array &&
+ value.GetArrayLength() > 0)
+ {
+ var first = value[0];
+ if (first.TryGetProperty("id", out var idProp))
+ {
+ return idProp.GetString();
+ }
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to resolve service principal for appId {AppId}", appId);
+ return null;
+ }
+ }
+
+ private enum GrantResult { Created, Updated, AlreadyExists, Failed }
+
+ private async Task UpsertDelegatedGrantAsync(
+ string tenantId,
+ string clientSpId,
+ string resourceSpId,
+ string scope,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var graphToken = await _graphApiService.GetGraphAccessTokenAsync(tenantId, ct: cancellationToken);
+ if (string.IsNullOrWhiteSpace(graphToken))
+ {
+ _logger.LogError("Failed to acquire Graph API access token");
+ return GrantResult.Failed;
+ }
+
+ using var httpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(graphToken);
+
+ // Check existing grants
+ var filter = $"clientId eq '{clientSpId}' and resourceId eq '{resourceSpId}' and consentType eq '{AllPrincipalsConsentType}'";
+ var getUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}";
+
+ using var getResponse = await httpClient.GetAsync(getUrl, cancellationToken);
+ if (!getResponse.IsSuccessStatusCode)
+ {
+ _logger.LogError("Failed to query existing grants: {Status}", getResponse.StatusCode);
+ return GrantResult.Failed;
+ }
+
+ var getJson = await getResponse.Content.ReadAsStringAsync(cancellationToken);
+ using var getDoc = JsonDocument.Parse(getJson);
+
+ if (getDoc.RootElement.TryGetProperty("value", out var grants) &&
+ grants.ValueKind == JsonValueKind.Array &&
+ grants.GetArrayLength() > 0)
+ {
+ // Grant exists: check if scope is already present
+ var existing = grants[0];
+ var grantId = existing.GetProperty("id").GetString();
+ var existingScope = existing.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
+ var existingScopes = existingScope.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
+
+ if (existingScopes.Contains(scope))
+ {
+ return GrantResult.AlreadyExists;
+ }
+
+ // Add scope via PATCH
+ existingScopes.Add(scope);
+ var newScope = string.Join(' ', existingScopes.OrderBy(x => x));
+ var patchUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants/{grantId}";
+ var patchBody = new { scope = newScope };
+
+ using var patchResponse = await httpClient.PatchAsync(
+ patchUrl,
+ new StringContent(JsonSerializer.Serialize(patchBody), Encoding.UTF8, "application/json"),
+ cancellationToken);
+
+ if (!patchResponse.IsSuccessStatusCode)
+ {
+ var error = await patchResponse.Content.ReadAsStringAsync(cancellationToken);
+ _logger.LogError("Failed to update grant {GrantId}: {Error}", grantId, error);
+ return GrantResult.Failed;
+ }
+
+ return GrantResult.Updated;
+ }
+
+ // Create new grant
+ var createUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants";
+ var createBody = new
+ {
+ clientId = clientSpId,
+ consentType = AllPrincipalsConsentType,
+ resourceId = resourceSpId,
+ scope = scope
+ };
+
+ using var createResponse = await httpClient.PostAsync(
+ createUrl,
+ new StringContent(JsonSerializer.Serialize(createBody), Encoding.UTF8, "application/json"),
+ cancellationToken);
+
+ if (!createResponse.IsSuccessStatusCode)
+ {
+ var error = await createResponse.Content.ReadAsStringAsync(cancellationToken);
+ _logger.LogError("Failed to create grant: {Error}", error);
+ return GrantResult.Failed;
+ }
+
+ return GrantResult.Created;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception upserting delegated grant for client {ClientSpId} -> resource {ResourceSpId}", clientSpId, resourceSpId);
+ return GrantResult.Failed;
+ }
+ }
+
+ private static async Task ResolveTenantIdAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var isWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
+ System.Runtime.InteropServices.OSPlatform.Windows);
+ var startInfo = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = isWindows ? "cmd.exe" : "az",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ if (isWindows)
+ {
+ startInfo.ArgumentList.Add("/c");
+ startInfo.ArgumentList.Add("az");
+ }
+ startInfo.ArgumentList.Add("account");
+ startInfo.ArgumentList.Add("show");
+ startInfo.ArgumentList.Add("--query");
+ startInfo.ArgumentList.Add("tenantId");
+ startInfo.ArgumentList.Add("-o");
+ startInfo.ArgumentList.Add("tsv");
+
+ using var process = System.Diagnostics.Process.Start(startInfo);
+ if (process == null) return null;
+
+ var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
+ await Task.WhenAll(outputTask, errorTask);
+ await process.WaitForExitAsync(cancellationToken);
+
+ if (process.ExitCode == 0)
+ {
+ var tenantId = outputTask.Result.Trim();
+ if (!string.IsNullOrWhiteSpace(tenantId))
+ return tenantId;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Non-fatal
+ }
+
+ return null;
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetAgentInstancesExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetAgentInstancesExecutor.cs
new file mode 100644
index 00000000..b4c6d2c8
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetAgentInstancesExecutor.cs
@@ -0,0 +1,150 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands;
+
+///
+/// Executor for the get-agent-instances subcommand.
+/// Lists agent instance service principals linked to a given blueprint ID.
+///
+internal sealed class GetAgentInstancesExecutor
+{
+ private readonly ILogger _logger;
+ private readonly AgentBlueprintService _blueprintService;
+
+ public GetAgentInstancesExecutor(ILogger logger, AgentBlueprintService blueprintService)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _blueprintService = blueprintService ?? throw new ArgumentNullException(nameof(blueprintService));
+ }
+
+ ///
+ /// Executes the get-agent-instances command.
+ ///
+ /// Agent Identity Blueprint ID (GUID).
+ /// Tenant ID (auto-detected if null).
+ /// Cancellation token.
+ /// True on success, false on failure.
+ public async Task ExecuteAsync(
+ string blueprintId,
+ string? tenantId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(blueprintId))
+ {
+ _logger.LogError("Blueprint ID is required");
+ return false;
+ }
+
+ if (!Guid.TryParse(blueprintId, out _))
+ {
+ _logger.LogError("Invalid blueprint ID format: {BlueprintId}. Must be a valid GUID.", blueprintId);
+ return false;
+ }
+
+ // Resolve tenant ID
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ tenantId = await ResolveTenantIdAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(tenantId))
+ {
+ _logger.LogError("Could not determine tenant ID. Provide --tenant-id or sign in with 'az login'.");
+ return false;
+ }
+ }
+ else if (!Guid.TryParse(tenantId, out _))
+ {
+ _logger.LogError("Invalid tenant ID format: {TenantId}. Must be a valid GUID.", tenantId);
+ return false;
+ }
+
+ _logger.LogInformation("Listing agent instances for blueprint {BlueprintId} in tenant {TenantId}...", blueprintId, tenantId);
+
+ try
+ {
+ var instances = await _blueprintService.GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, cancellationToken);
+
+ if (instances.Count == 0)
+ {
+ _logger.LogInformation("No agent instances found for blueprint {BlueprintId}", blueprintId);
+ return true;
+ }
+
+ // Table header
+ _logger.LogInformation("");
+ Console.WriteLine($"{"IdentitySpId",-40} {"DisplayName",-30} {"AgentUserId",-40}");
+ Console.WriteLine(new string('-', 112));
+
+ foreach (var instance in instances)
+ {
+ Console.WriteLine($"{instance.IdentitySpId,-40} {instance.DisplayName ?? "(none)",-30} {instance.AgentUserId ?? "(none)",-40}");
+ }
+
+ _logger.LogInformation("");
+ _logger.LogInformation("Found {Count} agent instance(s)", instances.Count);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to list agent instances for blueprint {BlueprintId}", blueprintId);
+ return false;
+ }
+ }
+
+ private static async Task ResolveTenantIdAsync(CancellationToken cancellationToken)
+ {
+ // Use az account show to detect tenant ID from the current Azure CLI session
+ try
+ {
+ var isWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
+ System.Runtime.InteropServices.OSPlatform.Windows);
+ var startInfo = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = isWindows ? "cmd.exe" : "az",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ if (isWindows)
+ {
+ startInfo.ArgumentList.Add("/c");
+ startInfo.ArgumentList.Add("az");
+ }
+ startInfo.ArgumentList.Add("account");
+ startInfo.ArgumentList.Add("show");
+ startInfo.ArgumentList.Add("--query");
+ startInfo.ArgumentList.Add("tenantId");
+ startInfo.ArgumentList.Add("-o");
+ startInfo.ArgumentList.Add("tsv");
+
+ using var process = System.Diagnostics.Process.Start(startInfo);
+ if (process == null) return null;
+
+ var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
+ await Task.WhenAll(outputTask, errorTask);
+ await process.WaitForExitAsync(cancellationToken);
+
+ if (process.ExitCode == 0)
+ {
+ var tenantId = outputTask.Result.Trim();
+ if (!string.IsNullOrWhiteSpace(tenantId))
+ return tenantId;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Non-fatal: fall through to null
+ }
+
+ return null;
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerAppIdResponse.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerAppIdResponse.cs
new file mode 100644
index 00000000..6be1d11f
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/McpServerAppIdResponse.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Models;
+
+///
+/// Response model for the GET /agents/mcpServers/appIds?serverName={name} endpoint.
+///
+public class McpServerAppIdResponse
+{
+ ///
+ /// The PPMI application (client) ID registered for the MCP server.
+ /// Used as the resource when creating oauth2PermissionGrants.
+ ///
+ [JsonPropertyName("mcpServerAppId")]
+ public string? McpServerAppId { get; set; }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
index 6661d462..359472be 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
@@ -174,7 +174,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, agentBlueprintService));
var confirmationProvider = serviceProvider.GetRequiredService();
rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor,
backendConfigurator, azureAuthValidator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator, confirmationProvider, armApiService, resolver: bootstrapResolver));
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs
index 578b0839..582b4645 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs
@@ -323,6 +323,12 @@ private string BuildProvisionIdentityUrl(string environment, string serverName)
return $"{baseUrl}/agents/mcpServers/{Uri.EscapeDataString(serverName)}/provisionIdentity";
}
+ private string BuildGetMcpServerAppIdsUrl(string environment, string serverName)
+ {
+ var baseUrl = BuildAgent365ToolsBaseUrl(environment);
+ return $"{baseUrl}/agents/mcpServers/appIds?serverName={Uri.EscapeDataString(serverName)}";
+ }
+
///
public async Task ListEnvironmentsAsync(CancellationToken cancellationToken = default)
{
@@ -846,6 +852,71 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de
}
}
+ ///
+ public async Task GetMcpServerAppIdByNameAsync(
+ string serverName,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(serverName))
+ throw new ArgumentException("Server name cannot be null or empty", nameof(serverName));
+
+ try
+ {
+ var endpointUrl = BuildGetMcpServerAppIdsUrl(_environment, serverName);
+
+ var correlationId = Internal.HttpClientFactory.GenerateCorrelationId();
+
+ _logger.LogDebug("Getting app ID for MCP server {ServerName} (CorrelationId: {CorrelationId})", serverName, correlationId);
+ _logger.LogDebug("Endpoint URL: {Url}", endpointUrl);
+
+ var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment);
+ _logger.LogDebug("Acquiring access token for audience: {Audience}", audience);
+
+ var loginHint = await AzCliHelper.ResolveLoginHintAsync();
+ var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint);
+ if (string.IsNullOrWhiteSpace(authToken))
+ {
+ _logger.LogError("Failed to acquire authentication token");
+ return null;
+ }
+
+ using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId);
+
+ LogRequest("GET", endpointUrl);
+
+ using var response = await httpClient.GetAsync(endpointUrl, cancellationToken);
+
+ var (isSuccess, responseContent) = await ValidateResponseAsync(response, "get MCP server app ID", cancellationToken);
+ if (!isSuccess)
+ {
+ return null;
+ }
+
+ if (string.IsNullOrWhiteSpace(responseContent))
+ {
+ _logger.LogError("Get MCP server app ID returned empty response");
+ return null;
+ }
+
+ var appIdResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization(
+ responseContent, _logger);
+
+ if (appIdResponse == null || string.IsNullOrWhiteSpace(appIdResponse.McpServerAppId))
+ {
+ _logger.LogError("Get MCP server app ID response is missing mcpServerAppId");
+ return null;
+ }
+
+ _logger.LogDebug("Successfully retrieved app ID {AppId} for MCP server {ServerName}", appIdResponse.McpServerAppId, serverName);
+ return appIdResponse;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get app ID for MCP server {ServerName}", serverName);
+ return null;
+ }
+ }
+
///
public async Task DeleteMcpServerAsync(
string serverName,
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs
index 91ba94e6..fba37980 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs
@@ -97,6 +97,17 @@ Task LogRegisterUsageAsync(
string serverName,
CancellationToken cancellationToken = default);
+ ///
+ /// Retrieves the PPMI application (client) ID for a BYO MCP server by name.
+ /// Calls GET /agents/mcpServers/appIds?serverName={serverName} on the MCP Platform.
+ ///
+ /// MCP server name
+ /// Cancellation token
+ /// Response containing the MCP server's PPMI app ID, or null on failure
+ Task GetMcpServerAppIdByNameAsync(
+ string serverName,
+ CancellationToken cancellationToken = default);
+
///
/// Deletes a BYO (Bring Your Own) MCP server and returns the associated app IDs for cleanup
///
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddByoScopesExecutorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddByoScopesExecutorTests.cs
new file mode 100644
index 00000000..8098290a
--- /dev/null
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddByoScopesExecutorTests.cs
@@ -0,0 +1,245 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Microsoft.Agents.A365.DevTools.Cli.Commands;
+using Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands;
+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 NSubstitute.ExceptionExtensions;
+using System.CommandLine;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands;
+
+public class AddByoScopesExecutorTests
+{
+ private readonly ILogger _mockLogger;
+ private readonly IAgent365ToolingService _mockToolingService;
+ private readonly AgentBlueprintService _mockBlueprintService;
+ private readonly GraphApiService _mockGraphApiService;
+
+ public AddByoScopesExecutorTests()
+ {
+ _mockLogger = Substitute.For();
+ _mockToolingService = Substitute.For();
+ _mockGraphApiService = Substitute.For();
+ _mockBlueprintService = Substitute.For(
+ NullLogger.Instance, _mockGraphApiService);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithNeitherBlueprintNorInstances_ReturnsFalse()
+ {
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+
+ var result = await executor.ExecuteAsync("ext_server1", null, null, "00000000-0000-0000-0000-000000000001", false, CancellationToken.None);
+
+ result.Should().BeFalse(because: "at least one of --blueprint-id or --agent-instances is required");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithEmptyServerNames_ReturnsFalse()
+ {
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+
+ var result = await executor.ExecuteAsync("", "00000000-0000-0000-0000-000000000001", null, "00000000-0000-0000-0000-000000000002", false, CancellationToken.None);
+
+ result.Should().BeFalse(because: "empty server names is not valid");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithInvalidBlueprintId_ReturnsFalse()
+ {
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+
+ var result = await executor.ExecuteAsync("ext_server1", "not-a-guid", null, "00000000-0000-0000-0000-000000000001", false, CancellationToken.None);
+
+ result.Should().BeFalse(because: "blueprint ID must be a valid GUID");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithInvalidTenantId_ReturnsFalse()
+ {
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+
+ var result = await executor.ExecuteAsync(
+ "ext_server1",
+ "00000000-0000-0000-0000-000000000001",
+ null,
+ "not-a-guid",
+ false,
+ CancellationToken.None);
+
+ result.Should().BeFalse(because: "tenant ID must be a valid GUID");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithInvalidAgentInstanceId_ReturnsFalse()
+ {
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+
+ var result = await executor.ExecuteAsync(
+ "ext_server1",
+ null,
+ "not-a-guid",
+ "00000000-0000-0000-0000-000000000001",
+ false,
+ CancellationToken.None);
+
+ result.Should().BeFalse(because: "agent instance SP IDs must be valid GUIDs");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_DryRun_WithBlueprint_ReturnsTrue()
+ {
+ var tenantId = "00000000-0000-0000-0000-000000000001";
+ var blueprintId = "00000000-0000-0000-0000-000000000002";
+
+ var instances = new List
+ {
+ new() { IdentitySpId = "00000000-0000-0000-0000-000000000003", DisplayName = "Agent 1" },
+ };
+
+ _mockBlueprintService
+ .GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, Arg.Any())
+ .Returns(Task.FromResult>(instances));
+
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+ var result = await executor.ExecuteAsync("ext_server1", blueprintId, null, tenantId, dryRun: true, CancellationToken.None);
+
+ result.Should().BeTrue(because: "dry run should succeed without making API calls");
+
+ // Verify no tooling or Graph calls were made
+ await _mockToolingService.DidNotReceive().GetMcpServerAppIdByNameAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_BlueprintReturnsNoInstances_ReturnsFalse()
+ {
+ var tenantId = "00000000-0000-0000-0000-000000000001";
+ var blueprintId = "00000000-0000-0000-0000-000000000002";
+
+ _mockBlueprintService
+ .GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, Arg.Any())
+ .Returns(Task.FromResult>(Array.Empty()));
+
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+ var result = await executor.ExecuteAsync("ext_server1", blueprintId, null, tenantId, false, CancellationToken.None);
+
+ result.Should().BeFalse(because: "no resolved instances means nothing to grant");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_AppIdLookupFails_ReturnsFalse()
+ {
+ var tenantId = "00000000-0000-0000-0000-000000000001";
+ var instanceSpId = "00000000-0000-0000-0000-000000000003";
+
+ _mockToolingService
+ .GetMcpServerAppIdByNameAsync("ext_server1", Arg.Any())
+ .Returns(Task.FromResult(null));
+
+ var executor = new AddByoScopesExecutor(_mockLogger, _mockToolingService, _mockBlueprintService, _mockGraphApiService);
+ var result = await executor.ExecuteAsync("ext_server1", null, instanceSpId, tenantId, false, CancellationToken.None);
+
+ result.Should().BeFalse(because: "failing to resolve PPMI app ID should cause failure");
+ }
+}
+
+public class AddByoScopesSubcommandTests
+{
+ private readonly ILogger _mockLogger;
+ private readonly IAgent365ToolingService _mockToolingService;
+ private readonly AgentBlueprintService _mockBlueprintService;
+ private readonly GraphApiService _mockGraphApiService;
+
+ public AddByoScopesSubcommandTests()
+ {
+ _mockLogger = Substitute.For();
+ _mockToolingService = Substitute.For();
+ _mockGraphApiService = Substitute.For();
+ _mockBlueprintService = Substitute.For(
+ NullLogger.Instance, _mockGraphApiService);
+ }
+
+ [Fact]
+ public void CreateCommand_WithBlueprintAndGraphServices_IncludesAddByoScopes()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService,
+ graphApiService: _mockGraphApiService,
+ agentBlueprintService: _mockBlueprintService);
+
+ command.Subcommands.Select(sc => sc.Name).Should().Contain(
+ "add-byo-scopes",
+ because: "both blueprint and graph services are provided");
+ }
+
+ [Fact]
+ public void CreateCommand_WithoutGraphService_DoesNotIncludeAddByoScopes()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService,
+ agentBlueprintService: _mockBlueprintService);
+
+ command.Subcommands.Select(sc => sc.Name).Should().NotContain(
+ "add-byo-scopes",
+ because: "add-byo-scopes requires the graph API service");
+ }
+
+ [Fact]
+ public void CreateCommand_WithoutBlueprintService_DoesNotIncludeAddByoScopes()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService,
+ graphApiService: _mockGraphApiService);
+
+ command.Subcommands.Select(sc => sc.Name).Should().NotContain(
+ "add-byo-scopes",
+ because: "add-byo-scopes requires the blueprint service");
+ }
+
+ [Fact]
+ public void AddByoScopesSubcommand_HasCorrectOptions()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService,
+ graphApiService: _mockGraphApiService,
+ agentBlueprintService: _mockBlueprintService);
+ var subcommand = command.Subcommands.First(sc => sc.Name == "add-byo-scopes");
+
+ var optionNames = subcommand.Options.Select(o => o.Name).ToList();
+ optionNames.Should().Contain("server-names");
+ optionNames.Should().Contain("blueprint-id");
+ optionNames.Should().Contain("agent-instances");
+ optionNames.Should().Contain("tenant-id");
+ optionNames.Should().Contain("dry-run");
+ optionNames.Should().Contain("verbose");
+
+ var serverNamesOption = subcommand.Options.First(o => o.Name == "server-names");
+ serverNamesOption.IsRequired.Should().BeTrue(because: "--server-names is required");
+ serverNamesOption.Aliases.Should().Contain("-s");
+
+ var blueprintOption = subcommand.Options.First(o => o.Name == "blueprint-id");
+ blueprintOption.Aliases.Should().Contain("-b");
+
+ var instancesOption = subcommand.Options.First(o => o.Name == "agent-instances");
+ instancesOption.Aliases.Should().Contain("-i");
+ }
+
+ [Fact]
+ public void AddByoScopesSubcommand_HasNoPositionalArguments()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService,
+ graphApiService: _mockGraphApiService,
+ agentBlueprintService: _mockBlueprintService);
+ var subcommand = command.Subcommands.First(sc => sc.Name == "add-byo-scopes");
+
+ subcommand.Arguments.Should().BeEmpty(
+ because: "Azure CLI compliance requires named options only");
+ }
+}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetAgentInstancesExecutorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetAgentInstancesExecutorTests.cs
new file mode 100644
index 00000000..c9ac6c4f
--- /dev/null
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/GetAgentInstancesExecutorTests.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Microsoft.Agents.A365.DevTools.Cli.Commands;
+using Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands;
+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 NSubstitute.ExceptionExtensions;
+using System.CommandLine;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands;
+
+public class GetAgentInstancesExecutorTests
+{
+ private readonly ILogger _mockLogger;
+ private readonly AgentBlueprintService _mockBlueprintService;
+
+ public GetAgentInstancesExecutorTests()
+ {
+ _mockLogger = Substitute.For();
+ var graphService = Substitute.For();
+ _mockBlueprintService = Substitute.For(
+ NullLogger.Instance, graphService);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithNullBlueprintId_ReturnsFalse()
+ {
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+
+ var result = await executor.ExecuteAsync(null!, null, CancellationToken.None);
+
+ result.Should().BeFalse(because: "null blueprint ID is not valid");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithEmptyBlueprintId_ReturnsFalse()
+ {
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+
+ var result = await executor.ExecuteAsync("", null, CancellationToken.None);
+
+ result.Should().BeFalse(because: "empty blueprint ID is not valid");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithInvalidGuidBlueprintId_ReturnsFalse()
+ {
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+
+ var result = await executor.ExecuteAsync("not-a-guid", "00000000-0000-0000-0000-000000000001", CancellationToken.None);
+
+ result.Should().BeFalse(because: "blueprint ID must be a valid GUID");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithInvalidTenantId_ReturnsFalse()
+ {
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+
+ var result = await executor.ExecuteAsync(
+ "00000000-0000-0000-0000-000000000001",
+ "not-a-guid",
+ CancellationToken.None);
+
+ result.Should().BeFalse(because: "tenant ID must be a valid GUID");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithValidInputs_NoInstances_ReturnsTrue()
+ {
+ var tenantId = "00000000-0000-0000-0000-000000000001";
+ var blueprintId = "00000000-0000-0000-0000-000000000002";
+
+ _mockBlueprintService
+ .GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, Arg.Any())
+ .Returns(Task.FromResult>(Array.Empty()));
+
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+ var result = await executor.ExecuteAsync(blueprintId, tenantId, CancellationToken.None);
+
+ result.Should().BeTrue(because: "no instances is a valid successful result");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithValidInputs_WithInstances_ReturnsTrue()
+ {
+ var tenantId = "00000000-0000-0000-0000-000000000001";
+ var blueprintId = "00000000-0000-0000-0000-000000000002";
+
+ var instances = new List
+ {
+ new() { IdentitySpId = "sp-id-1", DisplayName = "Agent 1", AgentUserId = "user-1" },
+ new() { IdentitySpId = "sp-id-2", DisplayName = "Agent 2", AgentUserId = null },
+ };
+
+ _mockBlueprintService
+ .GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, Arg.Any())
+ .Returns(Task.FromResult>(instances));
+
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+ var result = await executor.ExecuteAsync(blueprintId, tenantId, CancellationToken.None);
+
+ result.Should().BeTrue(because: "listing instances should succeed");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WhenServiceThrows_ReturnsFalse()
+ {
+ var tenantId = "00000000-0000-0000-0000-000000000001";
+ var blueprintId = "00000000-0000-0000-0000-000000000002";
+
+ _mockBlueprintService
+ .GetAgentInstancesForBlueprintAsync(tenantId, blueprintId, Arg.Any())
+ .ThrowsAsync(new InvalidOperationException("Graph API failure"));
+
+ var executor = new GetAgentInstancesExecutor(_mockLogger, _mockBlueprintService);
+ var result = await executor.ExecuteAsync(blueprintId, tenantId, CancellationToken.None);
+
+ result.Should().BeFalse(because: "service exception should cause failure");
+ }
+}
+
+public class GetAgentInstancesSubcommandTests
+{
+ private readonly ILogger _mockLogger;
+ private readonly IAgent365ToolingService _mockToolingService;
+ private readonly AgentBlueprintService _mockBlueprintService;
+
+ public GetAgentInstancesSubcommandTests()
+ {
+ _mockLogger = Substitute.For();
+ _mockToolingService = Substitute.For();
+ var graphService = Substitute.For();
+ _mockBlueprintService = Substitute.For(
+ NullLogger.Instance, graphService);
+ }
+
+ [Fact]
+ public void CreateCommand_WithBlueprintService_IncludesGetAgentInstances()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService, agentBlueprintService: _mockBlueprintService);
+
+ command.Subcommands.Select(sc => sc.Name).Should().Contain(
+ "get-agent-instances",
+ because: "providing the blueprint service should register the get-agent-instances subcommand");
+ }
+
+ [Fact]
+ public void CreateCommand_WithoutBlueprintService_DoesNotIncludeGetAgentInstances()
+ {
+ var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService);
+
+ command.Subcommands.Select(sc => sc.Name).Should().NotContain(
+ "get-agent-instances",
+ because: "get-agent-instances requires the blueprint service");
+ }
+
+ [Fact]
+ public void GetAgentInstancesSubcommand_HasCorrectOptions()
+ {
+ var command = DevelopMcpCommand.CreateCommand(
+ _mockLogger, _mockToolingService, agentBlueprintService: _mockBlueprintService);
+ var subcommand = command.Subcommands.First(sc => sc.Name == "get-agent-instances");
+
+ var optionNames = subcommand.Options.Select(o => o.Name).ToList();
+ optionNames.Should().Contain("blueprint-id");
+ optionNames.Should().Contain("tenant-id");
+ optionNames.Should().Contain("verbose");
+
+ var blueprintOption = subcommand.Options.First(o => o.Name == "blueprint-id");
+ blueprintOption.IsRequired.Should().BeTrue(because: "--blueprint-id is required for get-agent-instances");
+ blueprintOption.Aliases.Should().Contain("-b");
+ }
+}