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"); + } +}