Skip to content
Merged
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
130 changes: 92 additions & 38 deletions scripts/cli/Auth/New-Agent365ToolsServicePrincipalProdPublic.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,50 +10,45 @@
All V1 servers share this single resource and use McpServers.*.All scopes.

V2 model: Creates one Service Principal per MCP server using per-server AppIds.
V2 AppIds are discovered from the live Agent 365 V2 endpoint:
https://agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers
V2 servers use the Tools.ListInvoke.All scope against their own audience GUID.
AppIds are extracted from ToolingManifest.json (-ManifestPath) or passed
directly via -V2AppIds.
Pass -V2AppIds to bypass the live call and supply AppIds directly.

Use -Mode All (default) during migration when the tenant may have both V1 and V2 servers.

.PARAMETER Mode
V1 - Provision only the shared V1 ATG Service Principal.
V2 - Provision per-server V2 Service Principals only.
V2 - Provision per-server V2 Service Principals only (discovered from live endpoint).
All - Provision both V1 and all V2 servers (default, recommended during migration).

.PARAMETER ManifestPath
Path to ToolingManifest.json. The script reads audience GUIDs where scope equals
'Tools.ListInvoke.All' and creates a Service Principal for each unique V2 AppId found.

.PARAMETER V2AppIds
Explicit list of V2 per-server AppIds. Used when -ManifestPath is not provided.
Explicit list of V2 per-server AppIds. Bypasses the live discover endpoint call.

.EXAMPLE
.\New-Agent365ToolsServicePrincipalProdPublic.ps1
(Creates the V1 SP; V2 is skipped unless -ManifestPath or -V2AppIds are supplied.)
(Creates V1 SP and discovers V2 SPs from the live endpoint.)

.EXAMPLE
.\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode V2 -ManifestPath ".\ToolingManifest.json"
.\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode V2

.EXAMPLE
.\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode All -ManifestPath ".\ToolingManifest.json"
.\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode All

.EXAMPLE
.\New-Agent365ToolsServicePrincipalProdPublic.ps1 -Mode V2 -V2AppIds @("05879165-0320-489e-b644-f72b33f3edf0")

.NOTES
Requires: Admin permissions to create Service Principals.
Requires: Az CLI (az login) to acquire a token for the discover endpoint.
This script is safe to re-run — existing Service Principals are skipped, not re-created.
#>

param(
[ValidateSet("V1", "V2", "All")]
[string]$Mode = "All",

# Path to ToolingManifest.json — used to auto-extract V2 per-server AppIds
[string]$ManifestPath = "",

# Explicit V2 per-server AppIds (alternative to -ManifestPath)
# Explicit V2 per-server AppIds — bypasses the live discover endpoint call
[string[]]$V2AppIds = @()
)

Expand All @@ -63,6 +58,26 @@ Set-StrictMode -Version Latest
# V1: shared ATG AppId (WorkIQToolsProdAppId) — all V1 servers share this resource
$v1AppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"

# V2 discover endpoint — returns a bare JSON array of available MCP servers
$v2DiscoverUrl = "https://agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers"

# V2 scope value used by all per-server entries
$v2ScopeValue = "Tools.ListInvoke.All"

# V2 fallback AppIds — used when the discover endpoint is unreachable.
# Source: MCPPlatform_McpScopedApps__ServerAppMappings__* configuration values.
$v2FallbackAppIds = @(
"16b1878d-62c7-4009-aa25-68989d63bbad", # mcp_MailTools
"147dc821-b413-44c0-8009-1a3098378012", # mcp_MeServer
"910333d2-47e9-43ca-981f-6df2f4531ef4", # mcp_CalendarTools
"ce5029ee-c1d3-45c0-bdcc-efb5a4245687", # mcp_TeamsServer
"b0b2a2bb-6361-4549-a00c-a018417eb8e2", # mcp_OneDriveRemoteServer
"292cff14-c0e8-4116-9e3b-99934ae05766", # mcp_SharePointRemoteServer
"2dbeefeb-6462-48a4-abe6-1c4989699319", # mcp_AdminTools
"c2d0c2b6-8013-4346-9f8b-b81d3b754a29", # mcp_WordServer
"ab7c82de-7946-4454-ac28-70249d17c95e" # mcp_M365Copilot
)

# --- Helper: create Service Principal if it does not already exist ---
function Register-ServicePrincipalIfMissing {
param([string]$AppId, [string]$Label)
Expand All @@ -80,6 +95,51 @@ function Register-ServicePrincipalIfMissing {
Write-Host " Created: $($sp.DisplayName) (SP ID: $($sp.Id))" -ForegroundColor Green
}

# --- Helper: call the V2 discover endpoint and extract per-server AppIds ---
function Get-V2AppIdsFromDiscoverEndpoint {
Write-Host "Discovering V2 AppIds from: $v2DiscoverUrl" -ForegroundColor Cyan

# Acquire a token for the ATG audience using az CLI
try {
$token = az account get-access-token --resource $v1AppId --query accessToken -o tsv 2>$null
if ([string]::IsNullOrWhiteSpace($token)) {
Write-Host " WARNING: Could not acquire token via az CLI. Ensure you are logged in with 'az login'." -ForegroundColor Yellow
return @()
}
}
catch {
Write-Host " WARNING: az CLI token acquisition failed: $($_.Exception.Message)" -ForegroundColor Yellow
return @()
}

try {
$headers = @{ Authorization = "Bearer $token" }
$response = Invoke-RestMethod -Uri $v2DiscoverUrl -Headers $headers -Method Get -ErrorAction Stop

# V2 returns a bare array; V1 (legacy) returns a wrapped { mcpServers: [...] } object
$servers = if ($response -is [array]) { $response } else { $response.mcpServers }

if (-not $servers -or $servers.Count -eq 0) {
Write-Host " No servers returned from discover endpoint." -ForegroundColor Yellow
return @()
}

$appIds = @(
$servers |
Where-Object { $_.scope -eq $v2ScopeValue -and $_.audience -match '(?i)^[0-9a-f]{8}-' } |
Select-Object -ExpandProperty audience -Unique
)

Write-Host " Found $($appIds.Count) V2 AppId(s) from discover endpoint." -ForegroundColor Cyan
Write-Host ""
return $appIds
}
catch {
Write-Host " WARNING: Failed to call discover endpoint: $($_.Exception.Message)" -ForegroundColor Yellow
return @()
}
}

Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Service Principal Creation for Agent 365 MCP Servers (Admin Only)" -ForegroundColor Cyan
Write-Host " Mode: $Mode" -ForegroundColor Cyan
Expand All @@ -93,23 +153,18 @@ Write-Host ""
$resolvedV2AppIds = @()

if ($Mode -ne "V1") {
if ($ManifestPath -and (Test-Path $ManifestPath)) {
Write-Host "Reading V2 AppIds from manifest: $ManifestPath" -ForegroundColor Cyan
$manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json
$resolvedV2AppIds = @(
$manifest.mcpServers |
Where-Object { $_.scope -eq "Tools.ListInvoke.All" -and $_.audience -match '(?i)^[0-9a-f]{8}-' } |
Select-Object -ExpandProperty audience -Unique
)
Write-Host " Found $($resolvedV2AppIds.Count) V2 AppId(s) in manifest." -ForegroundColor Cyan
Write-Host ""
}
elseif ($V2AppIds.Count -gt 0) {
if ($V2AppIds.Count -gt 0) {
$resolvedV2AppIds = $V2AppIds
Write-Host "Using explicit V2 AppIds provided via -V2AppIds." -ForegroundColor Cyan
Write-Host ""
}
elseif ($Mode -eq "V2") {
Write-Host "ERROR: -Mode V2 requires -ManifestPath or -V2AppIds." -ForegroundColor Red
exit 1
else {
$liveAppIds = Get-V2AppIdsFromDiscoverEndpoint
# Always union live results with the hardcoded fallback so servers absent from
# the discover response (e.g. mcp_MeServer) are still provisioned.
$resolvedV2AppIds = @($liveAppIds + $v2FallbackAppIds | Select-Object -Unique)
Write-Host " Total V2 AppIds to provision (live + fallback): $($resolvedV2AppIds.Count)" -ForegroundColor Cyan
Write-Host ""
Comment thread
biswapm marked this conversation as resolved.
}
}

Expand All @@ -131,17 +186,17 @@ Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
# --- Connect to Microsoft Graph ---
Write-Host ""
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Write-Host "⚠ You need admin permissions for this operation." -ForegroundColor Yellow
Write-Host "You need admin permissions for this operation." -ForegroundColor Yellow
Write-Host ""

try {
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All" -NoWelcome
$context = Get-MgContext
Write-Host "✓ Connected to tenant: $($context.TenantId)" -ForegroundColor Green
Write-Host "Connected to tenant: $($context.TenantId)" -ForegroundColor Green
Write-Host ""
}
catch {
Write-Host "✗ Failed to connect to Microsoft Graph" -ForegroundColor Red
Write-Host "Failed to connect to Microsoft Graph" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
exit 1
}
Expand All @@ -155,26 +210,25 @@ try {
Register-ServicePrincipalIfMissing -AppId $v1AppId -Label "V1 Shared ATG"
}

# V2: per-server Service Principals
# V2: per-server Service Principals discovered from the live endpoint
if (($Mode -eq "V2" -or $Mode -eq "All") -and $resolvedV2AppIds.Count -gt 0) {
foreach ($appId in $resolvedV2AppIds) {
Register-ServicePrincipalIfMissing -AppId $appId -Label "V2 Per-Server"
}
}
elseif ($Mode -eq "All" -and $resolvedV2AppIds.Count -eq 0) {
Write-Host ""
Write-Host " V2 provisioning skipped — no V2 AppIds found." -ForegroundColor Yellow
Write-Host " Provide -ManifestPath or -V2AppIds to provision V2 servers." -ForegroundColor Yellow
Write-Host " V2 provisioning skipped — no V2 AppIds available." -ForegroundColor Yellow
}
}
catch {
Write-Host ""
Write-Host "✗ Failed to create Service Principal" -ForegroundColor Red
Write-Host "Failed to create Service Principal" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host ""

if ($_.Exception.Message -like "*Insufficient privileges*" -or $_.Exception.Message -like "*Authorization*") {
Write-Host "⚠ This error usually means you don't have admin permissions." -ForegroundColor Yellow
Write-Host "This error usually means you don't have admin permissions." -ForegroundColor Yellow
Write-Host ""
Write-Host "Required Permissions:" -ForegroundColor Cyan
Write-Host " - AppRoleAssignment.ReadWrite.All" -ForegroundColor White
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,23 @@ public static Command CreateCommand(
var manifestPath = manifest?.FullName
?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, McpConstants.ToolingManifestFileName);

// Determine which scopes to add
string[] requestedScopes;

var environment = setupConfig?.Environment ?? "prod";
var atgResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment);

// Determine which scopes to add.
// Explicit --scopes: single ATG call (no audience info available).
// Manifest: per-audience calls via GetScopesByAudienceAsync (V1 + V2 support).
string[]? requestedScopes = null;
Dictionary<string, string[]>? scopesByAudience = null;

if (scopes != null && scopes.Length > 0)
{
// User provided explicit scopes
requestedScopes = scopes;
logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", requestedScopes));
logger.LogInformation("");
}
else
{
// Read scopes from ToolingManifest.json
if (!File.Exists(manifestPath))
{
logger.LogError("ToolingManifest.json not found at: {Path}", manifestPath);
Expand All @@ -139,25 +143,21 @@ public static Command CreateCommand(

logger.LogInformation("Reading MCP server configuration from: {Path}", manifestPath);

// Use ManifestHelper to extract scopes (includes fallback to mappings and McpServersMetadata.Read.All)
requestedScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath);
scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: atgResourceAppId);

if (requestedScopes.Length == 0)
if (scopesByAudience.Count == 0)
{
logger.LogError("No scopes found in ToolingManifest.json");
logger.LogInformation("You can specify scopes explicitly with --scopes option.");
Environment.Exit(1);
return;
}

logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}",
requestedScopes.Length, string.Join(", ", requestedScopes));
var totalScopes = scopesByAudience.Values.SelectMany(s => s).Distinct(StringComparer.OrdinalIgnoreCase).Count();
logger.LogInformation("Found {AudienceCount} audience(s) with {ScopeCount} unique scope(s) from manifest",
scopesByAudience.Count, totalScopes);
}

var environment = setupConfig?.Environment ?? "prod";
var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment);

logger.LogInformation("Target resource: Agent 365 Tools ({ResourceAppId})", resourceAppId);
logger.LogInformation("");

// Dry run mode
Expand All @@ -166,8 +166,15 @@ public static Command CreateCommand(
logger.LogInformation("DRY RUN: Add MCP Server Permissions");
logger.LogInformation("Would add the following permissions to application {AppId}:", targetAppId);
logger.LogInformation("");
logger.LogInformation("Resource: {ResourceAppId}", resourceAppId);
logger.LogInformation(" Scopes: {Scopes}", string.Join(", ", requestedScopes));
if (scopesByAudience != null)
{
foreach (var kvp in scopesByAudience)
logger.LogInformation(" {ResourceAppId} — {Scopes}", kvp.Key, string.Join(", ", kvp.Value));
}
else
{
logger.LogInformation(" {ResourceAppId} — {Scopes}", atgResourceAppId, string.Join(", ", requestedScopes!));
}
logger.LogInformation("");
logger.LogInformation("No changes made (dry run mode)");
return;
Expand All @@ -177,35 +184,54 @@ public static Command CreateCommand(
logger.LogInformation("Adding permissions to application...");
logger.LogInformation("");

// Determine tenant ID (from config or detect from Azure CLI)
string tenantId = await TenantDetectionHelper.DetectTenantIdAsync(setupConfig, logger) ?? string.Empty;

logger.LogInformation("Processing resource: {ResourceAppId}", resourceAppId);

bool success;
try
bool success = true;
if (scopesByAudience != null)
{
success = await blueprintService.AddRequiredResourceAccessAsync(
tenantId,
targetAppId,
resourceAppId,
requestedScopes,
isDelegated: true);

if (success)
{
logger.LogInformation(" Added permissions for {ResourceAppId}", resourceAppId);
}
else
// Per-audience calls — one entry per resource app ID (V1 + V2)
foreach (var kvp in scopesByAudience)
{
logger.LogError(" Failed to add permissions for {ResourceAppId}", resourceAppId);
logger.LogInformation("Processing resource: {ResourceAppId}", kvp.Key);
try
{
var ok = await blueprintService.AddRequiredResourceAccessAsync(
tenantId, targetAppId, kvp.Key, kvp.Value, isDelegated: true);
if (ok)
logger.LogInformation(" Added permissions for {ResourceAppId}", kvp.Key);
else
{
logger.LogError(" Failed to add permissions for {ResourceAppId}", kvp.Key);
success = false;
}
}
catch (Exception ex)
{
logger.LogError(" {ResourceAppId}: {Message}", kvp.Key, ex.Message);
logger.LogDebug(" {StackTrace}", ex.StackTrace);
success = false;
}
}
}
Comment thread
biswapm marked this conversation as resolved.
catch (Exception ex)
else
{
logger.LogError(" Exception adding permissions for {ResourceAppId}: {Message}", resourceAppId, ex.Message);
logger.LogDebug(" {StackTrace}", ex.StackTrace);
success = false;
// Explicit --scopes: single ATG call
logger.LogInformation("Processing resource: {ResourceAppId}", atgResourceAppId);
try
{
success = await blueprintService.AddRequiredResourceAccessAsync(
tenantId, targetAppId, atgResourceAppId, requestedScopes!, isDelegated: true);
if (success)
logger.LogInformation(" Added permissions for {ResourceAppId}", atgResourceAppId);
else
logger.LogError(" Failed to add permissions for {ResourceAppId}", atgResourceAppId);
}
catch (Exception ex)
{
logger.LogError(" Exception adding permissions for {ResourceAppId}: {Message}", atgResourceAppId, ex.Message);
logger.LogDebug(" {StackTrace}", ex.StackTrace);
success = false;
}
}

logger.LogInformation("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,9 @@ private static async Task AcquireAndDisplayManifestTokensAsync(

logger.LogInformation("");

var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath);
var serverNamesByAudience = await ManifestHelper.GetServerNamesByAudienceAsync(manifestPath);
var tokenAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig?.Environment ?? "prod");
var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId);
var serverNamesByAudience = await ManifestHelper.GetServerNamesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId);

var tokenResults = new List<McpServerTokenResult>();
foreach (var kvp in scopesByAudience)
Expand Down
Loading
Loading