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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
**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.

### Added
- `--connectivity public|private` on `a365 develop-mcp register-external-mcp-server` records whether the created Power Platform connector should bypass environment-level VNet injection, defaulting to `private` (#498).
- `a365 develop-mcp grant-agents-access --agent-blueprint-id <GUID> --mcp-server-name <NAME>` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500).
- When more than one Entra application shares the MCP server's name, `a365 develop-mcp grant-agents-access` now lists them all and asks which one to use instead of failing (#500).
- `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere (#500).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,9 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
var descriptionOption = new Option<string?>("--description", description: "Server description (required, used in MOS package metadata)");
command.AddOption(descriptionOption);

var connectivityOption = new Option<string?>("--connectivity", description: "Whether the remote MCP server is reachable publicly or only inside the environment's VNet: 'public' or 'private' (default). 'public' asks Power Platform to bypass VNet injection on the connector, which takes effect only in environments enabled for it.");
command.AddOption(connectivityOption);

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

Expand Down Expand Up @@ -628,6 +631,7 @@ private static Command CreateRegisterExternalMcpServerSubcommand(
SecretLifetimeMonths: context.ParseResult.GetValueForOption(secretLifetimeMonthsOption),
PublisherName: context.ParseResult.GetValueForOption(publisherOption),
Description: context.ParseResult.GetValueForOption(descriptionOption),
Connectivity: context.ParseResult.GetValueForOption(connectivityOption),
DryRun: context.ParseResult.GetValueForOption(dryRunOption));

var executor = new RegisterCommandExecutor(logger, toolingService, graphApiService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ internal record RawRegisterArgs(
int? SecretLifetimeMonths,
string? PublisherName,
string? Description,
string? Connectivity,
bool DryRun);

/// <summary>
Expand All @@ -57,7 +58,7 @@ internal RegisterCommandExecutor(
_retryHelper = retryHelper ?? new RetryHelper(logger, maxRetries: 5, baseDelaySeconds: 3);
}

private sealed record ResolvedInput
internal sealed record ResolvedInput
{
public required string ServerName { get; init; }
public required string ServerUrl { get; init; }
Expand All @@ -82,9 +83,10 @@ private sealed record ResolvedInput
public string? IdpClientSecret { get; init; }
public string? ApiKeyLocation { get; init; }
public string? ApiKeyName { get; init; }
public string? Connectivity { get; init; }
}

private sealed record EntraAppSet(
internal sealed record EntraAppSet(
string A365AppClientId,
string A365AppSecret,
string A365AppObjectId,
Expand Down Expand Up @@ -191,7 +193,7 @@ internal async Task<bool> ExecuteAsync(RawRegisterArgs args, CancellationToken c
return true;
}

private async Task<ResolvedInput?> ResolveInputsAsync(RawRegisterArgs args)
internal async Task<ResolvedInput?> ResolveInputsAsync(RawRegisterArgs args)
{
var serverName = args.ServerName;
var serverUrl = args.ServerUrl;
Expand All @@ -210,6 +212,7 @@ internal async Task<bool> ExecuteAsync(RawRegisterArgs args, CancellationToken c
var secretLifetimeMonths = args.SecretLifetimeMonths;
var publisherName = args.PublisherName;
var serverDescription = args.Description;
var connectivity = args.Connectivity;

RegisterExternalMcpServerInput? inputFileData = null;
if (!string.IsNullOrWhiteSpace(args.InputFile))
Expand Down Expand Up @@ -244,6 +247,7 @@ internal async Task<bool> ExecuteAsync(RawRegisterArgs args, CancellationToken c
secretLifetimeMonths ??= inputFileData.SecretLifetimeMonths;
publisherName ??= inputFileData.PublisherName;
serverDescription ??= inputFileData.Description;
connectivity ??= inputFileData.Connectivity;
Comment thread
lasrivas marked this conversation as resolved.

if (inputFileData.ExternalOAuth is not null)
{
Expand Down Expand Up @@ -311,6 +315,26 @@ internal async Task<bool> ExecuteAsync(RawRegisterArgs args, CancellationToken c
return null;
}

// Non-null rather than non-blank: `--connectivity " "` is a mistake, and treating it as
// absent would silently register the server as private, which is the opposite of what
// someone typing the option intends. It would also let a blank CLI value quietly
// override a valid input-file value through the ??= merge above.
if (connectivity is not null)
{
connectivity = connectivity.Trim();
if (!connectivity.Equals("public", StringComparison.OrdinalIgnoreCase)
&& !connectivity.Equals("private", StringComparison.OrdinalIgnoreCase))
{
// The value may have come from either source, and naming the wrong one sends
// the caller editing a file they never passed.
var source = args.Connectivity is not null ? "--connectivity" : "connectivity in the input file";
_logger.LogError("{Source} must be 'public' or 'private'. Got: '{Value}'", source, connectivity);
return null;
}

connectivity = connectivity.ToLowerInvariant();
}

if (string.IsNullOrWhiteSpace(authType))
{
authType = DevelopMcpCommand.InputValidator.PromptAndValidateRequiredInput("Enter authentication type (EntraOAuth, ExternalOAuth, APIKey, or NoAuth): ", "Auth type", 20);
Expand Down Expand Up @@ -507,6 +531,7 @@ internal async Task<bool> ExecuteAsync(RawRegisterArgs args, CancellationToken c
IdpClientSecret = idpClientSecret,
ApiKeyLocation = apiKeyLocation,
ApiKeyName = apiKeyName,
Connectivity = connectivity,
};
}

Expand All @@ -523,6 +548,12 @@ private void DisplayRegistrationSummary(ResolvedInput input)
DevelopMcpCommand.WriteLabel(" Auth Type: "); Console.WriteLine(input.AuthType);
DevelopMcpCommand.WriteLabel(" Publisher: "); Console.WriteLine(input.PublisherName);
DevelopMcpCommand.WriteLabel(" Description: "); Console.WriteLine(input.Description);
DevelopMcpCommand.WriteLabel(" Connectivity: "); Console.WriteLine(input.Connectivity ?? "private (default)");
if (string.Equals(input.Connectivity, "public", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(" Note: the VNet bypass for 'public' applies only to environments enabled for it.");
Console.WriteLine(" Verify the server is reachable after registration.");
}
DevelopMcpCommand.WriteLabel(" Tools:");
Console.WriteLine();
foreach (var tool in input.ToolList)
Expand Down Expand Up @@ -626,7 +657,7 @@ private void DisplayRegistrationSummary(ResolvedInput input)
PublicClientsAppName: publicClients.AppName);
}

private static AddMcpServerRequest BuildRequest(ResolvedInput input, EntraAppSet apps)
internal static AddMcpServerRequest BuildRequest(ResolvedInput input, EntraAppSet apps)
{
AddMcpServerAuthMetadata authMetadata;

Expand Down Expand Up @@ -685,6 +716,7 @@ private static AddMcpServerRequest BuildRequest(ResolvedInput input, EntraAppSet
RemoteServerScopes = input.RemoteScopes,
PublisherName = input.PublisherName,
Description = input.Description,
Connectivity = input.Connectivity,
Comment thread
lasrivas marked this conversation as resolved.
Comment thread
lasrivas marked this conversation as resolved.
CopilotClientAppId = apps.PublicClientsClientId,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ public class AddMcpServerRequest
/// </summary>
[JsonPropertyName("force")]
public bool Force { get; set; }

/// <summary>
/// Connectivity of the remote MCP server: "public" or "private". Null means private.
/// </summary>
[JsonPropertyName("connectivity")]
public string? Connectivity { get; set; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ public class RegisterExternalMcpServerInput
[JsonPropertyName("secretLifetimeMonths")]
public int? SecretLifetimeMonths { get; set; }

/// <summary>
/// Whether the remote MCP server is reachable publicly or only inside the environment's VNet:
/// "public" or "private". Defaults to "private" when omitted. Overridden by --connectivity.
/// </summary>
[JsonPropertyName("connectivity")]
public string? Connectivity { get; set; }

/// <summary>
/// External OAuth configuration (required when authType is ExternalOAuth)
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"tenantId": null,
"serviceTreeId": null,
"secretLifetimeMonths": null,
"connectivity": "private",
"force": false,
"externalOAuth": {
"authorizationUrl": null,
Expand Down
Loading
Loading