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
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,12 @@ private static Command CreatePublishSubcommand(
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'.");
command.AddOption(publisherNameOption);

var serviceTreeIdOption = new Option<string?>("--service-tree-id", description: "ServiceTree ID for Entra app registration (required in Microsoft corporate tenants)");
command.AddOption(serviceTreeIdOption);

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.");
command.AddOption(secretLifetimeMonthsOption);
Comment thread
deepaligargms marked this conversation as resolved.

var yesOption = new Option<bool>(
["--yes", "-y"],
description: "Skip the interactive 'Proceed with publish? (y/N)' confirmation.");
Expand All @@ -412,7 +418,9 @@ private static Command CreatePublishSubcommand(
DisplayName: context.ParseResult.GetValueForOption(displayNameOption),
PublisherName: context.ParseResult.GetValueForOption(publisherNameOption),
Yes: context.ParseResult.GetValueForOption(yesOption),
DryRun: context.ParseResult.GetValueForOption(dryRunOption));
DryRun: context.ParseResult.GetValueForOption(dryRunOption),
ServiceTreeId: context.ParseResult.GetValueForOption(serviceTreeIdOption),
SecretLifetimeMonths: context.ParseResult.GetValueForOption(secretLifetimeMonthsOption));

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

Large diffs are not rendered by default.

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 @@ -239,7 +239,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 @@ -124,8 +124,10 @@ public void PublishSubcommand_HasCorrectOptionsWithAliases()
var options = subcommand.Options.ToList();

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

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

var displayNameOption = options.FirstOrDefault(o => o.Name == "display-name");
displayNameOption!.Aliases.Should().Contain("-d");

var secretLifetimeOption = options.FirstOrDefault(o => o.Name == "secret-lifetime-months");
secretLifetimeOption!.Aliases.Should().Contain(
"-l",
because: "register exposes --secret-lifetime-months as -l; publish must use the same alias for consistency.");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@ 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>,
/// 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.
/// The dry-run log must (a) name the Public Clients app — derived from <c>ServerName</c>,
/// not <c>Alias</c> — and (b) describe the full post-publish configuration: PPMI-scope back-fill,
/// the A365 proxy app's API permission and redirect URI, and its removal when no connector is
/// created. It must also (c) skip the platform publish call entirely.
/// </summary>
[Fact]
public async Task ExecuteAsync_DryRun_NamesPublicClientsApp_AndBackfillsPpmiScopeOnly()
public async Task ExecuteAsync_DryRun_NamesPublicClientsApp_AndDescribesProxyConfiguration()
{
var logger = Substitute.For<ILogger>();
var toolingService = Substitute.For<IAgent365ToolingService>();
Expand Down Expand Up @@ -58,13 +59,14 @@ public async Task ExecuteAsync_DryRun_NamesPublicClientsApp_AndBackfillsPpmiScop
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());

// The back-fill line now mentions only PPMI scope, not redirect URI.
// The back-fill line now also describes the proxy app's permission, redirect URI, and cleanup.
logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString()!.Contains("back-fill PPMI scope") &&
!o.ToString()!.Contains("redirect URI")),
o.ToString()!.Contains("PPMI scope") &&
o.ToString()!.Contains("A365 proxy app") &&
o.ToString()!.Contains("redirect URI")),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());

Expand Down
Loading
Loading