Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing the (#499) reference.

- 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> ExecuteAsync(RawPublishArgs args, CancellationToken ct = default)
{
Expand All @@ -84,7 +88,7 @@ internal async Task<bool> 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");
Comment thread
deepaligargms marked this conversation as resolved.
_logger.LogInformation("[DRY RUN] Would call publish endpoint and back-fill PPMI scope on the created app");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dry run still only mentions the PPMI back-fill. It should also list the proxy app creation and the permission / redirect URI configuration.

return true;
}
Expand Down Expand Up @@ -131,6 +135,8 @@ internal async Task<bool> ExecuteAsync(RawPublishArgs args, CancellationToken ct
DisplayName = input.DisplayName,
PublicClientsAppId = apps.PublicClientsClientId,
PublisherName = input.PublisherName,
A365ProxyClientId = apps.A365AppClientId,
A365ProxyClientSecret = apps.A365AppSecret,
Comment thread
deepaligargms marked this conversation as resolved.
};

PublishMcpServerResponse? publishResponse;
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy app and secret are created unconditionally, but the comment says they're only required for custom (non-Dataverse) servers. For Dataverse / first-party servers this mints a credential nobody uses. Please gate it on server type.

Also, the secret gets Graph's default lifetime and publish has no --secret-lifetime-months like register does, so tenants with stricter app management policies can now fail publish where it used to succeed. Please add/pass that option.

input.ServerName, tenantId, suffix: "A365Proxy", roleDisplay: "A365 Proxy",
serviceTreeId: null, ct: ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serviceTreeId: null is hard-coded, so this will fail with ServiceTreeValueMissing in tenants that enforce ServiceTree (same issue as #496). Please expose --service-tree-id on publish and pass it through here and to the public clients app.

if (a365ProxyApp is null) return null;

var publicClients = await provisioner.CreatePublicClientsAppAsync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this throws (Graph error, throttling, cancellation), the proxy app and its secret created above are left in the tenant; RollbackEntraAppsAsync only runs when the platform call fails. Please catch here, delete the proxy app, return failure, and add a test for this path.

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
Expand All @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -418,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));
Expand All @@ -430,12 +458,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.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warning fires on every non-custom publish, since only custom servers get a redirect URI back, so valid Dataverse / first-party publishes will look degraded. Please only warn when a redirect URI was expected.

_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<string> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,20 @@ public class PublishMcpServerRequest
/// </summary>
[JsonPropertyName("publisherName")]
public string? PublisherName { get; set; }

/// <summary>
/// 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 <see cref="A365ProxyClientSecret"/> are supplied; otherwise connector creation is
/// skipped (<c>A365ProxyConnectorCreation=SkippedNoCredentials</c>).
/// </summary>
[JsonPropertyName("a365ProxyClientId")]
public string? A365ProxyClientId { get; set; }

/// <summary>
/// Client secret for the A365 proxy Entra app. Paired with <see cref="A365ProxyClientId"/> so the
/// platform can create the Power Platform connector for custom servers.
/// </summary>
[JsonPropertyName("a365ProxyClientSecret")]
public string? A365ProxyClientSecret { get; set; }
Comment thread
deepaligargms marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ public class PublishMcpServerResponse
[JsonPropertyName("PublicClientsAppId")]
public string? PublicClientsAppId { get; set; }

/// <summary>
/// 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 <see cref="McpServerAppId"/>.
/// </summary>
[JsonPropertyName("A365ProxyRedirectUri")]
public string? A365ProxyRedirectUri { get; set; }

/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("A365ProxyConnectorId")]
public string? A365ProxyConnectorId { get; set; }

/// <summary>
/// Whether the operation was successful.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ private static void RedactSecretFields(System.Text.Json.Nodes.JsonObject obj)
{
var secretKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"clientApp1Secret", "clientApp2Secret", "clientSecret"
"clientApp1Secret", "clientApp2Secret", "clientSecret", "a365ProxyClientSecret"
};

foreach (var key in obj.Select(p => p.Key).ToList())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ public async Task PublishCommand_ForwardsParsedParametersToToolingService()
TestTenantId, TestPublicClientsObjectId, Arg.Any<string[]>(), Arg.Any<CancellationToken>())
.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<string>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<string?>("a365-proxy-secret"));

// Mock Graph for ConfigureEntraAppsAsync → required-resource-access grant on Public Clients.
graphApiService.GetOAuth2PermissionScopeIdAsync(
TestTenantId, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
Expand Down Expand Up @@ -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.");
}

/// <summary>
Expand Down Expand Up @@ -256,6 +270,9 @@ public async Task PublishCommand_ExplicitEmptyPublisherName_SkipsPromptAndForwar
graphApiService.UpdateAppPublicClientRedirectUrisAsync(
TestTenantId, TestPublicClientsObjectId, Arg.Any<string[]>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(true));
graphApiService.AddAppPasswordAsync(
TestTenantId, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<string?>("a365-proxy-secret"));
graphApiService.GetOAuth2PermissionScopeIdAsync(
TestTenantId, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Guid?>(Guid.NewGuid()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands;
/// <summary>
/// Tests for <see cref="PublishCommandExecutor"/> dry-run output. The dry-run log must mirror the
/// real Entra app naming scheme (derived from <c>ServerName</c>) so users can predict what will be
/// created — the <c>{ServerName}-PublicClients</c> app.
/// created — the <c>{ServerName}-PublicClients</c> and <c>{ServerName}-A365Proxy</c> apps.
/// </summary>
public class PublishCommandExecutorDryRunTests
{
/// <summary>
/// The dry-run log must (a) name only the Public Clients app — derived from <c>ServerName</c>,
/// The dry-run log must (a) name the Public Clients app — derived from <c>ServerName</c>,
/// not <c>Alias</c> — (b) describe a PPMI-scope-only back-fill (no redirect-URI back-fill), and
/// (c) skip the platform publish call entirely.
/// </summary>
Expand Down
Loading
Loading