From 62dd3998be7406f8eabd950d76129fffa8f02f96 Mon Sep 17 00:00:00 2001 From: Deepali Garg Date: Mon, 21 Sep 2026 21:53:30 -0700 Subject: [PATCH 1/3] Provision A365 proxy app on develop-mcp publish Publish now creates the confidential A365 proxy Entra app (app + secret) alongside the PublicClients app and forwards its credentials to the platform, so custom (non-Dataverse) MCP servers get a Power Platform connector created at publish time instead of the platform logging A365ProxyConnectorCreation=SkippedNoCredentials. Mirrors the register flow: proxy app created first (fatal on failure, with self-cleanup), request carries the proxy clientId/secret, proxy redirect URIs are updated post-publish, and rollback deletes both apps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Commands/PublishCommandExecutor.cs | 78 ++++++- .../Models/PublishMcpServerRequest.cs | 16 ++ .../Models/PublishMcpServerResponse.cs | 16 ++ .../DevelopMcpCommandRegressionTests.cs | 17 ++ .../PublishCommandExecutorDryRunTests.cs | 4 +- .../PublishCommandExecutorEntraAppTests.cs | 213 ++++++++++++++++++ 7 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aa369b..ba229c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `develop-mcp publish` now creates the A365 proxy Entra app and sends its credentials to the platform, so custom (non-Dataverse) MCP servers get a Power Platform connector created at publish time. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs index c60e3ed0..031357b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs @@ -73,7 +73,11 @@ private sealed record ResolvedInput internal sealed record EntraAppSet( string? PublicClientsClientId, string? PublicClientsObjectId, - string PublicClientsAppName); + string PublicClientsAppName, + string A365AppClientId, + string A365AppSecret, + string A365AppObjectId, + string A365AppName); internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct = default) { @@ -84,7 +88,7 @@ internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct if (input.DryRun) { - _logger.LogInformation("[DRY RUN] Would create Entra app '{PublicClients}' in tenant", $"{input.ServerName}-PublicClients"); + _logger.LogInformation("[DRY RUN] Would create Entra apps '{PublicClients}' and '{A365Proxy}' in tenant", $"{input.ServerName}-PublicClients", $"{input.ServerName}-A365Proxy"); _logger.LogInformation("[DRY RUN] Would call publish endpoint and back-fill PPMI scope on the created app"); return true; } @@ -131,6 +135,8 @@ internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct DisplayName = input.DisplayName, PublicClientsAppId = apps.PublicClientsClientId, PublisherName = input.PublisherName, + A365ProxyClientId = apps.A365AppClientId, + A365ProxyClientSecret = apps.A365AppSecret, }; PublishMcpServerResponse? publishResponse; @@ -318,13 +324,24 @@ private void DisplayPublishSummary(ResolvedInput input) { var provisioner = new EntraAppProvisioner(_logger, _graphApiService!, _retryHelper); + // Confidential A365 proxy app + secret. Required for custom (non-Dataverse) servers: the + // platform creates the Power Platform connector only when its credentials are supplied. + var a365ProxyApp = await provisioner.CreateProxyAppAsync( + input.ServerName, tenantId, suffix: "A365Proxy", roleDisplay: "A365 Proxy", + serviceTreeId: null, ct: ct); + if (a365ProxyApp is null) return null; + var publicClients = await provisioner.CreatePublicClientsAppAsync( input.ServerName, tenantId, serviceTreeId: null, warnings, ct); return new EntraAppSet( PublicClientsClientId: publicClients.ClientId, PublicClientsObjectId: publicClients.ObjectId, - PublicClientsAppName: publicClients.AppName); + PublicClientsAppName: publicClients.AppName, + A365AppClientId: a365ProxyApp.ClientId, + A365AppSecret: a365ProxyApp.Secret, + A365AppObjectId: a365ProxyApp.ObjectId, + A365AppName: a365ProxyApp.AppName); } // Best-effort compensating delete for the Entra apps created in CreateEntraAppsAsync, run when @@ -335,7 +352,7 @@ internal async Task RollbackEntraAppsAsync(EntraAppSet apps, string tenantId, Ca { if (_graphApiService is null) { - _logger.LogWarning("Graph API service is unavailable; cannot roll back Entra app '{PublicClients}'. Delete it manually in the Azure portal.", apps.PublicClientsAppName); + _logger.LogWarning("Graph API service is unavailable; cannot roll back Entra apps '{PublicClients}' and '{A365Proxy}'. Delete them manually in the Azure portal.", apps.PublicClientsAppName, apps.A365AppName); return; } @@ -346,6 +363,11 @@ internal async Task RollbackEntraAppsAsync(EntraAppSet apps, string tenantId, Ca await DeleteOneAsync(apps.PublicClientsObjectId, apps.PublicClientsClientId, apps.PublicClientsAppName, ct); } + if (!string.IsNullOrWhiteSpace(apps.A365AppObjectId)) + { + await DeleteOneAsync(apps.A365AppObjectId, apps.A365AppClientId, apps.A365AppName, ct); + } + async Task DeleteOneAsync(string objectId, string? clientId, string appName, CancellationToken cancellationToken) { try @@ -430,12 +452,60 @@ private async Task ConfigureEntraAppsAsync( concurrentWarnings.Add(msg); } + // Custom (non-Dataverse) servers get a Power Platform connector whose redirect URI the + // platform returns here. Write it onto the A365 proxy app so the connector's OAuth flow works. + 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 publish. Redirect URI configuration skipped."; + _logger.LogWarning(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 AddRequiredResourceAccessAsync( string tenantId, string appObjectId, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs index ceb92080..b767f6ce 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs @@ -37,4 +37,20 @@ public class PublishMcpServerRequest /// [JsonPropertyName("publisherName")] public string? PublisherName { get; set; } + + /// + /// A365 proxy (confidential) Entra app client id created CLI-side. The platform's v2 publish + /// path creates the Power Platform connector for custom (non-Dataverse) servers only when both + /// this and are supplied; otherwise connector creation is + /// skipped (A365ProxyConnectorCreation=SkippedNoCredentials). + /// + [JsonPropertyName("a365ProxyClientId")] + public string? A365ProxyClientId { get; set; } + + /// + /// Client secret for the A365 proxy Entra app. Paired with so the + /// platform can create the Power Platform connector for custom servers. + /// + [JsonPropertyName("a365ProxyClientSecret")] + public string? A365ProxyClientSecret { 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 2ea1e2b9..e9e05166 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs @@ -47,6 +47,22 @@ public class PublishMcpServerResponse [JsonPropertyName("PublicClientsAppId")] public string? PublicClientsAppId { get; set; } + /// + /// Redirect URI the platform assigns to the A365 proxy connector for custom servers. When + /// present, the CLI writes the tc/non-tc redirect URI list onto the A365 proxy Entra app it + /// created. Emitted PascalCase by the platform, same as . + /// + [JsonPropertyName("A365ProxyRedirectUri")] + public string? A365ProxyRedirectUri { get; set; } + + /// + /// Id of the Power Platform connector the platform created for the custom server, when proxy + /// credentials were supplied. Surfaced for logging/parity; empty when connector creation was + /// skipped. + /// + [JsonPropertyName("A365ProxyConnectorId")] + public string? A365ProxyConnectorId { get; set; } + /// /// Whether the operation was successful. /// 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 5d9a079d..5915ce78 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 @@ -155,6 +155,12 @@ public async Task PublishCommand_ForwardsParsedParametersToToolingService() TestTenantId, TestPublicClientsObjectId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); + // Publish now also creates the confidential A365 proxy app + secret (required so the platform + // creates the Power Platform connector for custom servers). Stub the secret so proxy creation succeeds. + graphApiService.AddAppPasswordAsync( + TestTenantId, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("a365-proxy-secret")); + // Mock Graph for ConfigureEntraAppsAsync → required-resource-access grant on Public Clients. graphApiService.GetOAuth2PermissionScopeIdAsync( TestTenantId, Arg.Any(), Arg.Any(), Arg.Any()) @@ -224,6 +230,14 @@ public async Task PublishCommand_ForwardsParsedParametersToToolingService() because: "the just-created Public Clients Entra app's clientId must be carried to the " + "platform so it can be echoed back and the CLI can grant the PPMI scope on it " + "post-publish."); + capturedRequest.A365ProxyClientId.Should().NotBeNullOrEmpty( + because: "the confidential A365 proxy app's clientId must be forwarded so the platform " + + "creates the Power Platform connector for custom servers instead of logging " + + "SkippedNoCredentials."); + capturedRequest.A365ProxyClientSecret.Should().Be( + "a365-proxy-secret", + because: "the proxy app's secret must be forwarded alongside its clientId; the platform " + + "requires both to create the connector."); } /// @@ -256,6 +270,9 @@ public async Task PublishCommand_ExplicitEmptyPublisherName_SkipsPromptAndForwar graphApiService.UpdateAppPublicClientRedirectUrisAsync( TestTenantId, TestPublicClientsObjectId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); + graphApiService.AddAppPasswordAsync( + TestTenantId, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("a365-proxy-secret")); graphApiService.GetOAuth2PermissionScopeIdAsync( TestTenantId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Guid.NewGuid())); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs index 1989ea12..6816c19b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs @@ -13,12 +13,12 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// /// Tests for dry-run output. The dry-run log must mirror the /// real Entra app naming scheme (derived from ServerName) so users can predict what will be -/// created — the {ServerName}-PublicClients app. +/// created — the {ServerName}-PublicClients and {ServerName}-A365Proxy apps. /// public class PublishCommandExecutorDryRunTests { /// - /// The dry-run log must (a) name only the Public Clients app — derived from ServerName, + /// The dry-run log must (a) name the Public Clients app — derived from ServerName, /// not Alias — (b) describe a PPMI-scope-only back-fill (no redirect-URI back-fill), and /// (c) skip the platform publish call entirely. /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs new file mode 100644 index 00000000..4c25b2e4 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +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; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Covers the Entra-app orchestration performs for custom +/// (non-Dataverse) MCP servers: the confidential A365 proxy app must be created and its credentials +/// forwarded to the platform so the Power Platform connector is created at publish time, and both +/// created apps must be rolled back on failure. These invariants are what let the platform stop +/// logging A365ProxyConnectorCreation=SkippedNoCredentials. +/// +/// Tests substitute the concrete (all Entra calls are virtual) and let +/// the real run against it, mirroring the production wiring. +/// +public class PublishCommandExecutorEntraAppTests +{ + private const string TenantId = "00000000-0000-0000-0000-000000000001"; + private const string EnvironmentId = "00000000-0000-0000-0000-000000000000"; + private const string ServerName = "mcp_TestServer"; + + private static RawPublishArgs MakeArgs() => new( + EnvironmentId: EnvironmentId, + ServerName: ServerName, + Alias: "myAlias", + DisplayName: "Test Display", + PublisherName: "Contoso", + Yes: true, + DryRun: false); + + private static PublishCommandExecutor MakeExecutor( + ILogger logger, IAgent365ToolingService tooling, GraphApiService graph) + { + var retry = new RetryHelper(logger, maxRetries: 1, baseDelaySeconds: 0); + return new TestablePublishCommandExecutor(logger, tooling, graph, retry, TenantId); + } + + /// + /// Stubs a successful two-app creation: the A365 proxy app (with a secret) and the Public + /// Clients app. Returns the proxy client id / secret / object id the tests assert on. + /// + private static (string ProxyClientId, string ProxySecret, string ProxyObjectId) ArrangeSuccessfulAppCreation(GraphApiService graph) + { + const string proxyObjectId = "proxy-object-id"; + const string proxyClientId = "proxy-client-id"; + const string proxySecret = "proxy-secret"; + + graph.CreateEntraAppAsync(Arg.Any(), Arg.Is(n => n.EndsWith("-A365Proxy")), Arg.Any(), Arg.Any()) + .Returns((proxyObjectId, proxyClientId)); + graph.CreateEntraAppAsync(Arg.Any(), Arg.Is(n => n.EndsWith("-PublicClients")), Arg.Any(), Arg.Any()) + .Returns(("pc-object-id", "pc-client-id")); + graph.AddAppPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(proxySecret); + graph.UpdateAppPublicClientRedirectUrisAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(true); + graph.UpdateAppRedirectUrisAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(true); + graph.DeleteEntraAppAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + return (proxyClientId, proxySecret, proxyObjectId); + } + + [Fact] + public async Task ExecuteAsync_WhenProxyAppCreationFails_AbortsPublish_WithoutCallingPlatform() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + // Proxy app creation fails; public-clients creation is never reached because the proxy app + // is mandatory for custom servers. + graph.CreateEntraAppAsync(Arg.Any(), Arg.Is(n => n.EndsWith("-A365Proxy")), Arg.Any(), Arg.Any()) + .Returns(((string, string)?)null); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeFalse("proxy app creation failure must fail the publish for custom servers"); + await tooling.DidNotReceiveWithAnyArgs().PublishServerAsync(default!, default!, default!, default); + } + + [Fact] + public async Task ExecuteAsync_ForwardsProxyCredentials_ToPlatformPublishRequest() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + var (proxyClientId, proxySecret, _) = ArrangeSuccessfulAppCreation(graph); + + PublishMcpServerRequest? capturedRequest = null; + tooling.PublishServerAsync(EnvironmentId, ServerName, Arg.Do(r => capturedRequest = r), Arg.Any()) + .Returns(new PublishMcpServerResponse { Status = "Success" }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.A365ProxyClientId.Should().Be(proxyClientId, + because: "the platform creates the Power Platform connector only when the proxy app's client id is supplied"); + capturedRequest.A365ProxyClientSecret.Should().Be(proxySecret, + because: "the platform needs the proxy app's secret to create the connector; without it it logs SkippedNoCredentials"); + } + + [Fact] + public async Task ExecuteAsync_WhenProxyRedirectUriReturned_UpdatesProxyAppRedirectUris() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + var (_, _, proxyObjectId) = ArrangeSuccessfulAppCreation(graph); + + tooling.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse + { + Status = "Success", + A365ProxyRedirectUri = "https://global.consent.azure-apim.net/redirect", + }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + await graph.Received(1).UpdateAppRedirectUrisAsync( + TenantId, proxyObjectId, Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WhenProxyRedirectUriMissing_WarnsAndSkipsRedirectUpdate() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + ArrangeSuccessfulAppCreation(graph); + + tooling.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse { Status = "Success" }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + await graph.DidNotReceive().UpdateAppRedirectUrisAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); + logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("A365 Proxy redirect URI was not returned")), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task RollbackEntraAppsAsync_DeletesBothPublicClientsAndProxyApps() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + graph.DeleteEntraAppAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + var executor = MakeExecutor(logger, tooling, graph); + + var apps = new PublishCommandExecutor.EntraAppSet( + PublicClientsClientId: "pc-client-id", + PublicClientsObjectId: "pc-object-id", + PublicClientsAppName: $"{ServerName}-PublicClients", + A365AppClientId: "proxy-client-id", + A365AppSecret: "proxy-secret", + A365AppObjectId: "proxy-object-id", + A365AppName: $"{ServerName}-A365Proxy"); + + await executor.RollbackEntraAppsAsync(apps, TenantId, CancellationToken.None); + + await graph.Received(1).DeleteEntraAppAsync(TenantId, "pc-object-id", Arg.Any()); + await graph.Received(1).DeleteEntraAppAsync(TenantId, "proxy-object-id", Arg.Any()); + } + + /// + /// Overrides only the tenant-detection seam (which shells out to Azure CLI) so the rest of the + /// executor runs unchanged against the substituted Graph and tooling services. + /// + private sealed class TestablePublishCommandExecutor : PublishCommandExecutor + { + private readonly string _tenantId; + + public TestablePublishCommandExecutor( + ILogger logger, IAgent365ToolingService toolingService, GraphApiService graphApiService, + RetryHelper retryHelper, string tenantId) + : base(logger, toolingService, graphApiService, retryHelper) + { + _tenantId = tenantId; + } + + protected override Task DetectTenantIdAsync() => Task.FromResult(_tenantId); + } +} From 620eda8b78730f23a49dcd821a5c2aa562271dbc Mon Sep 17 00:00:00 2001 From: Deepali Garg Date: Mon, 21 Sep 2026 22:39:16 -0700 Subject: [PATCH 2/3] Grant McpServer resource access on A365 proxy app The platform wires the A365 proxy connector with the proxy app as its OAuth client and McpServerAppId as the resource, so the proxy app must hold the McpServerScope required-resource-access grant or Entra rejects the connector's token request with AADSTS650057. ConfigureEntraAppsAsync previously granted this only on the PublicClients app; now it grants on both the proxy app and the PublicClients app, mirroring register. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Commands/PublishCommandExecutor.cs | 6 +++ .../PublishCommandExecutorEntraAppTests.cs | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs index 031357b3..c0d6b5d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs @@ -440,6 +440,12 @@ private async Task ConfigureEntraAppsAsync( if (resourceScopeId.HasValue) { + // The platform wires the A365 proxy connector with the proxy app as its OAuth client and + // McpServerAppId as the resource, so the proxy app must hold this required-resource-access + // grant or Entra rejects the token request (AADSTS650057). Grant it on both the proxy app + // and the Public Clients app, mirroring register. + tasks.Add(AddRequiredResourceAccessAsync(tenantId, apps.A365AppObjectId, apps.A365AppName, resourceAppId!, resourceScopeId.Value, concurrentWarnings, ct)); + if (apps.PublicClientsObjectId != null) { tasks.Add(AddRequiredResourceAccessAsync(tenantId, apps.PublicClientsObjectId, apps.PublicClientsAppName, resourceAppId!, resourceScopeId.Value, concurrentWarnings, ct)); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs index 4c25b2e4..7e212c08 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs @@ -167,6 +167,51 @@ await graph.DidNotReceive().UpdateAppRedirectUrisAsync( Arg.Any>()); } + /// + /// The A365 proxy app is the OAuth client of the platform-created connector, with McpServerAppId + /// as its resource. Without a required-resource-access grant for that resource on the proxy app, + /// Entra rejects the connector's token request (AADSTS650057). The grant must therefore land on + /// BOTH the proxy app and the Public Clients app. + /// + [Fact] + public async Task ExecuteAsync_GrantsMcpServerResourceAccess_OnBothProxyAndPublicClientsApps() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + var (_, _, proxyObjectId) = ArrangeSuccessfulAppCreation(graph); + + const string mcpServerAppId = "1a2a0eb6-0000-0000-0000-000000000000"; + const string mcpServerScope = "Tools.ListInvoke.All"; + var scopeId = Guid.NewGuid(); + + graph.GetOAuth2PermissionScopeIdAsync(TenantId, mcpServerAppId, mcpServerScope, Arg.Any()) + .Returns(scopeId); + graph.AddRequiredResourceAccessAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + tooling.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse + { + Status = "Success", + McpServerAppId = mcpServerAppId, + McpServerScope = mcpServerScope, + }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + await graph.Received(1).AddRequiredResourceAccessAsync( + TenantId, proxyObjectId, mcpServerAppId, scopeId, Arg.Any()); + await graph.Received(1).AddRequiredResourceAccessAsync( + TenantId, "pc-object-id", mcpServerAppId, scopeId, Arg.Any()); + await graph.Received(2).AddRequiredResourceAccessAsync( + Arg.Any(), Arg.Any(), mcpServerAppId, scopeId, Arg.Any()); + } + [Fact] public async Task RollbackEntraAppsAsync_DeletesBothPublicClientsAndProxyApps() { From 9c97cc0cbc4d2927485d5a2cfc3d74df1e1448c9 Mon Sep 17 00:00:00 2001 From: Deepali Garg Date: Tue, 22 Sep 2026 10:58:17 -0700 Subject: [PATCH 3/3] Redact a365ProxyClientSecret from request logging The publish request now carries the newly created A365 proxy Entra app secret as a365ProxyClientSecret. RedactSecretFields only masked clientApp1Secret/clientApp2Secret/clientSecret, so verbose request-payload logging wrote the live client secret in plaintext. Add a365ProxyClientSecret to the redaction key set with a regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/Agent365ToolingService.cs | 2 +- .../Agent365ToolingServicePureFunctionTests.cs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 578b0839..d65b5c93 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -196,7 +196,7 @@ private static void RedactSecretFields(System.Text.Json.Nodes.JsonObject obj) { var secretKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { - "clientApp1Secret", "clientApp2Secret", "clientSecret" + "clientApp1Secret", "clientApp2Secret", "clientSecret", "a365ProxyClientSecret" }; foreach (var key in obj.Select(p => p.Key).ToList()) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs index 86539da0..33ba7c99 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs @@ -119,6 +119,19 @@ public void RedactSecretsFromPayload_RedactsClientSecret() result.Should().Contain("myid"); } + [Fact] + public void RedactSecretsFromPayload_RedactsA365ProxyClientSecret() + { + // The publish request serializes the newly created A365 proxy Entra app secret as + // a365ProxyClientSecret; it must be redacted so verbose request logging never writes the + // live client secret in plaintext. + var payload = """{"a365ProxyClientId":"proxy-id","a365ProxyClientSecret":"proxysecret"}"""; + var result = Agent365ToolingService.RedactSecretsFromPayload(payload); + result.Should().NotContain("proxysecret", because: "the A365 proxy client secret must never be logged in plaintext"); + result.Should().Contain("***REDACTED***"); + result.Should().Contain("proxy-id", because: "the non-secret proxy client id is safe to log and aids diagnostics"); + } + [Fact] public void RedactSecretsFromPayload_PreservesNonSecretFields() {