diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index fd66a4fc..5aae2a93 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -35,7 +35,7 @@ public static Command CreateCommand( // Add subcommands developMcpCommand.AddCommand(CreateListEnvironmentsSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateListServersSubcommand(logger, toolingService)); - developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService, graphApiService)); developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateApproveSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateBlockSubcommand(logger, toolingService)); @@ -279,180 +279,62 @@ private static Command CreateListServersSubcommand( /// Creates the publish subcommand /// private static Command CreatePublishSubcommand( - ILogger logger, - IAgent365ToolingService toolingService) + ILogger logger, + IAgent365ToolingService toolingService, + GraphApiService? graphApiService) { - var command = new Command("publish", "Publish an MCP server to a Dataverse environment"); + var command = new Command("publish", "Publish an MCP server to a Dataverse environment. Creates the A365 Proxy + Public Clients Entra apps in your tenant, calls the platform publish endpoint, and back-fills redirect URIs and PPMI scope grants — same orchestration shape as register-external-mcp-server."); var envIdOption = new Option( ["--environment-id", "-e"], - description: "Dataverse environment ID" - ); - envIdOption.IsRequired = false; // Allow null so we can prompt + description: "Dataverse environment ID"); command.AddOption(envIdOption); var serverNameOption = new Option( ["--server-name", "-s"], - description: "MCP server name to publish" - ); - serverNameOption.IsRequired = false; // Allow null so we can prompt + description: "MCP server name to publish"); command.AddOption(serverNameOption); var aliasOption = new Option( ["--alias", "-a"], - description: "Alias for the MCP server" - ); + description: "Alias for the MCP server (used as the MCC row name and the CMS connector id)"); command.AddOption(aliasOption); var displayNameOption = new Option( ["--display-name", "-d"], - description: "Display name for the MCP server" - ); + description: "Display name for the MCP server (max 30 chars)"); command.AddOption(displayNameOption); - var dryRunOption = new Option( - name: "--dry-run", - description: "Show what would be done without executing" - ); - command.AddOption(dryRunOption); - - var verboseOption = new Option( - ["--verbose", "-v"], - description: "Enable verbose logging" - ); - command.AddOption(verboseOption); - - command.SetHandler(async (envId, serverName, alias, displayName, dryRun, verbose) => - { - _ = verbose; - try - { - // Validate and prompt for missing required arguments with security checks - if (string.IsNullOrWhiteSpace(envId)) - { - envId = InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID"); - if (string.IsNullOrWhiteSpace(envId)) - { - logger.LogError("Environment ID is required"); - return; - } - } - else - { - // Validate provided environment ID - envId = InputValidator.ValidateInput(envId, "Environment ID"); - if (envId == null) - { - logger.LogError("Invalid environment ID format"); - return; - } - } - - if (string.IsNullOrWhiteSpace(serverName)) - { - serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to publish: ", "Server name", 100); - if (string.IsNullOrWhiteSpace(serverName)) - { - logger.LogError("Server name is required"); - return; - } - } - else - { - // Validate provided server name - serverName = InputValidator.ValidateInput(serverName, "Server name"); - if (serverName == null) - { - logger.LogError("Invalid server name format"); - return; - } - } - - logger.LogInformation("Starting publish operation for server {ServerName} in environment {EnvId}...", serverName, envId); - - if (dryRun) - { - logger.LogInformation("[DRY RUN] Would read config from a365.config.json"); - logger.LogInformation("[DRY RUN] Would publish MCP server {ServerName} to environment {EnvId}", serverName, envId); - logger.LogInformation("[DRY RUN] Alias: {Alias}", alias ?? "[would prompt]"); - logger.LogInformation("[DRY RUN] Display Name: {DisplayName}", displayName ?? "[would prompt]"); - await Task.CompletedTask; - return; - } - - // Validate and prompt for missing optional values with security checks - if (string.IsNullOrWhiteSpace(alias)) - { - alias = InputValidator.PromptAndValidateRequiredInput("Enter alias for the MCP server: ", "Alias", 50); - if (string.IsNullOrWhiteSpace(alias)) - { - logger.LogError("Alias is required"); - return; - } - } - else - { - // Validate provided alias - alias = InputValidator.ValidateInput(alias, "Alias", maxLength: 50); - if (alias == null) - { - logger.LogError("Invalid alias format"); - return; - } - } - - if (string.IsNullOrWhiteSpace(displayName)) - { - displayName = InputValidator.PromptAndValidateRequiredInput("Enter display name for the MCP server: ", "Display name", 100); - if (string.IsNullOrWhiteSpace(displayName)) - { - logger.LogError("Display name is required"); - return; - } - } - else - { - // Validate provided display name - displayName = InputValidator.ValidateInput(displayName, "Display name", maxLength: 100); - if (displayName == null) - { - logger.LogError("Invalid display name format"); - return; - } - } - } - catch (ArgumentException ex) - { - logger.LogError("Input validation failed: {Message}", ex.Message); - return; - } + var tenantIdOption = new Option( + ["--tenant-id", "-t"], + description: "Entra tenant ID for Entra app creation (defaults to current az login tenant)"); + command.AddOption(tenantIdOption); - // Create request - var request = new PublishMcpServerRequest - { - Alias = alias, - DisplayName = displayName - }; + var serviceTreeIdOption = new Option( + "--service-tree-id", + description: "ServiceTree ID for Entra app registration (required in Microsoft corporate tenants)"); + command.AddOption(serviceTreeIdOption); - // Call service - var response = await toolingService.PublishServerAsync(envId, serverName, request); + var dryRunOption = new Option("--dry-run", "Show what would be done without executing"); + command.AddOption(dryRunOption); - if (response == null || !response.IsSuccess) - { - if (response?.Message != null) - { - logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: {ErrorMessage}", serverName, envId, response.Message); - } - else - { - logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: No response received", serverName, envId); - } - return; - } + // Verbose is handled globally in Program.cs (sets LogLevel.Debug); declared here so the parser accepts -v. + command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); - logger.LogInformation("Successfully published MCP server {ServerName} to environment {EnvId}", serverName, envId); + command.SetHandler(async (context) => + { + var args = new RawPublishArgs( + EnvironmentId: context.ParseResult.GetValueForOption(envIdOption), + ServerName: context.ParseResult.GetValueForOption(serverNameOption), + Alias: context.ParseResult.GetValueForOption(aliasOption), + DisplayName: context.ParseResult.GetValueForOption(displayNameOption), + TenantId: context.ParseResult.GetValueForOption(tenantIdOption), + ServiceTreeId: context.ParseResult.GetValueForOption(serviceTreeIdOption), + DryRun: context.ParseResult.GetValueForOption(dryRunOption)); - }, envIdOption, serverNameOption, aliasOption, displayNameOption, dryRunOption, verboseOption); + var executor = new PublishCommandExecutor(logger, toolingService, graphApiService); + await executor.ExecuteAsync(args, context.GetCancellationToken()); + }); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs new file mode 100644 index 00000000..9278d1ea --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands; + +/// +/// Raw CLI arguments passed to the develop-mcp publish command. +/// +internal record RawPublishArgs( + string? EnvironmentId, + string? ServerName, + string? Alias, + string? DisplayName, + string? TenantId, + string? ServiceTreeId, + bool DryRun); + +/// +/// Orchestrates first-party MCP server publish in one CLI command. The shape mirrors +/// for BYO register: the CLI creates the Entra apps it has +/// delegated authority to create (A365 Proxy + Public Clients in the user's own tenant), calls the +/// platform publish endpoint with those credentials, and back-fills the apps' redirect URIs and +/// PPMI scope grants after the platform creates the CMS connector and PPMI identity. +/// +internal class PublishCommandExecutor +{ + private readonly ILogger _logger; + private readonly IAgent365ToolingService _toolingService; + private readonly GraphApiService? _graphApiService; + private readonly RetryHelper _retryHelper; + + internal PublishCommandExecutor( + ILogger logger, + IAgent365ToolingService toolingService, + GraphApiService? graphApiService) + { + _logger = logger; + _toolingService = toolingService; + _graphApiService = graphApiService; + _retryHelper = new RetryHelper(logger, maxRetries: 5, baseDelaySeconds: 3); + } + + private sealed record ResolvedInput + { + public required string EnvironmentId { get; init; } + public required string ServerName { get; init; } + public required string Alias { get; init; } + public required string DisplayName { get; init; } + public required bool DryRun { get; init; } + public string? TenantId { get; init; } + public string? ServiceTreeId { get; init; } + } + + private sealed record EntraAppSet( + string A365AppClientId, + string A365AppSecret, + string A365AppObjectId, + string A365AppName, + string? PublicClientsClientId, + string? PublicClientsObjectId, + string PublicClientsAppName); + + internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct = default) + { + var input = ResolveInputs(args); + if (input is null) return; + + DisplayPublishSummary(input); + + if (input.DryRun) + { + _logger.LogInformation("[DRY RUN] Would create Entra apps '{A365}' and '{PublicClients}' in tenant", $"{input.Alias}-A365Proxy", $"{input.Alias}-PublicClients"); + _logger.LogInformation("[DRY RUN] Would call publish endpoint and back-fill redirect URI + PPMI scope on the created apps"); + return; + } + + Console.Write("Proceed with publish? (y/N): "); + var confirmation = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (confirmation != "y" && confirmation != "yes") + { + Console.WriteLine("Publish cancelled."); + return; + } + + Console.WriteLine(); + ct.ThrowIfCancellationRequested(); + Console.WriteLine($"Publishing MCP server '{input.ServerName}' as '{input.Alias}' to environment {input.EnvironmentId}..."); + + var tenantId = await DetectTenantIdAsync(input.TenantId); + if (tenantId is null) return; + + if (_graphApiService is null) + { + _logger.LogError("Graph API service is not available. Cannot create Entra applications."); + return; + } + + var warnings = new List(); + var apps = await CreateEntraAppsAsync(input, tenantId, warnings); + if (apps is null) return; + + ct.ThrowIfCancellationRequested(); + + var request = new PublishMcpServerRequest + { + Alias = input.Alias, + DisplayName = input.DisplayName, + A365ProxyClientId = Guid.Parse(apps.A365AppClientId), + A365ProxyClientSecret = apps.A365AppSecret, + PublicClientsAppId = apps.PublicClientsClientId, + }; + + PublishMcpServerResponse? publishResponse; + try + { + // Hits the v2 publish endpoint, which performs the full elevation orchestration + // (lazy PPMI, MOS upload, A365 Proxy CMS connector creation). v1's PublishServerAsync + // is preserved on the platform for any callers relying on the original behavior. + publishResponse = await _toolingService.PublishServerV2Async(input.EnvironmentId, input.ServerName, request, ct); + } + catch (Exception ex) + { + _logger.LogError("Failed to publish MCP server '{ServerName}': {Error}", input.ServerName, ex.Message); + _logger.LogDebug("Exception details: {Exception}", ex.ToString()); + _logger.LogWarning("Entra app registrations were NOT rolled back. Delete them manually in the Azure portal if needed."); + return; + } + + if (publishResponse is null || !publishResponse.IsSuccess) + { + var errorMsg = publishResponse?.Message ?? "No response received"; + _logger.LogError("Failed to publish MCP server {ServerName}: {Error}", input.ServerName, errorMsg); + _logger.LogWarning("Entra app registrations were NOT rolled back. Delete them manually in the Azure portal if needed."); + return; + } + + _logger.LogDebug("Successfully published MCP server {ServerName}", input.ServerName); + + await ConfigureEntraAppsAsync(input, apps, publishResponse, tenantId, warnings, ct); + + DisplayResults(input, warnings); + } + + private ResolvedInput? ResolveInputs(RawPublishArgs args) + { + try + { + var environmentId = args.EnvironmentId; + if (string.IsNullOrWhiteSpace(environmentId)) + { + environmentId = DevelopMcpCommand.InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID"); + if (string.IsNullOrWhiteSpace(environmentId)) { _logger.LogError("Environment ID is required"); return null; } + } + else + { + environmentId = DevelopMcpCommand.InputValidator.ValidateInput(environmentId, "Environment ID"); + if (environmentId == null) { _logger.LogError("Invalid environment ID format"); return null; } + } + + var serverName = args.ServerName; + if (string.IsNullOrWhiteSpace(serverName)) + { + serverName = DevelopMcpCommand.InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to publish: ", "Server name", 100); + if (string.IsNullOrWhiteSpace(serverName)) { _logger.LogError("Server name is required"); return null; } + } + else + { + serverName = DevelopMcpCommand.InputValidator.ValidateInput(serverName, "Server name"); + if (serverName == null) { _logger.LogError("Invalid server name format"); return null; } + } + + var alias = args.Alias; + if (string.IsNullOrWhiteSpace(alias)) + { + alias = DevelopMcpCommand.InputValidator.PromptAndValidateRequiredInput("Enter alias for the MCP server: ", "Alias", 50); + if (string.IsNullOrWhiteSpace(alias)) { _logger.LogError("Alias is required"); return null; } + } + else + { + alias = DevelopMcpCommand.InputValidator.ValidateInput(alias, "Alias", maxLength: 50); + if (alias == null) { _logger.LogError("Invalid alias format"); return null; } + } + + var displayName = args.DisplayName; + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = DevelopMcpCommand.InputValidator.PromptAndValidateRequiredInput("Enter display name for the MCP server: ", "Display name", 100); + if (string.IsNullOrWhiteSpace(displayName)) { _logger.LogError("Display name is required"); return null; } + } + else + { + displayName = DevelopMcpCommand.InputValidator.ValidateInput(displayName, "Display name", maxLength: 100); + if (displayName == null) { _logger.LogError("Invalid display name format"); return null; } + } + + return new ResolvedInput + { + EnvironmentId = environmentId, + ServerName = serverName, + Alias = alias, + DisplayName = displayName, + DryRun = args.DryRun, + TenantId = args.TenantId, + ServiceTreeId = args.ServiceTreeId, + }; + } + catch (ArgumentException ex) + { + _logger.LogError("Input validation failed: {Message}", ex.Message); + return null; + } + } + + private void DisplayPublishSummary(ResolvedInput input) + { + Console.WriteLine(); + var prevColor = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("Publish Summary"); + Console.WriteLine("==============="); + Console.ForegroundColor = prevColor; + DevelopMcpCommand.WriteLabel(" Environment: "); Console.WriteLine(input.EnvironmentId); + DevelopMcpCommand.WriteLabel(" Server Name: "); Console.WriteLine(input.ServerName); + DevelopMcpCommand.WriteLabel(" Alias: "); Console.WriteLine(input.Alias); + DevelopMcpCommand.WriteLabel(" Display Name: "); Console.WriteLine(input.DisplayName); + Console.WriteLine(); + } + + private async Task DetectTenantIdAsync(string? userTenantId) + { + var tenantId = userTenantId; + if (string.IsNullOrWhiteSpace(tenantId)) + { + tenantId = await TenantDetectionHelper.DetectTenantIdAsync(null, _logger); + } + + if (string.IsNullOrWhiteSpace(tenantId)) + { + _logger.LogError("Tenant ID could not be determined. Pass --tenant-id or run 'az login'."); + return null; + } + + return tenantId; + } + + private async Task CreateEntraAppsAsync(ResolvedInput input, string tenantId, List warnings) + { + var a365AppName = $"{input.ServerName}-A365Proxy"; + var publicClientsAppName = $"{input.ServerName}-PublicClients"; + + _logger.LogDebug("Creating Entra application for A365 Proxy..."); + var a365App = await _graphApiService!.CreateEntraAppAsync(tenantId, a365AppName, serviceTreeId: input.ServiceTreeId); + if (a365App == null) + { + _logger.LogError("Failed to create Entra application '{AppName}'. Ensure you have Application.ReadWrite.All permission in the target tenant. Run with -v for details.", a365AppName); + return null; + } + _logger.LogInformation("Created Entra app '{AppName}' (clientId: {ClientId})", a365AppName, a365App.Value.ClientId); + + var a365Secret = await _graphApiService.AddAppPasswordAsync(tenantId, a365App.Value.ObjectId); + if (string.IsNullOrWhiteSpace(a365Secret)) + { + _logger.LogError("Failed to create secret for '{AppName}'. Run with -v for details.", a365AppName); + return null; + } + + if (string.IsNullOrWhiteSpace(a365App.Value.ClientId)) + { + _logger.LogError("A365 Proxy Entra application was created but returned an empty client ID"); + return null; + } + + _logger.LogDebug("Created A365 Proxy app: {ClientId}", a365App.Value.ClientId); + + string? publicClientsClientId = null; + string? publicClientsObjectId = null; + + _logger.LogDebug("Creating Entra application for Public Clients..."); + var copilotApp = await _graphApiService.CreateEntraAppAsync(tenantId, publicClientsAppName, serviceTreeId: input.ServiceTreeId); + if (copilotApp != null) + { + publicClientsClientId = copilotApp.Value.ClientId; + publicClientsObjectId = copilotApp.Value.ObjectId; + _logger.LogInformation("Created Entra app '{AppName}' (clientId: {ClientId})", publicClientsAppName, publicClientsClientId); + + var copilotRedirectUri = $"ms-appx-web://Microsoft.AAD.BrokerPlugin/{publicClientsClientId}"; + var publicClientUris = new[] { copilotRedirectUri, "http://localhost:8080/callback", "https://vscode.dev/redirect", "http://localhost" }; + try + { + var success = await _retryHelper.ExecuteWithRetryAsync( + async ct => await _graphApiService.UpdateAppPublicClientRedirectUrisAsync(tenantId, publicClientsObjectId, publicClientUris, ct), + result => !result); + if (!success) + { + var msg = $"Failed to set redirect URIs on Public Clients app '{publicClientsAppName}' after retries."; + _logger.LogError(msg); + warnings.Add(msg); + } + else + { + _logger.LogDebug( + "Set {RedirectUriCount} redirect URIs on '{AppName}' ({ObjectId}): {RedirectUris}", + publicClientUris.Length, + publicClientsAppName, + publicClientsObjectId, + string.Join(", ", publicClientUris)); + } + } + catch (Exception ex) + { + var msg = $"Failed to set redirect URIs on Public Clients app: {ex.Message}"; + _logger.LogError(msg); + warnings.Add(msg); + } + } + else + { + var msg = "Failed to create Public Clients Entra app. Continuing without it."; + _logger.LogWarning(msg); + warnings.Add(msg); + } + + return new EntraAppSet( + A365AppClientId: a365App.Value.ClientId, + A365AppSecret: a365Secret, + A365AppObjectId: a365App.Value.ObjectId, + A365AppName: a365AppName, + PublicClientsClientId: publicClientsClientId, + PublicClientsObjectId: publicClientsObjectId, + PublicClientsAppName: publicClientsAppName); + } + + private async Task ConfigureEntraAppsAsync( + ResolvedInput input, + EntraAppSet apps, + PublishMcpServerResponse response, + string tenantId, + List warnings, + CancellationToken ct = default) + { + var tasks = new List(); + var concurrentWarnings = new System.Collections.Concurrent.ConcurrentBag(); + + var a365RedirectUri = response.A365ProxyRedirectUri; + + if (!string.IsNullOrWhiteSpace(a365RedirectUri)) + { + tasks.Add(UpdateA365RedirectUrisAsync(tenantId, apps, a365RedirectUri, concurrentWarnings, ct)); + } + else + { + var msg = "A365 Proxy redirect URI was not returned by the server. Redirect URI configuration skipped."; + _logger.LogWarning(msg); + concurrentWarnings.Add(msg); + } + + var ppmiAppClientId = response.PpmiAppClientId; + Guid? ppmiScopeId = null; + if (!string.IsNullOrWhiteSpace(ppmiAppClientId)) + { + _logger.LogDebug("PPMI app provisioned: {PpmiAppClientId}", ppmiAppClientId); + try + { + ppmiScopeId = await _retryHelper.ExecuteWithRetryAsync( + async retryCt => await _graphApiService!.GetOAuth2PermissionScopeIdAsync( + tenantId, ppmiAppClientId, "Tools.ListInvoke.All", retryCt), + result => !result.HasValue, + cancellationToken: ct); + } + catch (Exception ex) + { + var msg = $"Could not find 'Tools.ListInvoke.All' scope on PPMI app {ppmiAppClientId} after retries: {ex.Message}. API permissions not added."; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + } + + if (ppmiScopeId.HasValue) + { + tasks.Add(AddPpmiPermissionAsync(tenantId, apps.A365AppObjectId, apps.A365AppName, ppmiAppClientId!, ppmiScopeId.Value, concurrentWarnings, ct)); + + if (apps.PublicClientsObjectId != null) + { + tasks.Add(AddPpmiPermissionAsync(tenantId, apps.PublicClientsObjectId, apps.PublicClientsAppName, ppmiAppClientId!, ppmiScopeId.Value, concurrentWarnings, ct)); + } + } + else if (!string.IsNullOrWhiteSpace(ppmiAppClientId) && ppmiScopeId == null) + { + var msg = $"Could not find 'Tools.ListInvoke.All' scope on PPMI app {ppmiAppClientId}. API permissions not added."; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + + await Task.WhenAll(tasks); + + foreach (var w in concurrentWarnings) + warnings.Add(w); + } + + private async Task UpdateA365RedirectUrisAsync( + string tenantId, + EntraAppSet apps, + string a365RedirectUri, + System.Collections.Concurrent.ConcurrentBag concurrentWarnings, + CancellationToken ct = default) + { + try + { + var a365TcUri = DevelopMcpCommand.AddTcPrefix(a365RedirectUri); + var a365NonTcUri = DevelopMcpCommand.RemoveTcPrefix(a365RedirectUri); + var a365Uris = DevelopMcpCommand.BuildRedirectUriList(a365RedirectUri, a365TcUri, a365NonTcUri); + _logger.LogDebug("Updating redirect URIs on '{AppName}' ({ObjectId})", apps.A365AppName, apps.A365AppObjectId); + var success = await _retryHelper.ExecuteWithRetryAsync( + async retryCt => await _graphApiService!.UpdateAppRedirectUrisAsync(tenantId, apps.A365AppObjectId, a365Uris, retryCt), + result => !result, + cancellationToken: ct); + if (!success) + { + var msg = $"Failed to update redirect URIs on A365 Proxy app '{apps.A365AppName}' after retries."; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + else + { + _logger.LogInformation("Updated redirect URIs on '{AppName}'", apps.A365AppName); + } + } + catch (Exception ex) + { + var msg = $"Failed to update redirect URIs on A365 Proxy app: {ex.Message}"; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + } + + private async Task AddPpmiPermissionAsync( + string tenantId, + string appObjectId, + string appName, + string ppmiAppClientId, + Guid ppmiScopeId, + System.Collections.Concurrent.ConcurrentBag concurrentWarnings, + CancellationToken ct = default) + { + try + { + _logger.LogDebug("Adding PPMI 'Tools.ListInvoke.All' permission on '{AppName}' ({ObjectId})", appName, appObjectId); + var success = await _retryHelper.ExecuteWithRetryAsync( + async retryCt => await _graphApiService!.AddRequiredResourceAccessAsync( + tenantId, appObjectId, ppmiAppClientId, ppmiScopeId, retryCt), + result => !result, + cancellationToken: ct); + if (!success) + { + var msg = $"Failed to add PPMI permission on '{appName}' after retries."; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + else + { + _logger.LogInformation("Added API permission on '{AppName}'", appName); + } + } + catch (Exception ex) + { + var msg = $"Failed to add PPMI permission on '{appName}': {ex.Message}"; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + } + + private void DisplayResults(ResolvedInput input, List warnings) + { + Console.WriteLine(); + if (warnings.Count == 0) + { + var prevColor = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"MCP server '{input.ServerName}' published as '{input.Alias}' to environment {input.EnvironmentId}."); + Console.ForegroundColor = prevColor; + } + else + { + var prevColor = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"MCP server '{input.ServerName}' was published with {warnings.Count} warning(s):"); + Console.ForegroundColor = prevColor; + Console.WriteLine(); + foreach (var w in warnings) + { + _logger.LogWarning(" - {Warning}", w); + } + } + + Console.WriteLine(); + Console.WriteLine($"Please ask your tenant admin to approve MCP server '{input.ServerName}' in the Microsoft 365 Admin Center."); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs index 966def0b..67af70da 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs @@ -1,23 +1,46 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using System; using System.Text.Json.Serialization; namespace Microsoft.Agents.A365.DevTools.Cli.Models; /// -/// Request model for publishing an MCP server to a Dataverse environment +/// Request model for publishing an MCP server to a Dataverse environment. /// public class PublishMcpServerRequest { /// - /// Alias for the MCP server + /// Alias for the MCP server. /// [JsonPropertyName("alias")] public required string Alias { get; set; } /// - /// Display name for the MCP server + /// Display name for the MCP server. /// [JsonPropertyName("DisplayName")] public required string DisplayName { get; set; } + + /// + /// A365 Proxy Entra app client id created CLI-side at publish time. Paired with + /// . When provided, the platform creates an A365 Proxy CMS + /// connector keyed by server name so the published server is reachable through Power Platform / Copilot. + /// + [JsonPropertyName("a365ProxyClientId")] + public Guid? A365ProxyClientId { get; set; } + + /// + /// A365 Proxy Entra app client secret. Paired with . + /// + [JsonPropertyName("a365ProxyClientSecret")] + public string? A365ProxyClientSecret { get; set; } + + /// + /// Public Clients (VS Code / Copilot CLI) Entra app client id created CLI-side. Carried in the + /// request so the platform echoes it back in the response and the CLI can wire the PPMI + /// Tools.ListInvoke.All scope onto it after publish completes. + /// + [JsonPropertyName("publicClientsAppId")] + public string? PublicClientsAppId { get; set; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs index 1b874682..af5b0ebe 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs @@ -1,28 +1,58 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using System; using System.Text.Json.Serialization; namespace Microsoft.Agents.A365.DevTools.Cli.Models; /// -/// Response model for MCP server publish operation +/// Response model for MCP server publish operation. /// public class PublishMcpServerResponse { /// - /// Status of the publish operation + /// Status of the publish operation. /// [JsonPropertyName("Status")] public string? Status { get; set; } /// - /// Message from the API response + /// Message from the API response. /// [JsonPropertyName("Message")] public string? Message { get; set; } /// - /// Whether the operation was successful + /// PPMI app client id for the published server. Used by the CLI to look up the + /// Tools.ListInvoke.All scope id and grant it to the A365 Proxy + Public Clients Entra + /// apps after publish completes. + /// + [JsonPropertyName("PpmiAppClientId")] + public string? PpmiAppClientId { get; set; } + + /// + /// CMS connector id created at publish time for the A365 Proxy connector, or null when the CLI + /// didn't pass Entra app credentials (older CLI flow) and no connector was created. + /// + [JsonPropertyName("A365ProxyConnectorId")] + public string? A365ProxyConnectorId { get; set; } + + /// + /// OAuth redirect URI for the A365 Proxy connector. The CLI writes this onto the just-created + /// A365 Proxy Entra app's redirect URI list (with tc / non-tc variants) so OAuth flows complete. + /// + [JsonPropertyName("A365ProxyRedirectUri")] + public string? A365ProxyRedirectUri { get; set; } + + /// + /// Public Clients Entra app client id, echoed back from the request so post-response + /// orchestration can grant the PPMI scope onto it. + /// + [JsonPropertyName("PublicClientsAppId")] + public string? PublicClientsAppId { get; set; } + + /// + /// Whether the operation was successful. /// [JsonIgnore] public bool IsSuccess => Status?.Equals("Success", StringComparison.OrdinalIgnoreCase) ?? false; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 374357e9..47f935a7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -251,7 +251,7 @@ private string BuildListMcpServersUrl(string environment, string environmentId) } /// - /// Builds URL for publishing an MCP server to a Dataverse environment + /// Builds URL for publishing an MCP server to a Dataverse environment (v1) /// /// Environment name /// Dataverse environment ID @@ -263,6 +263,20 @@ private string BuildPublishMcpServerUrl(string environment, string environmentId return $"{baseUrl}/agents/dataverse/environments/{environmentId}/mcpServers/{serverName}/publish"; } + /// + /// Builds URL for the v2 publish endpoint, which performs the full elevation orchestration + /// (lazy PPMI, MOS upload, A365 Proxy CMS connector creation). + /// + /// Environment name + /// Dataverse environment ID + /// MCP server name + /// Full URL for v2 publish MCP server endpoint + private string BuildPublishMcpServerV2Url(string environment, string environmentId, string serverName) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/v2/dataverse/environments/{environmentId}/mcpServers/{serverName}/publish"; + } + /// /// Builds URL for unpublishing an MCP server from a Dataverse environment /// @@ -581,6 +595,80 @@ private string BuildGetMCPServerUrl(string environment) } } + /// + public async Task PublishServerV2Async( + string environmentId, + string serverName, + PublishMcpServerRequest request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(environmentId)) + throw new ArgumentException("Environment ID cannot be null or empty", nameof(environmentId)); + if (string.IsNullOrWhiteSpace(serverName)) + throw new ArgumentException("Server name cannot be null or empty", nameof(serverName)); + if (request == null) + throw new ArgumentNullException(nameof(request)); + + try + { + var endpointUrl = BuildPublishMcpServerV2Url(_environment, environmentId, serverName); + var correlationId = Internal.HttpClientFactory.GenerateCorrelationId(); + + _logger.LogDebug("Publishing (v2) MCP server {ServerName} to environment {EnvId} (CorrelationId: {CorrelationId})", serverName, environmentId, correlationId); + _logger.LogDebug("Environment: {Env}", _environment); + _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, ct: cancellationToken); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return null; + } + + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId); + + var requestPayload = JsonSerializer.Serialize(request); + var jsonContent = new StringContent(requestPayload, System.Text.Encoding.UTF8, "application/json"); + + LogRequest("POST", endpointUrl, requestPayload); + + using var response = await httpClient.PostAsync(endpointUrl, jsonContent, cancellationToken); + + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "publish (v2) MCP server", cancellationToken); + if (!isSuccess) + { + return null; + } + + if (string.IsNullOrWhiteSpace(responseContent)) + { + return new PublishMcpServerResponse + { + Status = "Success", + Message = $"Successfully published {serverName}", + }; + } + + var publishResponse = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent, _logger); + + return publishResponse ?? new PublishMcpServerResponse + { + Status = "Success", + Message = $"Successfully published {serverName}", + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish (v2) MCP server {ServerName} to environment {EnvId}", serverName, environmentId); + return null; + } + } + /// public async Task UnpublishServerAsync( string environmentId, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs index dd110f90..30103ab3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs @@ -46,6 +46,23 @@ public interface IAgent365ToolingService PublishMcpServerRequest request, CancellationToken cancellationToken = default); + /// + /// Publishes an MCP server via the v2 endpoint, which performs the full elevation orchestration + /// (lazy PPMI provision, MOS upload, A365 Proxy CMS connector creation when Entra creds are + /// supplied in the request). v1 remains for callers relying on + /// the original side-effect-free behavior. + /// + /// Dataverse environment ID + /// MCP server name to publish + /// Publish request with alias, display name, and optional Entra app credentials + /// Cancellation token + /// Response from the publish operation, including PPMI app id, A365 Proxy connector id, and redirect URI + Task PublishServerV2Async( + string environmentId, + string serverName, + PublishMcpServerRequest request, + CancellationToken cancellationToken = default); + /// /// Unpublishes an MCP server from a Dataverse environment /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs index 50a9c375..d1d74b94 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs @@ -94,46 +94,35 @@ public async Task AzureCliStyleParameters_AreAcceptedCorrectly(string command, p result.Should().Be(0, $"Azure CLI style command should be accepted: {string.Join(" ", fullCommand)}"); } - [Fact] - public async Task ServiceIntegration_PublishCommand_PassesCorrectParameters() + [Fact] + public async Task ServiceIntegration_PublishCommand_AcceptsAllNamedParameters() { - // Core functionality test: Ensures publish command integration works correctly - + // Verifies the publish CLI parses every documented flag without error. The publish flow now + // orchestrates Entra app creation + redirect-URI back-fill via GraphApiService (mirroring + // register-external-mcp-server), so end-to-end "params flow to PublishServerAsync" can't be + // exercised here without mocking Graph too — that path is covered by the + // regression test and by manual E2E testing. + // Arrange var testEnvId = "test-environment-123"; var testServerName = "msdyn_TestServer"; var testAlias = "test-alias"; var testDisplayName = "Test Server Display Name"; - var mockResponse = new PublishMcpServerResponse + // Act — dry-run short-circuits the Graph + platform calls so this stays a pure CLI parsing test. + var result = await _command.InvokeAsync(new[] { - Status = "Success", - Message = "Server published successfully" - }; - - _mockToolingService.PublishServerAsync(testEnvId, testServerName, Arg.Any()) - .Returns(mockResponse); - - // Act - var result = await _command.InvokeAsync(new[] - { - "publish", + "publish", "--environment-id", testEnvId, "--server-name", testServerName, "--alias", testAlias, - "--display-name", testDisplayName + "--display-name", testDisplayName, + "--dry-run", }); - // Assert + // Assert — successful parse + dispatch, no service calls. result.Should().Be(0); - - await _mockToolingService.Received(1).PublishServerAsync( - testEnvId, - testServerName, - Arg.Is(req => - req.Alias == testAlias && - req.DisplayName == testDisplayName) - ); + await _mockToolingService.DidNotReceive().PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs index 9e1d2416..504b83e3 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -115,31 +115,39 @@ public void PublishSubcommand_HasCorrectOptionsWithAliases() var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); var subcommand = command.Subcommands.First(sc => sc.Name == "publish"); - // Assert - subcommand.Description.Should().Be("Publish an MCP server to a Dataverse environment"); - + // Assert — description copy was extended in the BYO-parity work to call out the Entra app + + // back-fill orchestration the executor now performs, so match on the Azure-CLI-style verb prefix + // rather than the full string. + subcommand.Description.Should().StartWith("Publish an MCP server to a Dataverse environment"); + var options = subcommand.Options.ToList(); - - // Verify all expected options exist + + // Verify all expected options exist (including tenant-id + service-tree-id added for the Entra + // app creation step that mirrors register-external-mcp-server). var optionNames = options.Select(o => o.Name).ToList(); optionNames.Should().Contain("environment-id"); optionNames.Should().Contain("server-name"); optionNames.Should().Contain("alias"); optionNames.Should().Contain("display-name"); + optionNames.Should().Contain("tenant-id"); + optionNames.Should().Contain("service-tree-id"); optionNames.Should().Contain("dry-run"); // Verify critical aliases for Azure CLI compliance var envOption = options.FirstOrDefault(o => o.Name == "environment-id"); envOption!.Aliases.Should().Contain("-e"); - + var serverOption = options.FirstOrDefault(o => o.Name == "server-name"); serverOption!.Aliases.Should().Contain("-s"); - + var aliasOption = options.FirstOrDefault(o => o.Name == "alias"); aliasOption!.Aliases.Should().Contain("-a"); - + var displayNameOption = options.FirstOrDefault(o => o.Name == "display-name"); displayNameOption!.Aliases.Should().Contain("-d"); + + var tenantOption = options.FirstOrDefault(o => o.Name == "tenant-id"); + tenantOption!.Aliases.Should().Contain("-t"); } [Fact]