Skip to content
Merged
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
- `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.
- `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.
- `--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.
- `--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.
- `--yes` / `-y` option on `develop-mcp publish` — skips the interactive "Proceed with publish? (y/N)" confirmation.

### Fixed
- `setup all` now exits silently on Ctrl+C instead of printing `ERROR: Setup failed: A task was canceled.` followed by a misleading partial summary.
Expand Down Expand Up @@ -112,6 +114,9 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
- **`a365 config display` removed** — use `a365 query-entra blueprint-scopes` to inspect live blueprint permissions and consent state.
- **`a365 config permissions` removed** — replace with `a365 setup permissions custom --resource-app-id <guid> --scopes <scopes>`.
- **`--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.
- **`--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.
- **`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.
- **`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.
- **`--agent-instance-only` renamed to `--agent-registration-only`** on `a365 setup all` — update any scripts using the old flag name.
- **`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.
- `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
Expand Down
191 changes: 38 additions & 153 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static Command CreateCommand(
// Add subcommands
developMcpCommand.AddCommand(CreateListEnvironmentsSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreateListServersSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService, graphApiService));
developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreateApproveSubcommand(logger, toolingService));
developMcpCommand.AddCommand(CreateBlockSubcommand(logger, toolingService));
Expand Down Expand Up @@ -279,180 +279,68 @@ private static Command CreateListServersSubcommand(
/// Creates the publish subcommand
/// </summary>
private static Command CreatePublishSubcommand(
ILogger logger,
IAgent365ToolingService toolingService)
ILogger logger,
IAgent365ToolingService toolingService,
GraphApiService? graphApiService)
{
var command = new Command("publish", "Publish an MCP server to a Dataverse environment");
var command = new Command("publish", "Publish an MCP server to a Dataverse environment.");
Comment thread
deepaligargms marked this conversation as resolved.

var envIdOption = new Option<string?>(
["--environment-id", "-e"],
description: "Dataverse environment ID"
);
description: "Dataverse environment ID");
envIdOption.IsRequired = false; // Allow null so we can prompt
Comment thread
deepaligargms marked this conversation as resolved.
command.AddOption(envIdOption);

var serverNameOption = new Option<string?>(
["--server-name", "-s"],
description: "MCP server name to publish"
);
serverNameOption.IsRequired = false; // Allow null so we can prompt
Comment thread
deepaligargms marked this conversation as resolved.
description: "MCP server name to publish");
serverNameOption.IsRequired = false;
command.AddOption(serverNameOption);

var aliasOption = new Option<string?>(
["--alias", "-a"],
description: "Alias for the MCP server"
);
description: "Alias for the MCP server");
command.AddOption(aliasOption);

var displayNameOption = new Option<string?>(
["--display-name", "-d"],
description: "Display name for the MCP server"
);
description: "Display name for the MCP server (max 30 chars)");
Comment thread
deepaligargms marked this conversation as resolved.
command.AddOption(displayNameOption);

var dryRunOption = new Option<bool>(
name: "--dry-run",
description: "Show what would be done without executing"
);
command.AddOption(dryRunOption);

var verboseOption = new Option<bool>(
["--verbose", "-v"],
description: "Enable verbose logging"
);
command.AddOption(verboseOption);

command.SetHandler(async (envId, serverName, alias, displayName, dryRun, verbose) =>
{
_ = verbose;
try
{
// Validate and prompt for missing required arguments with security checks
if (string.IsNullOrWhiteSpace(envId))
{
envId = InputValidator.PromptAndValidateRequiredInput("Enter Dataverse environment ID: ", "Environment ID");
if (string.IsNullOrWhiteSpace(envId))
{
logger.LogError("Environment ID is required");
return;
}
}
else
{
// Validate provided environment ID
envId = InputValidator.ValidateInput(envId, "Environment ID");
if (envId == null)
{
logger.LogError("Invalid environment ID format");
return;
}
}

if (string.IsNullOrWhiteSpace(serverName))
{
serverName = InputValidator.PromptAndValidateRequiredInput("Enter MCP server name to publish: ", "Server name", 100);
if (string.IsNullOrWhiteSpace(serverName))
{
logger.LogError("Server name is required");
return;
}
}
else
{
// Validate provided server name
serverName = InputValidator.ValidateInput(serverName, "Server name");
if (serverName == null)
{
logger.LogError("Invalid server name format");
return;
}
}

logger.LogInformation("Starting publish operation for server {ServerName} in environment {EnvId}...", serverName, envId);
var publisherNameOption = new Option<string?>(
["--publisher-name", "-p"],
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);

if (dryRun)
{
logger.LogInformation("[DRY RUN] Would read config from a365.config.json");
logger.LogInformation("[DRY RUN] Would publish MCP server {ServerName} to environment {EnvId}", serverName, envId);
logger.LogInformation("[DRY RUN] Alias: {Alias}", alias ?? "[would prompt]");
logger.LogInformation("[DRY RUN] Display Name: {DisplayName}", displayName ?? "[would prompt]");
await Task.CompletedTask;
return;
}

// Validate and prompt for missing optional values with security checks
if (string.IsNullOrWhiteSpace(alias))
{
alias = InputValidator.PromptAndValidateRequiredInput("Enter alias for the MCP server: ", "Alias", 50);
if (string.IsNullOrWhiteSpace(alias))
{
logger.LogError("Alias is required");
return;
}
}
else
{
// Validate provided alias
alias = InputValidator.ValidateInput(alias, "Alias", maxLength: 50);
if (alias == null)
{
logger.LogError("Invalid alias format");
return;
}
}
var yesOption = new Option<bool>(
["--yes", "-y"],
description: "Skip the interactive 'Proceed with publish? (y/N)' confirmation.");
command.AddOption(yesOption);

if (string.IsNullOrWhiteSpace(displayName))
{
displayName = InputValidator.PromptAndValidateRequiredInput("Enter display name for the MCP server: ", "Display name", 100);
if (string.IsNullOrWhiteSpace(displayName))
{
logger.LogError("Display name is required");
return;
}
}
else
{
// Validate provided display name
displayName = InputValidator.ValidateInput(displayName, "Display name", maxLength: 100);
if (displayName == null)
{
logger.LogError("Invalid display name format");
return;
}
}
}
catch (ArgumentException ex)
{
logger.LogError("Input validation failed: {Message}", ex.Message);
return;
}
var dryRunOption = new Option<bool>("--dry-run", "Show what would be done without executing");
command.AddOption(dryRunOption);

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

// Call service
var response = await toolingService.PublishServerAsync(envId, serverName, request);
command.SetHandler(async (context) =>
{
var args = new RawPublishArgs(
EnvironmentId: context.ParseResult.GetValueForOption(envIdOption),
ServerName: context.ParseResult.GetValueForOption(serverNameOption),
Alias: context.ParseResult.GetValueForOption(aliasOption),
DisplayName: context.ParseResult.GetValueForOption(displayNameOption),
PublisherName: context.ParseResult.GetValueForOption(publisherNameOption),
Yes: context.ParseResult.GetValueForOption(yesOption),
DryRun: context.ParseResult.GetValueForOption(dryRunOption));

if (response == null || !response.IsSuccess)
var executor = new PublishCommandExecutor(logger, toolingService, graphApiService);
var success = await executor.ExecuteAsync(args, context.GetCancellationToken());
if (!success)
{
if (response?.Message != null)
{
logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: {ErrorMessage}", serverName, envId, response.Message);
}
else
{
logger.LogError("Failed to publish MCP server {ServerName} to environment {EnvId}: No response received", serverName, envId);
}
return;
context.ExitCode = 1;
}

logger.LogInformation("Successfully published MCP server {ServerName} to environment {EnvId}", serverName, envId);

}, envIdOption, serverNameOption, aliasOption, displayNameOption, dryRunOption, verboseOption);
});
Comment thread
deepaligargms marked this conversation as resolved.

return command;
}
Expand Down Expand Up @@ -844,9 +732,6 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
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')");
command.AddOption(remoteScopesOption);

var tenantIdOption = new Option<string?>(["--tenant-id", "-t"], description: "Entra tenant ID for app registration (defaults to current az login tenant)");
command.AddOption(tenantIdOption);

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

Expand Down Expand Up @@ -881,7 +766,7 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
ToolsInput: context.ParseResult.GetValueForOption(toolsOption),
InputFile: context.ParseResult.GetValueForOption(inputFileOption),
RemoteScopes: context.ParseResult.GetValueForOption(remoteScopesOption),
TenantId: context.ParseResult.GetValueForOption(tenantIdOption),
TenantId: null,
Comment thread
deepaligargms marked this conversation as resolved.
ServiceTreeId: context.ParseResult.GetValueForOption(serviceTreeIdOption),
Comment thread
deepaligargms marked this conversation as resolved.
SecretLifetimeMonths: context.ParseResult.GetValueForOption(secretLifetimeMonthsOption),
PublisherName: context.ParseResult.GetValueForOption(publisherOption),
Expand Down
Loading
Loading