Skip to content

Commit a7b7de5

Browse files
Making changes to the publish CLI to for MOS Publish, Custom connector creation and App registrations (#400)
* Making changes to the publish CLI to for MOS Publish, Custom connector creation and App registrations * Switch to v2 * Updating app id changes * Addressing PR Comments * Commenting out A365 proxy app * Adding publisher parameter * Addressing comments * Addrssing copilot comments * Reinstate secret lifetime support * Addressing additional copilot comments * Removing a365 prxy code and other comments * Copilot automated comments addressed
1 parent cc17187 commit a7b7de5

13 files changed

Lines changed: 1420 additions & 312 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
5050
- `setup requirements` now detects and auto-repairs the **`wids` optional claim** on the CLI client app's access tokens. Without this claim, the CLI cannot read the signed-in user's directory roles from the token and silently skips the tenant-wide consent step on the agent blueprint — the user-visible symptom is a blueprint with `inheritablePermissions.kind=allAllowed` but no permissions actually granted on the blueprint service principal. When the running user has admin authority over the app registration, the CLI patches `optionalClaims.accessToken` to include `wids` and clears the local MSAL token cache so the next acquisition carries the new claim. The well-known CLI app is also now created with `wids` already configured. Non-admin users get explicit Azure portal click-path remediation.
5151
- `setup requirements` now auto-provisions the `Application.Read.All` consented permission when it is declared on the CLI client app but missing from the tenant's OAuth2 consent grant. Required by the wids check (which queries `/v1.0/applications`) and the existing redirect-URI / public-client validators.
5252
- `--secret-lifetime-months <N>` option (and matching `secretLifetimeMonths` field in the `--input-file` JSON) on `develop-mcp register-external-mcp-server` — controls the lifetime of the client secrets created on the A365Proxy and RemoteProxy Entra apps. Valid range `1-24`; omit to use the Graph default (~2 years). Calendar-aware (uses `DateTimeOffset.AddMonths`, so Jan 31 + 1 month → Feb 28/29). Added so tenants with an `appManagementPolicies` cap on client-secret lifetime — previously a hard failure inside `CreateEntraAppsAsync` with a generic "Failed to create secret" message — can fit registration inside their tenant's policy. When Graph rejects the requested (or default) lifetime with a tenant-policy error, the CLI now emits an actionable error naming the flag and the attempted value (e.g. `Tenant Entra ID policy rejected the requested 12-month lifetime ... Pass --secret-lifetime-months N with a smaller value (e.g. --secret-lifetime-months 3) that fits inside your tenant's appManagementPolicies cap.`) instead of the previous generic failure.
53+
- `--publisher-name` / `-p` option on `develop-mcp publish` — sets the publisher name written into the published MCP server's package metadata. Required for custom (user-created) MCP servers; ignored for 1p Microsoft-owned servers (e.g. `msdyn_DataverseMCPServer`), which always publish as "Microsoft". Prompted interactively when omitted.
54+
- `--yes` / `-y` option on `develop-mcp publish` — skips the interactive "Proceed with publish? (y/N)" confirmation.
5355

5456
### Fixed
5557
- `setup all` now exits silently on Ctrl+C instead of printing `ERROR: Setup failed: A task was canceled.` followed by a misleading partial summary.
@@ -116,6 +118,9 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
116118
- **`a365 config display` removed** — use `a365 query-entra blueprint-scopes` to inspect live blueprint permissions and consent state.
117119
- **`a365 config permissions` removed** — replace with `a365 setup permissions custom --resource-app-id <guid> --scopes <scopes>`.
118120
- **`--config`/`-c` option removed from all commands** — config file is now always resolved from the current directory (`a365.config.json`). Scripts passing `--config <path>` will receive a parse error; change directory before running the CLI instead.
121+
- **`--tenant-id` / `-t` removed from `a365 develop-mcp register-external-mcp-server`** — the tenant is now auto-detected from the current `az login` session. Scripts passing `-t <id>` / `--tenant-id <id>` will receive a System.CommandLine parse error; run `az login --tenant <id>` (or `az account set --subscription <id>`) to target a specific tenant instead.
122+
- **`a365 develop-mcp publish` now creates a `<server-name>-PublicClients` Entra app registration in your tenant** — the publish orchestration runs CLI-side, so after each publish you will see a new app registration named `<server-name>-PublicClients` in your tenant's Entra ID. These are created by the CLI; clean them up with the same name if you unpublish.
123+
- **`a365 develop-mcp publish` now requires the `Application.ReadWrite.All` Microsoft Graph permission** — needed to create the Entra app registration above. Running publish with only read-only Graph permissions will fail. Grant `Application.ReadWrite.All` to the account (or app) running the CLI before publishing.
119124
- **`--agent-instance-only` renamed to `--agent-registration-only`** on `a365 setup all` — update any scripts using the old flag name.
120125
- **`setup permissions custom --resource-app-id --scopes` applies permissions directly to Entra ID** — unlike the former `a365 config permissions` which only wrote to `a365.config.json`, this inline mode immediately mutates the live blueprint in Entra and cannot be undone by editing a config file.
121126
- `a365 setup` now writes the `Agent365Observability` placeholder section (`AgentId`, `AgentBlueprintId`, `TenantId`, `AgentName`, `AgentDescription`) and `EnableAgent365Exporter: false` to `appsettings.json` (.NET) and `ENABLE_A365_OBSERVABILITY_EXPORTER=false` to `.env` (Python/Node.js), so observability configuration is pre-populated for all three platforms after running setup

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

Lines changed: 38 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public static Command CreateCommand(
3535
// Add subcommands
3636
developMcpCommand.AddCommand(CreateListEnvironmentsSubcommand(logger, toolingService));
3737
developMcpCommand.AddCommand(CreateListServersSubcommand(logger, toolingService));
38-
developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService));
38+
developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService, graphApiService));
3939
developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService));
4040
developMcpCommand.AddCommand(CreateApproveSubcommand(logger, toolingService));
4141
developMcpCommand.AddCommand(CreateBlockSubcommand(logger, toolingService));
@@ -279,180 +279,68 @@ private static Command CreateListServersSubcommand(
279279
/// Creates the publish subcommand
280280
/// </summary>
281281
private static Command CreatePublishSubcommand(
282-
ILogger logger,
283-
IAgent365ToolingService toolingService)
282+
ILogger logger,
283+
IAgent365ToolingService toolingService,
284+
GraphApiService? graphApiService)
284285
{
285-
var command = new Command("publish", "Publish an MCP server to a Dataverse environment");
286+
var command = new Command("publish", "Publish an MCP server to a Dataverse environment.");
286287

287288
var envIdOption = new Option<string?>(
288289
["--environment-id", "-e"],
289-
description: "Dataverse environment ID"
290-
);
290+
description: "Dataverse environment ID");
291291
envIdOption.IsRequired = false; // Allow null so we can prompt
292292
command.AddOption(envIdOption);
293293

294294
var serverNameOption = new Option<string?>(
295295
["--server-name", "-s"],
296-
description: "MCP server name to publish"
297-
);
298-
serverNameOption.IsRequired = false; // Allow null so we can prompt
296+
description: "MCP server name to publish");
297+
serverNameOption.IsRequired = false;
299298
command.AddOption(serverNameOption);
300299

301300
var aliasOption = new Option<string?>(
302301
["--alias", "-a"],
303-
description: "Alias for the MCP server"
304-
);
302+
description: "Alias for the MCP server");
305303
command.AddOption(aliasOption);
306304

307305
var displayNameOption = new Option<string?>(
308306
["--display-name", "-d"],
309-
description: "Display name for the MCP server"
310-
);
307+
description: "Display name for the MCP server (max 30 chars)");
311308
command.AddOption(displayNameOption);
312309

313-
var dryRunOption = new Option<bool>(
314-
name: "--dry-run",
315-
description: "Show what would be done without executing"
316-
);
317-
command.AddOption(dryRunOption);
318-
319-
var verboseOption = new Option<bool>(
320-
["--verbose", "-v"],
321-
description: "Enable verbose logging"
322-
);
323-
command.AddOption(verboseOption);
324-
325-
command.SetHandler(async (envId, serverName, alias, displayName, dryRun, verbose) =>
326-
{
327-
_ = verbose;
328-
try
329-
{
330-
// Validate and prompt for missing required arguments with security checks
331-
if (string.IsNullOrWhiteSpace(envId))
332-
{
333-
envId = InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID");
334-
if (string.IsNullOrWhiteSpace(envId))
335-
{
336-
logger.LogError("Environment ID is required");
337-
return;
338-
}
339-
}
340-
else
341-
{
342-
// Validate provided environment ID
343-
envId = InputValidator.ValidateInput(envId, "Environment ID");
344-
if (envId == null)
345-
{
346-
logger.LogError("Invalid environment ID format");
347-
return;
348-
}
349-
}
350-
351-
if (string.IsNullOrWhiteSpace(serverName))
352-
{
353-
serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to publish: ", "Server name", 100);
354-
if (string.IsNullOrWhiteSpace(serverName))
355-
{
356-
logger.LogError("Server name is required");
357-
return;
358-
}
359-
}
360-
else
361-
{
362-
// Validate provided server name
363-
serverName = InputValidator.ValidateInput(serverName, "Server name");
364-
if (serverName == null)
365-
{
366-
logger.LogError("Invalid server name format");
367-
return;
368-
}
369-
}
370-
371-
logger.LogInformation("Starting publish operation for server {ServerName} in environment {EnvId}...", serverName, envId);
310+
var publisherNameOption = new Option<string?>(
311+
["--publisher-name", "-p"],
312+
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'.");
313+
command.AddOption(publisherNameOption);
372314

373-
if (dryRun)
374-
{
375-
logger.LogInformation("[DRY RUN] Would read config from a365.config.json");
376-
logger.LogInformation("[DRY RUN] Would publish MCP server {ServerName} to environment {EnvId}", serverName, envId);
377-
logger.LogInformation("[DRY RUN] Alias: {Alias}", alias ?? "[would prompt]");
378-
logger.LogInformation("[DRY RUN] Display Name: {DisplayName}", displayName ?? "[would prompt]");
379-
await Task.CompletedTask;
380-
return;
381-
}
382-
383-
// Validate and prompt for missing optional values with security checks
384-
if (string.IsNullOrWhiteSpace(alias))
385-
{
386-
alias = InputValidator.PromptAndValidateRequiredInput("Enter alias for the MCP server: ", "Alias", 50);
387-
if (string.IsNullOrWhiteSpace(alias))
388-
{
389-
logger.LogError("Alias is required");
390-
return;
391-
}
392-
}
393-
else
394-
{
395-
// Validate provided alias
396-
alias = InputValidator.ValidateInput(alias, "Alias", maxLength: 50);
397-
if (alias == null)
398-
{
399-
logger.LogError("Invalid alias format");
400-
return;
401-
}
402-
}
315+
var yesOption = new Option<bool>(
316+
["--yes", "-y"],
317+
description: "Skip the interactive 'Proceed with publish? (y/N)' confirmation.");
318+
command.AddOption(yesOption);
403319

404-
if (string.IsNullOrWhiteSpace(displayName))
405-
{
406-
displayName = InputValidator.PromptAndValidateRequiredInput("Enter display name for the MCP server: ", "Display name", 100);
407-
if (string.IsNullOrWhiteSpace(displayName))
408-
{
409-
logger.LogError("Display name is required");
410-
return;
411-
}
412-
}
413-
else
414-
{
415-
// Validate provided display name
416-
displayName = InputValidator.ValidateInput(displayName, "Display name", maxLength: 100);
417-
if (displayName == null)
418-
{
419-
logger.LogError("Invalid display name format");
420-
return;
421-
}
422-
}
423-
}
424-
catch (ArgumentException ex)
425-
{
426-
logger.LogError("Input validation failed: {Message}", ex.Message);
427-
return;
428-
}
320+
var dryRunOption = new Option<bool>("--dry-run", "Show what would be done without executing");
321+
command.AddOption(dryRunOption);
429322

430-
// Create request
431-
var request = new PublishMcpServerRequest
432-
{
433-
Alias = alias,
434-
DisplayName = displayName
435-
};
323+
// Verbose is handled globally in Program.cs (sets LogLevel.Debug); declared here so the parser accepts -v.
324+
command.AddOption(new Option<bool>(["--verbose", "-v"], description: "Enable verbose logging"));
436325

437-
// Call service
438-
var response = await toolingService.PublishServerAsync(envId, serverName, request);
326+
command.SetHandler(async (context) =>
327+
{
328+
var args = new RawPublishArgs(
329+
EnvironmentId: context.ParseResult.GetValueForOption(envIdOption),
330+
ServerName: context.ParseResult.GetValueForOption(serverNameOption),
331+
Alias: context.ParseResult.GetValueForOption(aliasOption),
332+
DisplayName: context.ParseResult.GetValueForOption(displayNameOption),
333+
PublisherName: context.ParseResult.GetValueForOption(publisherNameOption),
334+
Yes: context.ParseResult.GetValueForOption(yesOption),
335+
DryRun: context.ParseResult.GetValueForOption(dryRunOption));
439336

440-
if (response == null || !response.IsSuccess)
337+
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService);
338+
var success = await executor.ExecuteAsync(args, context.GetCancellationToken());
339+
if (!success)
441340
{
442-
if (response?.Message != null)
443-
{
444-
logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: {ErrorMessage}", serverName, envId, response.Message);
445-
}
446-
else
447-
{
448-
logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: No response received", serverName, envId);
449-
}
450-
return;
341+
context.ExitCode = 1;
451342
}
452-
453-
logger.LogInformation("Successfully published MCP server {ServerName} to environment {EnvId}", serverName, envId);
454-
455-
}, envIdOption, serverNameOption, aliasOption, displayNameOption, dryRunOption, verboseOption);
343+
});
456344

457345
return command;
458346
}
@@ -844,9 +732,6 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
844732
var remoteScopesOption = new Option<string?>("--remote-scopes", description: "Scopes for the remote MCP server (e.g., 'api://{appId-guid}/{scopeName}' such as 'api://00000000-0000-0000-0000-000000000000/access_as_user')");
845733
command.AddOption(remoteScopesOption);
846734

847-
var tenantIdOption = new Option<string?>(["--tenant-id", "-t"], description: "Entra tenant ID for app registration (defaults to current az login tenant)");
848-
command.AddOption(tenantIdOption);
849-
850735
var serviceTreeIdOption = new Option<string?>("--service-tree-id", description: "ServiceTree ID for Entra app registration (required in Microsoft corporate tenants)");
851736
command.AddOption(serviceTreeIdOption);
852737

@@ -881,7 +766,7 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
881766
ToolsInput: context.ParseResult.GetValueForOption(toolsOption),
882767
InputFile: context.ParseResult.GetValueForOption(inputFileOption),
883768
RemoteScopes: context.ParseResult.GetValueForOption(remoteScopesOption),
884-
TenantId: context.ParseResult.GetValueForOption(tenantIdOption),
769+
TenantId: null,
885770
ServiceTreeId: context.ParseResult.GetValueForOption(serviceTreeIdOption),
886771
SecretLifetimeMonths: context.ParseResult.GetValueForOption(secretLifetimeMonthsOption),
887772
PublisherName: context.ParseResult.GetValueForOption(publisherOption),

0 commit comments

Comments
 (0)