Skip to content

Commit 7fe23fd

Browse files
Address PR #499 review: gate proxy app, add options, harden cleanup
Publish still forwards the A365 proxy app credentials on every call (the CLI can't classify custom vs first-party before the platform does), but now reconciles after publish: when the response shows no connector was created, the unused proxy app is deleted so no orphaned credential lingers. - Post-publish cleanup of the unused proxy app for first-party/Dataverse servers, gated on the platform returning a connector id / redirect URI. - Redirect-URI warning now fires only when a connector was actually created but no URI came back, not on every first-party publish. - Proxy required-resource-access grant applied only when a connector exists; Public Clients grant unchanged. - New --service-tree-id and --secret-lifetime-months options on publish, threaded to both created Entra apps, mirroring register. - Orphaned proxy app is deleted if Public Clients creation throws after the proxy app was created. - Dry-run output now describes proxy creation, permission/redirect config, and cleanup. - CHANGELOG entry references (#499). Tests: proxy grant on both apps only when a connector exists, unused-proxy deletion, orphan-cleanup-on-throw, option flow-through, and updated publish option/dry-run assertions with documented requirement changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9c97cc0 commit 7fe23fd

6 files changed

Lines changed: 240 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
2323
**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.
2424

2525
### Added
26-
- `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.
26+
- `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. The proxy app is removed again when the server turns out not to need a connector, and `--service-tree-id` / `--secret-lifetime-months` options are honored for it (#499).
2727
- 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).
2828
- 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.
2929
- 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.

‎src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,12 @@ private static Command CreatePublishSubcommand(
386386
description: "Publisher name for the MCP Server. Required for custom (user-created) MCP servers; ignored for 1p Microsoft-owned servers (e.g. msdyn_DataverseMCPServer) which always publish as 'Microsoft'.");
387387
command.AddOption(publisherNameOption);
388388

389+
var serviceTreeIdOption = new Option<string?>("--service-tree-id", description: "ServiceTree ID for Entra app registration (required in Microsoft corporate tenants)");
390+
command.AddOption(serviceTreeIdOption);
391+
392+
var secretLifetimeMonthsOption = new Option<int?>(["--secret-lifetime-months", "-l"], description: "Lifetime in months (1-24) for the generated client secret on the A365 proxy Entra app. Default is 2 years. Set a value smaller than the appManagementPolicies cap in your tenant.");
393+
command.AddOption(secretLifetimeMonthsOption);
394+
389395
var yesOption = new Option<bool>(
390396
["--yes", "-y"],
391397
description: "Skip the interactive 'Proceed with publish? (y/N)' confirmation.");
@@ -406,7 +412,9 @@ private static Command CreatePublishSubcommand(
406412
DisplayName: context.ParseResult.GetValueForOption(displayNameOption),
407413
PublisherName: context.ParseResult.GetValueForOption(publisherNameOption),
408414
Yes: context.ParseResult.GetValueForOption(yesOption),
409-
DryRun: context.ParseResult.GetValueForOption(dryRunOption));
415+
DryRun: context.ParseResult.GetValueForOption(dryRunOption),
416+
ServiceTreeId: context.ParseResult.GetValueForOption(serviceTreeIdOption),
417+
SecretLifetimeMonths: context.ParseResult.GetValueForOption(secretLifetimeMonthsOption));
410418

411419
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService);
412420
var success = await executor.ExecuteAsync(args, context.GetCancellationToken());

‎src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs‎

Lines changed: 104 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ internal record RawPublishArgs(
1919
string? DisplayName,
2020
string? PublisherName,
2121
bool Yes,
22-
bool DryRun);
22+
bool DryRun,
23+
string? ServiceTreeId = null,
24+
int? SecretLifetimeMonths = null);
2325

2426
/// <summary>
2527
/// Orchestrates first-party MCP server publish in one CLI command. The shape mirrors
@@ -68,6 +70,14 @@ private sealed record ResolvedInput
6870
// When true, skip the interactive "Proceed with publish? (y/N)" confirmation. Set via
6971
// --yes / -y. Required for non-interactive contexts (CI scripts, automation).
7072
public required bool Yes { get; init; }
73+
74+
// ServiceTree ID stamped onto the Entra apps created here. Required in Microsoft corporate
75+
// tenants; null elsewhere. Applied to both the A365 proxy and Public Clients apps.
76+
public string? ServiceTreeId { get; init; }
77+
78+
// Optional client-secret lifetime (months) for the A365 proxy app's secret. Null uses Graph's
79+
// default; a smaller value avoids the appManagementPolicies cap failing publish in strict tenants.
80+
public int? SecretLifetimeMonths { get; init; }
7181
}
7282

7383
internal sealed record EntraAppSet(
@@ -89,7 +99,8 @@ internal async Task<bool> ExecuteAsync(RawPublishArgs args, CancellationToken ct
8999
if (input.DryRun)
90100
{
91101
_logger.LogInformation("[DRY RUN] Would create Entra apps '{PublicClients}' and '{A365Proxy}' in tenant", $"{input.ServerName}-PublicClients", $"{input.ServerName}-A365Proxy");
92-
_logger.LogInformation("[DRY RUN] Would call publish endpoint and back-fill PPMI scope on the created app");
102+
_logger.LogInformation("[DRY RUN] Would call the publish endpoint and forward the A365 proxy app credentials so the platform can create the Power Platform connector for custom (non-Dataverse) servers");
103+
_logger.LogInformation("[DRY RUN] Would back-fill the PPMI scope on the created apps, add the McpServer API permission and connector redirect URI to the A365 proxy app, and delete the proxy app when the publish response shows no connector was created");
93104
return true;
94105
}
95106

@@ -282,6 +293,8 @@ internal async Task<bool> ExecuteAsync(RawPublishArgs args, CancellationToken ct
282293
PublisherName = string.IsNullOrWhiteSpace(publisherName) ? null : publisherName,
283294
Yes = args.Yes,
284295
DryRun = args.DryRun,
296+
ServiceTreeId = string.IsNullOrWhiteSpace(args.ServiceTreeId) ? null : args.ServiceTreeId,
297+
SecretLifetimeMonths = args.SecretLifetimeMonths,
285298
};
286299
}
287300
catch (ArgumentException ex)
@@ -304,6 +317,10 @@ private void DisplayPublishSummary(ResolvedInput input)
304317
DevelopMcpCommand.WriteLabel(" Alias: "); Console.WriteLine(input.Alias);
305318
DevelopMcpCommand.WriteLabel(" Display Name: "); Console.WriteLine(input.DisplayName);
306319
DevelopMcpCommand.WriteLabel(" Publisher: "); Console.WriteLine(input.PublisherName ?? "(none — platform will reject if this is a custom server)");
320+
if (input.SecretLifetimeMonths is { } lifetime)
321+
{
322+
DevelopMcpCommand.WriteLabel(" Secret Lifetime: "); Console.WriteLine($"{lifetime} month(s)");
323+
}
307324
Console.WriteLine();
308325
}
309326

@@ -328,20 +345,36 @@ private void DisplayPublishSummary(ResolvedInput input)
328345
// platform creates the Power Platform connector only when its credentials are supplied.
329346
var a365ProxyApp = await provisioner.CreateProxyAppAsync(
330347
input.ServerName, tenantId, suffix: "A365Proxy", roleDisplay: "A365 Proxy",
331-
serviceTreeId: null, ct: ct);
348+
serviceTreeId: input.ServiceTreeId, lifetimeMonths: input.SecretLifetimeMonths, ct: ct);
332349
if (a365ProxyApp is null) return null;
333350

334-
var publicClients = await provisioner.CreatePublicClientsAppAsync(
335-
input.ServerName, tenantId, serviceTreeId: null, warnings, ct);
336-
337-
return new EntraAppSet(
338-
PublicClientsClientId: publicClients.ClientId,
339-
PublicClientsObjectId: publicClients.ObjectId,
340-
PublicClientsAppName: publicClients.AppName,
341-
A365AppClientId: a365ProxyApp.ClientId,
342-
A365AppSecret: a365ProxyApp.Secret,
343-
A365AppObjectId: a365ProxyApp.ObjectId,
344-
A365AppName: a365ProxyApp.AppName);
351+
// If Public Clients creation throws after the proxy app exists, the proxy app (with its
352+
// secret) is orphaned — RollbackEntraAppsAsync only runs once we have a full EntraAppSet and
353+
// the platform call fails. Clean it up here so a Graph error / throttling / cancellation
354+
// doesn't leak a credential. Use CancellationToken.None for the compensating delete so a
355+
// caller Ctrl+C still removes the orphan.
356+
try
357+
{
358+
var publicClients = await provisioner.CreatePublicClientsAppAsync(
359+
input.ServerName, tenantId, serviceTreeId: input.ServiceTreeId, warnings, ct);
360+
361+
return new EntraAppSet(
362+
PublicClientsClientId: publicClients.ClientId,
363+
PublicClientsObjectId: publicClients.ObjectId,
364+
PublicClientsAppName: publicClients.AppName,
365+
A365AppClientId: a365ProxyApp.ClientId,
366+
A365AppSecret: a365ProxyApp.Secret,
367+
A365AppObjectId: a365ProxyApp.ObjectId,
368+
A365AppName: a365ProxyApp.AppName);
369+
}
370+
catch (Exception ex)
371+
{
372+
_logger.LogError("Failed to create the Public Clients Entra app after the A365 proxy app was created; deleting the orphaned proxy app '{A365Proxy}'.", a365ProxyApp.AppName);
373+
_logger.LogDebug("Exception details: {Exception}", ex.ToString());
374+
await TryDeleteEntraAppAsync(tenantId, a365ProxyApp.ObjectId, a365ProxyApp.ClientId, a365ProxyApp.AppName, CancellationToken.None);
375+
if (ex is OperationCanceledException && ct.IsCancellationRequested) throw;
376+
return null;
377+
}
345378
}
346379

347380
// Best-effort compensating delete for the Entra apps created in CreateEntraAppsAsync, run when
@@ -358,40 +391,41 @@ internal async Task RollbackEntraAppsAsync(EntraAppSet apps, string tenantId, Ca
358391

359392
_logger.LogInformation("Rolling back Entra app registrations created for failed publish...");
360393

361-
if (!string.IsNullOrWhiteSpace(apps.PublicClientsObjectId))
362-
{
363-
await DeleteOneAsync(apps.PublicClientsObjectId, apps.PublicClientsClientId, apps.PublicClientsAppName, ct);
364-
}
394+
await TryDeleteEntraAppAsync(tenantId, apps.PublicClientsObjectId, apps.PublicClientsClientId, apps.PublicClientsAppName, ct);
395+
await TryDeleteEntraAppAsync(tenantId, apps.A365AppObjectId, apps.A365AppClientId, apps.A365AppName, ct);
396+
}
365397

366-
if (!string.IsNullOrWhiteSpace(apps.A365AppObjectId))
398+
// Best-effort compensating delete for a single Entra app. No-op when the object id is unknown or
399+
// Graph is unavailable. Failures are logged with both clientId and objectId so the user can clean
400+
// up manually; the delete never throws.
401+
private async Task TryDeleteEntraAppAsync(string tenantId, string? objectId, string? clientId, string appName, CancellationToken ct)
402+
{
403+
if (_graphApiService is null || string.IsNullOrWhiteSpace(objectId))
367404
{
368-
await DeleteOneAsync(apps.A365AppObjectId, apps.A365AppClientId, apps.A365AppName, ct);
405+
return;
369406
}
370407

371-
async Task DeleteOneAsync(string objectId, string? clientId, string appName, CancellationToken cancellationToken)
408+
try
372409
{
373-
try
410+
var deleted = await _graphApiService.DeleteEntraAppAsync(tenantId, objectId, ct);
411+
if (deleted)
374412
{
375-
var deleted = await _graphApiService!.DeleteEntraAppAsync(tenantId, objectId, cancellationToken);
376-
if (deleted)
377-
{
378-
_logger.LogInformation("Rolled back Entra app '{AppName}' (objectId {ObjectId})", appName, objectId);
379-
}
380-
else
381-
{
382-
_logger.LogError(
383-
"Failed to roll back Entra app '{AppName}' (clientId {ClientId}, objectId {ObjectId}). Delete it manually in the Azure portal.",
384-
appName, clientId ?? "<unknown>", objectId);
385-
}
413+
_logger.LogInformation("Rolled back Entra app '{AppName}' (objectId {ObjectId})", appName, objectId);
386414
}
387-
catch (Exception ex)
415+
else
388416
{
389417
_logger.LogError(
390-
ex,
391-
"Exception rolling back Entra app '{AppName}' (clientId {ClientId}, objectId {ObjectId}). Delete it manually in the Azure portal.",
418+
"Failed to roll back Entra app '{AppName}' (clientId {ClientId}, objectId {ObjectId}). Delete it manually in the Azure portal.",
392419
appName, clientId ?? "<unknown>", objectId);
393420
}
394421
}
422+
catch (Exception ex)
423+
{
424+
_logger.LogError(
425+
ex,
426+
"Exception rolling back Entra app '{AppName}' (clientId {ClientId}, objectId {ObjectId}). Delete it manually in the Azure portal.",
427+
appName, clientId ?? "<unknown>", objectId);
428+
}
395429
}
396430

397431
private async Task ConfigureEntraAppsAsync(
@@ -405,6 +439,19 @@ private async Task ConfigureEntraAppsAsync(
405439
var tasks = new List<Task>();
406440
var concurrentWarnings = new System.Collections.Concurrent.ConcurrentBag<string>();
407441

442+
// The proxy app + secret are forwarded on every publish because the CLI can't classify the
443+
// server (custom vs 1p/Dataverse) before the platform does. Only custom servers actually get
444+
// a Power Platform connector; the platform signals that by returning a connector id and/or a
445+
// redirect URI. When neither is present the proxy app is unused, so delete it here rather than
446+
// leave an unused credential in the tenant (the Public Clients app and its PPMI grant remain).
447+
var connectorCreated = !string.IsNullOrWhiteSpace(response.A365ProxyConnectorId)
448+
|| !string.IsNullOrWhiteSpace(response.A365ProxyRedirectUri);
449+
if (!connectorCreated)
450+
{
451+
_logger.LogInformation("Publish returned no A365 proxy connector for '{ServerName}' (first-party / Dataverse server); removing the unused A365 proxy app '{A365Proxy}'.", input.ServerName, apps.A365AppName);
452+
await TryDeleteEntraAppAsync(tenantId, apps.A365AppObjectId, apps.A365AppClientId, apps.A365AppName, ct);
453+
}
454+
408455
// Grant required-resource-access on the just-created Public Clients Entra app.
409456
// The platform resolves the right resource per server type (Custom: managedidentityid; app-based
410457
// / Dataverse MCP: 1p mappings; fallback: platform's own app id) and returns both the resource
@@ -442,9 +489,13 @@ private async Task ConfigureEntraAppsAsync(
442489
{
443490
// The platform wires the A365 proxy connector with the proxy app as its OAuth client and
444491
// McpServerAppId as the resource, so the proxy app must hold this required-resource-access
445-
// grant or Entra rejects the token request (AADSTS650057). Grant it on both the proxy app
446-
// and the Public Clients app, mirroring register.
447-
tasks.Add(AddRequiredResourceAccessAsync(tenantId, apps.A365AppObjectId, apps.A365AppName, resourceAppId!, resourceScopeId.Value, concurrentWarnings, ct));
492+
// grant or Entra rejects the token request (AADSTS650057). Grant it on the proxy app only
493+
// when a connector was created (otherwise the proxy app was just deleted above); always
494+
// grant it on the Public Clients app, mirroring register.
495+
if (connectorCreated)
496+
{
497+
tasks.Add(AddRequiredResourceAccessAsync(tenantId, apps.A365AppObjectId, apps.A365AppName, resourceAppId!, resourceScopeId.Value, concurrentWarnings, ct));
498+
}
448499

449500
if (apps.PublicClientsObjectId != null)
450501
{
@@ -460,16 +511,21 @@ private async Task ConfigureEntraAppsAsync(
460511

461512
// Custom (non-Dataverse) servers get a Power Platform connector whose redirect URI the
462513
// platform returns here. Write it onto the A365 proxy app so the connector's OAuth flow works.
463-
var a365RedirectUri = response.A365ProxyRedirectUri;
464-
if (!string.IsNullOrWhiteSpace(a365RedirectUri))
465-
{
466-
tasks.Add(UpdateA365RedirectUrisAsync(tenantId, apps, a365RedirectUri, concurrentWarnings, ct));
467-
}
468-
else
514+
// Only relevant when a connector was created; first-party servers get no connector (and the
515+
// proxy app was already removed), so no redirect URI is expected and none is warned about.
516+
if (connectorCreated)
469517
{
470-
var msg = "A365 Proxy redirect URI was not returned by publish. Redirect URI configuration skipped.";
471-
_logger.LogWarning(msg);
472-
concurrentWarnings.Add(msg);
518+
var a365RedirectUri = response.A365ProxyRedirectUri;
519+
if (!string.IsNullOrWhiteSpace(a365RedirectUri))
520+
{
521+
tasks.Add(UpdateA365RedirectUrisAsync(tenantId, apps, a365RedirectUri, concurrentWarnings, ct));
522+
}
523+
else
524+
{
525+
var msg = "A365 Proxy connector was created but publish returned no redirect URI. Redirect URI configuration skipped.";
526+
_logger.LogWarning(msg);
527+
concurrentWarnings.Add(msg);
528+
}
473529
}
474530

475531
await Task.WhenAll(tasks);

‎src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ public void PublishSubcommand_HasCorrectOptionsWithAliases()
124124
var options = subcommand.Options.ToList();
125125

126126
// Verify all expected options exist. Tenant ID is auto-detected from the current az login
127-
// session, so publish does not expose --tenant-id; ServiceTree tagging is not required for
128-
// publish since it targets Dataverse environments rather than Microsoft corp tenants.
127+
// session, so publish does not expose --tenant-id. Publish now registers the A365 proxy and
128+
// Public Clients Entra apps in the operator's own tenant (via az login) — which may be a
129+
// ServiceTree-enrolled Microsoft corp tenant — so it exposes --service-tree-id and
130+
// --secret-lifetime-months, mirroring register.
129131
var optionNames = options.Select(o => o.Name).ToList();
130132
optionNames.Should().Contain("environment-id");
131133
optionNames.Should().Contain("server-name");
@@ -135,10 +137,16 @@ public void PublishSubcommand_HasCorrectOptionsWithAliases()
135137
"tenant-id",
136138
because: "tenant id is auto-detected from the current 'az login' session; exposing " +
137139
"--tenant-id would imply per-publish tenant targeting that the executor does not support.");
138-
optionNames.Should().NotContain(
140+
optionNames.Should().Contain(
139141
"service-tree-id",
140-
because: "publish targets a customer's Dataverse env, not a Microsoft corp tenant — " +
141-
"the ServiceTree tagging that --service-tree-id provides is not applicable here.");
142+
because: "publish creates Entra app registrations in the operator's own tenant, which may " +
143+
"be ServiceTree-enrolled; those registrations are rejected without a " +
144+
"serviceManagementReference, so --service-tree-id must be available (reviewer request on #499, same as #496).");
145+
optionNames.Should().Contain(
146+
"secret-lifetime-months",
147+
because: "the A365 proxy app's client secret must fit under the tenant's appManagementPolicies " +
148+
"lifetime cap or publish fails in strict tenants; --secret-lifetime-months lets the " +
149+
"operator set a compliant lifetime, mirroring register.");
142150
optionNames.Should().Contain("dry-run");
143151

144152
// Verify critical aliases for Azure CLI compliance
@@ -153,6 +161,11 @@ public void PublishSubcommand_HasCorrectOptionsWithAliases()
153161

154162
var displayNameOption = options.FirstOrDefault(o => o.Name == "display-name");
155163
displayNameOption!.Aliases.Should().Contain("-d");
164+
165+
var secretLifetimeOption = options.FirstOrDefault(o => o.Name == "secret-lifetime-months");
166+
secretLifetimeOption!.Aliases.Should().Contain(
167+
"-l",
168+
because: "register exposes --secret-lifetime-months as -l; publish must use the same alias for consistency.");
156169
}
157170

158171
[Fact]

0 commit comments

Comments
 (0)