Skip to content

Commit a0da4ec

Browse files
committed
Addressing additional copilot comments
1 parent 79319cb commit a0da4ec

4 files changed

Lines changed: 173 additions & 16 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,11 @@ private static Command CreatePublishSubcommand(
311311
description: "Publisher name written into the MOS package manifest. Required for custom (user-created) MCP servers; ignored for 1p Microsoft-owned servers (e.g. msdyn_DataverseMCPServer) which always publish as 'Microsoft'.");
312312
command.AddOption(publisherNameOption);
313313

314+
var yesOption = new Option<bool>(
315+
["--yes", "-y"],
316+
description: "Skip the interactive 'Proceed with publish? (y/N)' confirmation. Useful for non-interactive contexts (CI/CD pipelines, scripts). Matches az CLI convention.");
317+
command.AddOption(yesOption);
318+
314319
var dryRunOption = new Option<bool>("--dry-run", "Show what would be done without executing");
315320
command.AddOption(dryRunOption);
316321

@@ -325,6 +330,7 @@ private static Command CreatePublishSubcommand(
325330
Alias: context.ParseResult.GetValueForOption(aliasOption),
326331
DisplayName: context.ParseResult.GetValueForOption(displayNameOption),
327332
PublisherName: context.ParseResult.GetValueForOption(publisherNameOption),
333+
Yes: context.ParseResult.GetValueForOption(yesOption),
328334
DryRun: context.ParseResult.GetValueForOption(dryRunOption));
329335

330336
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService);

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal record RawPublishArgs(
1818
string? Alias,
1919
string? DisplayName,
2020
string? PublisherName,
21+
bool Yes,
2122
bool DryRun);
2223

2324
/// <summary>
@@ -32,6 +33,9 @@ internal record RawPublishArgs(
3233
/// </summary>
3334
internal class PublishCommandExecutor
3435
{
36+
// protected (instead of private) on the seam methods + non-sealed class so tests can stub
37+
// out the parts that hit external systems (Azure CLI for tenant detection). The class is
38+
// still internal — overrides only happen in the test assembly via InternalsVisibleTo.
3539
private readonly ILogger _logger;
3640
private readonly IAgent365ToolingService _toolingService;
3741
private readonly GraphApiService? _graphApiService;
@@ -62,6 +66,10 @@ private sealed record ResolvedInput
6266
// classify ahead of time without knowing the server's mapping, so it leaves the value
6367
// null when unspecified and lets the platform decide.
6468
public string? PublisherName { get; init; }
69+
70+
// When true, skip the interactive "Proceed with publish? (y/N)" confirmation. Set via
71+
// --yes / -y. Required for non-interactive contexts (CI scripts, automation).
72+
public required bool Yes { get; init; }
6573
}
6674

6775
internal sealed record EntraAppSet(
@@ -94,14 +102,21 @@ internal async Task<bool> ExecuteAsync(RawPublishArgs args, CancellationToken ct
94102
return true;
95103
}
96104

97-
Console.Write("Proceed with publish? (y/N): ");
98-
var confirmation = Console.ReadLine()?.Trim().ToLowerInvariant();
99-
if (confirmation != "y" && confirmation != "yes")
105+
if (!input.Yes)
100106
{
101-
Console.WriteLine("Publish cancelled.");
102-
// User cancellation is not a failure — exit 0. Matches the register command's same
103-
// prompt-cancel path.
104-
return true;
107+
Console.Write("Proceed with publish? (y/N): ");
108+
var confirmation = Console.ReadLine()?.Trim().ToLowerInvariant();
109+
if (confirmation != "y" && confirmation != "yes")
110+
{
111+
Console.WriteLine("Publish cancelled.");
112+
// User cancellation is not a failure — exit 0. Matches the register command's same
113+
// prompt-cancel path.
114+
return true;
115+
}
116+
}
117+
else
118+
{
119+
_logger.LogDebug("Skipping interactive confirmation (--yes was supplied).");
105120
}
106121

107122
Console.WriteLine();
@@ -276,6 +291,7 @@ internal async Task<bool> ExecuteAsync(RawPublishArgs args, CancellationToken ct
276291
Alias = alias,
277292
DisplayName = displayName,
278293
PublisherName = string.IsNullOrWhiteSpace(publisherName) ? null : publisherName,
294+
Yes = args.Yes,
279295
DryRun = args.DryRun,
280296
};
281297
}
@@ -302,7 +318,7 @@ private void DisplayPublishSummary(ResolvedInput input)
302318
Console.WriteLine();
303319
}
304320

305-
private async Task<string?> DetectTenantIdAsync()
321+
protected virtual async Task<string?> DetectTenantIdAsync()
306322
{
307323
var tenantId = await TenantDetectionHelper.DetectTenantIdAsync(null, _logger);
308324

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,13 @@ public async Task AzureCliStyleParameters_AreAcceptedCorrectly(string command, p
9595
}
9696

9797
[Fact]
98-
public async Task ServiceIntegration_PublishCommand_AcceptsAllNamedParameters()
98+
public async Task PublishCommand_AcceptsAllNamedParameters_InDryRun()
9999
{
100-
// Verifies the publish CLI parses every documented flag without error. The publish flow now
101-
// orchestrates Entra app creation + redirect-URI back-fill via GraphApiService (mirroring
102-
// register-external-mcp-server), so end-to-end "params flow to PublishServerAsync" can't be
103-
// exercised here without mocking Graph too — that path is covered by the
104-
// <see cref="DryRunMode_NeverCallsActualServices"/> regression test and by manual E2E testing.
100+
// Verifies the publish CLI parses every documented flag without error. Dry-run
101+
// short-circuits before any platform call, so this is a pure CLI parsing test —
102+
// it does NOT verify that the parsed values flow into PublishServerAsync.
103+
// That contract is covered by PublishCommand_ForwardsParsedParametersToToolingService
104+
// (which mocks Graph + tenant detection so the non-dry-run path can be exercised).
105105

106106
// Arrange
107107
var testEnvId = "test-environment-123";
@@ -121,8 +121,140 @@ public async Task ServiceIntegration_PublishCommand_AcceptsAllNamedParameters()
121121
});
122122

123123
// Assert — successful parse + dispatch, no service calls.
124-
result.Should().Be(0);
125-
await _mockToolingService.DidNotReceive().PublishServerAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<PublishMcpServerRequest>());
124+
result.Should().Be(
125+
0,
126+
because: "dry-run should never trigger a non-zero exit code when all flags parse cleanly.");
127+
await _mockToolingService.DidNotReceive().PublishServerAsync(
128+
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<PublishMcpServerRequest>());
129+
}
130+
131+
/// <summary>
132+
/// Strengthened contract test: verifies that every parsed publish flag flows into the actual
133+
/// <see cref="IAgent365ToolingService.PublishServerAsync"/> call. Exercises the non-dry-run
134+
/// path by (a) using <c>--yes</c> to bypass the interactive confirmation prompt, (b) mocking
135+
/// <see cref="GraphApiService"/> so Entra app creation succeeds without a real tenant, and
136+
/// (c) subclassing <see cref="PublishCommandExecutor"/> to stub out tenant auto-detection so
137+
/// the executor doesn't shell out to Azure CLI in CI. Catches breakage of the CLI-to-service
138+
/// contract (alias/display-name/publisher-name mapping, request shaping, and selecting the
139+
/// correct service method) that the dry-run-only test above can't catch.
140+
/// </summary>
141+
[Fact]
142+
public async Task PublishCommand_ForwardsParsedParametersToToolingService()
143+
{
144+
// Arrange
145+
const string TestTenantId = "test-tenant-99999";
146+
const string TestEnvironmentId = "test-env-forward";
147+
const string TestServerName = "msdyn_TestServer";
148+
const string TestAlias = "test-alias-forward";
149+
const string TestDisplayName = "Test Display Forward";
150+
const string TestPublisherName = "Contoso Forward";
151+
const string TestPublicClientsObjectId = "public-clients-object-id";
152+
const string TestPublicClientsClientId = "public-clients-client-id";
153+
154+
var logger = Substitute.For<ILogger>();
155+
var toolingService = Substitute.For<IAgent365ToolingService>();
156+
var graphApiService = Substitute.For<GraphApiService>();
157+
158+
// Mock Graph so CreateEntraAppsAsync → factory.CreatePublicClientsAppAsync succeeds.
159+
graphApiService.CreateEntraAppAsync(
160+
TestTenantId, Arg.Any<string>(), serviceTreeId: Arg.Any<string?>(), Arg.Any<CancellationToken>())
161+
.Returns(Task.FromResult<(string ObjectId, string ClientId)?>(
162+
(TestPublicClientsObjectId, TestPublicClientsClientId)));
163+
graphApiService.UpdateAppPublicClientRedirectUrisAsync(
164+
TestTenantId, TestPublicClientsObjectId, Arg.Any<string[]>(), Arg.Any<CancellationToken>())
165+
.Returns(Task.FromResult(true));
166+
167+
// Mock Graph for ConfigureEntraAppsAsync → required-resource-access grant on Public Clients.
168+
graphApiService.GetOAuth2PermissionScopeIdAsync(
169+
TestTenantId, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
170+
.Returns(Task.FromResult<Guid?>(Guid.NewGuid()));
171+
graphApiService.AddRequiredResourceAccessAsync(
172+
TestTenantId, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
173+
.Returns(Task.FromResult(true));
174+
175+
// Capture what gets forwarded to PublishServerAsync.
176+
string? capturedEnvId = null;
177+
string? capturedServerName = null;
178+
PublishMcpServerRequest? capturedRequest = null;
179+
toolingService.PublishServerAsync(
180+
Arg.Do<string>(e => capturedEnvId = e),
181+
Arg.Do<string>(s => capturedServerName = s),
182+
Arg.Do<PublishMcpServerRequest>(r => capturedRequest = r),
183+
Arg.Any<CancellationToken>())
184+
.Returns(new PublishMcpServerResponse
185+
{
186+
Status = "Success",
187+
McpServerAppId = Guid.NewGuid().ToString(),
188+
McpServerScope = "Tools.ListInvoke.All",
189+
});
190+
191+
var executor = new TestablePublishCommandExecutor(
192+
logger, toolingService, graphApiService, TestTenantId);
193+
194+
var args = new RawPublishArgs(
195+
EnvironmentId: TestEnvironmentId,
196+
ServerName: TestServerName,
197+
Alias: TestAlias,
198+
DisplayName: TestDisplayName,
199+
PublisherName: TestPublisherName,
200+
Yes: true,
201+
DryRun: false);
202+
203+
// Act
204+
var result = await executor.ExecuteAsync(args);
205+
206+
// Assert
207+
result.Should().BeTrue(
208+
because: "the happy path with all dependencies mocked must return true so the publish " +
209+
"handler exits 0.");
210+
capturedRequest.Should().NotBeNull(
211+
because: "PublishServerAsync must be invoked once the prompt is skipped, the tenant " +
212+
"is detected, and the Public Clients app is created.");
213+
capturedEnvId.Should().Be(
214+
TestEnvironmentId,
215+
because: "--environment-id must flow unchanged into PublishServerAsync's first positional arg.");
216+
capturedServerName.Should().Be(
217+
TestServerName,
218+
because: "--server-name must flow unchanged into PublishServerAsync's second positional arg.");
219+
capturedRequest!.Alias.Should().Be(
220+
TestAlias,
221+
because: "--alias must populate request.Alias; this is the platform's `name` for the published row.");
222+
capturedRequest.DisplayName.Should().Be(
223+
TestDisplayName,
224+
because: "--display-name must populate request.DisplayName; the platform's v2 validator " +
225+
"requires it and surfaces it in MOS.");
226+
capturedRequest.PublisherName.Should().Be(
227+
TestPublisherName,
228+
because: "--publisher-name must populate request.PublisherName so the MOS manifest's " +
229+
"developer field is set for custom servers (the platform rejects empty values " +
230+
"for non-1p servers).");
231+
capturedRequest.PublicClientsAppId.Should().Be(
232+
TestPublicClientsClientId,
233+
because: "the just-created Public Clients Entra app's clientId must be carried to the " +
234+
"platform so it can be echoed back and the CLI can grant the PPMI scope on it " +
235+
"post-publish.");
236+
}
237+
238+
/// <summary>
239+
/// Test-only subclass of <see cref="PublishCommandExecutor"/> that stubs out
240+
/// <see cref="PublishCommandExecutor.DetectTenantIdAsync"/> with a known value, so the
241+
/// strengthened contract test doesn't need to shell out to <c>az account show</c> in CI.
242+
/// </summary>
243+
private sealed class TestablePublishCommandExecutor : PublishCommandExecutor
244+
{
245+
private readonly string? _tenantId;
246+
247+
public TestablePublishCommandExecutor(
248+
ILogger logger,
249+
IAgent365ToolingService toolingService,
250+
GraphApiService? graphApiService,
251+
string? tenantId)
252+
: base(logger, toolingService, graphApiService)
253+
{
254+
_tenantId = tenantId;
255+
}
256+
257+
protected override Task<string?> DetectTenantIdAsync() => Task.FromResult(_tenantId);
126258
}
127259

128260
[Fact]

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public async Task ExecuteAsync_DryRun_LogsEntraAppNamesDerivedFromServerName()
3737
Alias: alias,
3838
DisplayName: "Test Display",
3939
PublisherName: null,
40+
Yes: false,
4041
DryRun: true);
4142

4243
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService: null);
@@ -89,6 +90,7 @@ public async Task ExecuteAsync_DryRun_OmitsA365ProxyApp_WhileCustomConnectorFlow
8990
Alias: alias,
9091
DisplayName: "Test Display",
9192
PublisherName: null,
93+
Yes: false,
9294
DryRun: true);
9395

9496
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService: null);
@@ -139,6 +141,7 @@ public async Task ExecuteAsync_DryRun_AcceptsPublisherName_WithoutCallingPlatfor
139141
Alias: "myAlias",
140142
DisplayName: "Test Display",
141143
PublisherName: "Contoso",
144+
Yes: false,
142145
DryRun: true);
143146

144147
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService: null);

0 commit comments

Comments
 (0)