Thursday, November 5, 2026

Build a Secure MCP Server on Azure Functions for Copilot Studio Agents (Entra ID OAuth)

Model Context Protocol (MCP) is quickly becoming the standard way to give AI agents real tools. Copilot Studio can call any remote MCP server, but most samples skip the hard part: securing the server with Microsoft Entra ID.

In this hands-on guide we build a small IT Helpdesk MCP server on Azure Functions, protect it with Entra ID, and connect it to a Copilot Studio agent using OAuth 2.0. Every step was built and tested end to end.

What we will build

ComponentPurpose
Azure Function App (Flex Consumption, Python)Hosts the remote MCP server using the Azure Functions MCP extension
4 MCP toolsget_service_status, search_kb_articles, create_support_ticket, whoami
Built-in MCP authentication (Easy Auth)Rejects anonymous calls with HTTP 401 and validates Entra tokens
Server app registrationRepresents the MCP API and exposes the user_impersonation scope
Client app registrationUsed by Copilot Studio to sign the user in (OAuth 2.0 authorization code flow)
Copilot Studio agentCalls the MCP tools on behalf of the signed-in user

Request flow

User --> Copilot Studio agent
            |  (1) User signs in with Entra ID (OAuth 2.0, client app)
            |  (2) Agent receives an access token for api://<server-app-id>
            v
   HTTPS + Bearer token
            v
Azure Functions  /runtime/webhooks/mcp
   [Easy Auth validates token, audience and allowed client app]
            |  (3) Valid  -> MCP tool runs, caller identity in X-MS-CLIENT-PRINCIPAL
            |  (4) Invalid -> HTTP 401 Unauthorized
            v
   Tool result --> Agent --> User

Prerequisites

RequirementNotes
Azure subscriptionOwner or Contributor on a resource group
Entra ID permissionsCreate app registrations; admin consent is optional but simplifies sign-in
Copilot StudioA licence or trial; tools require it
Azure Cloud Shell (bash)Used for deployment and app registration commands
Use a dev/test tenantRecommended for following along

Step 1: Write the MCP server code

We use the Azure Functions MCP extension with the Python v2 programming model. Each tool is a normal Python function decorated with @app.mcp_tool().

ToolWhat it doesInputs
get_service_statusReturns health of IT services (email, VPN, HR portal, printing)service_name (optional)
search_kb_articlesKeyword search over knowledge base articlesquery (required)
create_support_ticketCreates a simulated ticket and returns its numbertitle, description (required), priority (optional)
whoamiReturns the Entra identity of the caller, as validated by Easy Authnone

All sample data is fictional and held in memory, so you can focus on the MCP and security plumbing.

function_app.py

"""
Contoso IT Helpdesk - remote MCP server on Azure Functions (Python v2 model).

Tools are exposed with the Azure Functions MCP extension. When deployed with
built-in (Entra ID) authentication, App Service validates the caller's token
before any tool runs, and forwards the caller identity in the
X-MS-CLIENT-PRINCIPAL header, which the `whoami` tool decodes.

All data in this sample is fictional and held in memory.
"""

import base64
import json
import logging
from datetime import datetime, timezone

import azure.functions as func
from azure.functions.mcp import MCPToolContext

app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)

# ---------------------------------------------------------------------------
# Fictional sample data
# ---------------------------------------------------------------------------
SERVICES = {
    "email": {"status": "Healthy", "note": "All mailboxes operating normally."},
    "vpn": {"status": "Degraded", "note": "Slow connections in one region. Engineers are investigating."},
    "hr-portal": {"status": "Healthy", "note": "No known issues."},
    "printing": {"status": "Outage", "note": "Floor 3 print server offline. ETA 2 hours."},
}

KB_ARTICLES = [
    {"id": "KB1001", "title": "Reset your password with self-service password reset",
     "keywords": ["password", "reset", "locked", "sspr"]},
    {"id": "KB1002", "title": "Connect to the corporate VPN",
     "keywords": ["vpn", "remote", "connect", "network"]},
    {"id": "KB1003", "title": "Add a shared mailbox in Outlook",
     "keywords": ["mailbox", "outlook", "shared", "email"]},
    {"id": "KB1004", "title": "Install a network printer",
     "keywords": ["printer", "printing", "install", "driver"]},
]

VALID_PRIORITIES = {"low", "medium", "high"}


def _json(data) -> str:
    return json.dumps(data, indent=2)


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@app.mcp_tool()
@app.mcp_tool_property(
    arg_name="service_name",
    description="Optional service to check: email, vpn, hr-portal or printing. Leave empty for all services.",
    is_required=False,
)
def get_service_status(service_name: str = "") -> str:
    """Get the current health status of IT services."""
    name = (service_name or "").strip().lower()
    if not name:
        return _json({"services": SERVICES})
    if name not in SERVICES:
        return _json({"error": f"Unknown service '{service_name}'.", "known_services": list(SERVICES)})
    return _json({name: SERVICES[name]})


@app.mcp_tool()
@app.mcp_tool_property(
    arg_name="query",
    description="Keywords describing the problem, for example 'vpn not connecting'.",
    is_required=True,
)
def search_kb_articles(query: str) -> str:
    """Search IT knowledge base articles by keyword."""
    words = {w.strip(".,?!").lower() for w in (query or "").split()}
    matches = [
        {"id": a["id"], "title": a["title"]}
        for a in KB_ARTICLES
        if words & set(a["keywords"])
    ]
    return _json({"query": query, "results": matches or "No matching articles found."})


@app.mcp_tool()
@app.mcp_tool_property(arg_name="title", description="Short summary of the issue.", is_required=True)
@app.mcp_tool_property(arg_name="description", description="Details of the issue.", is_required=True)
@app.mcp_tool_property(arg_name="priority", description="low, medium or high. Defaults to medium.", is_required=False)
def create_support_ticket(title: str, description: str, priority: str = "medium") -> str:
    """Create a simulated IT support ticket and return its ticket number."""
    level = (priority or "medium").strip().lower()
    if level not in VALID_PRIORITIES:
        level = "medium"
    now = datetime.now(timezone.utc)
    ticket = {
        "ticket_id": f"INC{now.strftime('%y%m%d%H%M%S')}",
        "title": title,
        "description": description,
        "priority": level,
        "status": "New",
        "created_utc": now.isoformat(timespec="seconds"),
    }
    logging.info("Created simulated ticket %s", ticket["ticket_id"])
    return _json(ticket)


@app.mcp_tool()
def whoami(context: MCPToolContext) -> str:
    """Return the identity of the signed-in user calling this MCP server, as validated by Microsoft Entra ID."""
    headers = context.get("transport", {}).get("properties", {}).get("headers", {}) or {}
    # Header names can arrive in any case
    headers = {k.lower(): v for k, v in headers.items()}
    encoded = headers.get("x-ms-client-principal", "")
    if not encoded:
        return _json({"authenticated": False,
                      "message": "No Entra principal found. Built-in authentication is not enabled (or running locally)."})

    principal = json.loads(base64.b64decode(encoded))
    wanted = {"name", "preferred_username", "tid", "appid", "azp", "scp"}
    claims = {c["typ"]: c["val"] for c in principal.get("claims", []) if c.get("typ") in wanted}
    return _json({
        "authenticated": True,
        "identity_provider": principal.get("auth_typ"),
        "display_name": claims.get("name"),
        "user_principal_name": claims.get("preferred_username"),
        "calling_client_app_id": claims.get("azp") or claims.get("appid"),
        "scopes": claims.get("scp"),
    })

Key points in the code

ItemWhy it matters
@app.mcp_tool()Registers the function as an MCP tool. The docstring becomes the tool description the agent uses to choose tools.
@app.mcp_tool_property(..., is_required=False)Describes each input. Mark optional inputs explicitly, otherwise they may be treated as required.
MCPToolContextGives access to the transport, including HTTP headers forwarded by App Service authentication.
X-MS-CLIENT-PRINCIPALBase64 JSON with the validated user claims. Your code never parses raw tokens.

host.json

The MCP extension ships in the preview extension bundle.

{
  "version": "2.0",
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": true,
        "excludedTypes": "Request"
      }
    }
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview",
    "version": "[4.*, 5.0.0)"
  }
}

requirements.txt

azure-functions>=2.2.0b3

Tip: Do not add azure-functions-worker to requirements.txt. The platform manages it.

Step 2: Create the Function App (Flex Consumption)

In the Azure portal, go to Create a resource > Function App and choose Flex Consumption. It scales to zero, so an idle demo costs almost nothing.

[Image_1]
Choose the Flex Consumption hosting plan

SettingValue used
Resource grouprg-mcp-helpdesk-demo (new)
Function App namefunc-mcp-helpdesk-demo01
RegionAny supported region near you
Runtime stack / versionPython 3.13
Instance size2048 MB (default)

[Image_2]
Basics tab

On the Authentication tab, switch host storage, deployment storage and Application Insights to Managed identity. This removes connection-string secrets from the app.

[Image_3]
Use managed identity for all resources

Select Review + create, then Create.

[Image_4]
Review + create

[Image_5]
Deployment complete

Tip: New apps use a secure unique default hostname such as <app>-<hash>.<region>-01.azurewebsites.net. Always copy the hostname from the Overview page.

Step 3: Deploy the code

Open Cloud Shell (bash), place the three files in a folder, then zip and deploy with a remote build:

RG=rg-mcp-helpdesk-demo
APP=func-mcp-helpdesk-demo01

zip -q app.zip function_app.py host.json requirements.txt
az functionapp deployment source config-zip -g $RG -n $APP --src app.zip --build-remote true

[Image_6]
Zip deployment succeeded

Important: If the first deployment fails with BlobUploadFailedException (status 500), the managed identity's new storage role assignment has not propagated yet. Wait about 2 minutes and run the deploy again.

Confirm all four functions are registered as mcpToolTrigger and get the MCP endpoint:

az functionapp function list -g $RG -n $APP \
  --query "[].{Function:name, Trigger:config.bindings[0].type}" -o table

HOST=$(az resource show -g $RG -n $APP --resource-type Microsoft.Web/sites \
  --query properties.defaultHostName -o tsv)
echo "MCP endpoint: https://$HOST/runtime/webhooks/mcp"

[Image_7]
Four mcpToolTrigger functions and the MCP endpoint

Tip: az functionapp show --query defaultHostName can return an empty value with secure unique hostnames. az resource show ... properties.defaultHostName works reliably.

[Image_8]
Function App overview showing the MCP tools

Step 4: Turn on built-in MCP authentication

Azure Functions has a one-click option that configures App Service authentication with Entra ID for MCP servers. Open the Function App and select AI (preview) > Turn on MCP authentication.


[Image_9]
AI (preview) blade

Enter a name for the new app registration (for example mcp-helpdesk-server) and select Save.

[Image_10]
Create the server app registration

[Image_11]
MCP authentication is On

This single action does a lot for you:

What it configuresResult
Entra app registration (server)Exposes the scope api://<server-app-id>/user_impersonation
App Service authenticationRequire authentication; unauthenticated requests get HTTP 401
Key-based accessTurned off for the MCP endpoint
Federated credential via managed identityNo client secret stored on the server app
Protected resource metadata (PRM)Published at /.well-known/oauth-protected-resource

[Image_12]
Authentication settings: Require authentication, HTTP 401

Verify the endpoint is protected

MCP_URL=https://$HOST/runtime/webhooks/mcp

# Anonymous call - expect HTTP 401
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST $MCP_URL \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# Protected resource metadata
curl -s https://$HOST/.well-known/oauth-protected-resource | python3 -m json.tool

[Image_13]
HTTP 401 for anonymous calls, plus PRM metadata

Step 5: Create the client app registration for Copilot Studio

Microsoft Entra ID does not support dynamic client registration, so Copilot Studio needs its own app registration (client ID and secret) to sign users in.

SERVER_APP=<server-app-id>

CLIENT_APP=$(az ad app create --display-name mcp-helpdesk-copilotstudio-client \
  --sign-in-audience AzureADMyOrg --query appId -o tsv)

SCOPE_ID=$(az ad app show --id $SERVER_APP \
  --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv)

az ad sp create --id $CLIENT_APP -o none

# Delegated permission to the MCP server scope
az ad app permission add --id $CLIENT_APP --api $SERVER_APP \
  --api-permissions $SCOPE_ID=Scope

# Optional: tenant-wide admin consent for this one scope
az ad app permission grant --id $CLIENT_APP --api $SERVER_APP \
  --scope user_impersonation

echo "Client app ID: $CLIENT_APP"

[Image_14]
Client app created and admin consent granted

Create a client secret and copy the value for later. Store it safely; it is shown only once.

az ad app credential reset --id $CLIENT_APP --display-name copilot-studio \
  --years 1 --append --query password -o tsv

Important: Never paste the secret into documents, chat or screenshots. Clear the terminal after copying it.

Allow the client app on the MCP server

By default, the built-in policy only accepts tokens issued to the server app itself. Without this step, Copilot Studio's token is rejected.

Go to Function App > Authentication, edit the Microsoft identity provider, choose Allow requests from specific client applications, and add the client app ID.


[Image_15]
Add the Copilot Studio client app to Allowed client applications

Step 6: Add the MCP server to a Copilot Studio agent

Create an agent (for example IT Helpdesk Assistant), then go to Tools > Add a tool > Add new MCP.

FieldValue
Server nameIT Helpdesk MCP
Server descriptionIT helpdesk tools: check IT service status, search knowledge base articles, create support tickets, and show the signed-in caller identity.
Server URLhttps://<your-function-host>/runtime/webhooks/mcp
AuthenticationOAuth 2.0 > Manual
Client IDClient app ID
Client secretValue from Step 5
Authorization URLhttps://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize
Token URL templatehttps://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
Refresh URLSame as the token URL
Scopesapi://<server-app-id>/user_impersonation offline_access

[Image_16]
MCP wizard with OAuth 2.0 Manual

Tip: Choose Manual. The Dynamic discovery and Dynamic options need dynamic client registration, which Entra ID does not support.

Select Create. Copilot Studio shows a Redirect URL. Copy it.

[Image_17]
Tool created; copy the Redirect URL

Register the redirect URL on the client app

az ad app update --id $CLIENT_APP --web-redirect-uris "<redirect-url-from-copilot-studio>"

[Image_18]
Redirect URI registered as a Web platform URI

Create the connection and add the tool

Back in the wizard, select Next, then Create new connection and sign in. A green indicator confirms the connection. Select Add.

[Image_19]
Connection created

[Image_20]
MCP tool added and enabled

Open the tool. Copilot Studio calls tools/list through the authenticated connection and shows every tool exposed by the server.

[Image_21]
All four tools discovered from the secured server

Step 7: Enable generative orchestration

This step catches many people. If the agent uses classic orchestration, it never calls MCP tools and replies with "No information was found".

Go to Settings > Generative AI > Orchestration, select Yes, and save.

[Image_22]
Generative AI orchestration enabled

Step 8: Test the agent

Start a new test session and try these prompts:

PromptTool calledResult
Is the VPN working right now?get_service_statusVPN status: Degraded
I forgot my password and I'm locked out. Is there a KB article?search_kb_articlesKB1001: Reset your password with SSPR
Please raise a high priority ticket: my laptop will not boot after a Windows update.create_support_ticketTicket INC... created, priority high
Who am I signed in as on the helpdesk server?whoamiSigned-in user and calling client app ID

[Image_23]
Service status tool call with raw MCP output

[Image_24]
Activity map: tool intent, inputs, execution

[Image_25]
Support ticket created

[Image_26]
whoami proves the Entra identity reached the server

The whoami result is the proof that matters. The server received the validated user identity and the Copilot Studio client app ID, and your code never had to handle a raw token.

Troubleshooting

SymptomCauseFix
Zip deploy fails with BlobUploadFailedException (500)Managed identity storage role not propagated yetWait about 2 minutes and redeploy
defaultHostName is emptySecure unique default hostnameUse az resource show ... properties.defaultHostName
HTTP 401 from the endpointExpected for calls without a tokenConnect through OAuth; check PRM metadata
HTTP 403 or token rejected after sign-inClient app not in Allowed client applicationsAdd the client app ID (Step 5)
AADSTS50011 redirect URI mismatchRedirect URL not registered on the client appAdd the exact Copilot Studio redirect URL as a Web URI
AADSTS65001 consent requiredNo user or admin consent for the scopeGrant admin consent, or accept consent at sign-in
Agent says "No information was found"Classic orchestrationEnable generative orchestration
Tools page asks to upgrade your licenceNo Copilot Studio licence or trialStart a trial or assign a licence

Security best practices

PracticeWhy
Keep key-based access off when OAuth is onFunction keys are shared secrets and can leak
Allow only specific client applicationsStops other apps in the tenant from calling your tools
Use managed identity for storage and monitoringNo connection strings to rotate
Rotate client secrets, or use certificatesLimits the impact of a leaked secret
Never pass the user's token to downstream APIsUse the On-Behalf-Of flow for Graph or other APIs
Govern MCP connectors with data policiesMCP servers in Copilot Studio are Power Platform connectors, so DLP policies apply
Use a dedicated app registration per MCP serverDo not reuse registrations across components

Clean up

az group delete -n rg-mcp-helpdesk-demo --yes
az ad app delete --id <client-app-id>
az ad app delete --id <server-app-id>

Summary

StepOutcome
1-3Python MCP server running on Azure Functions Flex Consumption
4Endpoint protected by Entra ID; anonymous calls get 401
5Client app registration, consent, secret and allow-list
6-7Copilot Studio agent connected with OAuth 2.0 and generative orchestration
8All four tools called successfully on behalf of the signed-in user

With built-in MCP authentication, securing a remote MCP server for Copilot Studio is mostly configuration, not code. Your tools stay simple, and Entra ID handles the identity.

References

Wednesday, November 4, 2026

Microsoft 365 Copilot icon in SharePoint Online Management Shell

Applies to: SharePoint Online, Microsoft 365 Copilot, SharePoint Online Management Shell, Microsoft Graph PowerShell
Level: Intermediate to Advanced  |  Purpose: Educational

The Symptom

A user who has a Microsoft 365 Copilot license opens a SharePoint site. The Copilot (agent) icon is missing from the global header, so the header shows only Announcements, Settings and Help. On other sites, the same user sees the icon.

Root Causes at a Glance

#CauseScopeWhere to checkWho fixes it
1No Microsoft 365 Copilot license, or the Microsoft Copilot in SharePoint service plan is disabledUserMicrosoft Graph PowerShell / M365 admin centerLicense admin
2KnowledgeAgentScope excludes the site (NoSites, an allow-list that doesn't include it, or a block-list that does)TenantSharePoint Online Management Shell (no UI)SharePoint admin
3Restricted Content Discovery (RCD) is On for the siteSiteSharePoint admin center → Active sites → SettingsSharePoint admin
4Site AI settings: Copilot hidden for visitors, or the Main agent points to a broken custom agentSiteSite → Settings (gear) → Site AISite owner
5Cached session or browser extensionClientInPrivate / Incognito windowUser

Key insight: once a valid license is confirmed, a missing icon on a single site is almost always a site or tenant scoping problem, not a user problem. Test one other site first. If Copilot appears there, skip straight to checks 2–4.

Step 1 – Rule Out the Browser (2 minutes)

  • Open the site in an InPrivate/Incognito window and sign in again.
  • Open a second site where you'd expect Copilot. If it shows there but not on the first site, the problem is limited to that site.

Step 2 – Verify the License Service Plan

The service plan that drives Copilot in SharePoint is M365_COPILOT_SHAREPOINT (ID 0aedf20c-091d-420b-aadf-30c042609612).

Connect-MgGraph -Scopes "User.Read.All" -NoWelcome
$upn = "user@contoso.com"
Get-MgUserLicenseDetail -UserId $upn | ForEach-Object {
    $_.ServicePlans | Where-Object { $_.ServicePlanId -eq "0aedf20c-091d-420b-aadf-30c042609612" }
} | Select-Object ServicePlanName, ProvisioningStatus
ResultMeaning
SuccessLicense is fine, so move on to the site and tenant checks
No output / DisabledAssign a Copilot license or enable the service plan, then allow time for it to take effect

Note: Copilot Chat on its own does not add the SharePoint header icon. A paid Microsoft 365 Copilot license is required.

Step 3 – Check Restricted Content Discovery (UI)

  1. SharePoint admin center → Sites → Active sites.
  2. Select the site → Settings tab.
  3. Check Restrict content discovery. If it's On, Copilot is hidden on that site whatever the other settings are.

PowerShell equivalent: Get-SPOSite -Identity <url> | Select-Object RestrictContentOrgWideSearch

Caution: RCD is usually turned on for a governance reason, such as oversharing risk or a pending permissions review. Confirm with the governance owner before turning it off.

Step 4 – Check Site AI Settings (UI)

Site → Settings (gear) → Site AI. Site owners can:

  • Choose the Main agent that opens from the header icon (Copilot in SharePoint or a custom agent).
  • Hide the Copilot button for site visitors.

Tip: if the Main agent points to a custom .agent file that was moved, deleted or restricted, the icon may have nothing to open. Switch the Main agent back to the default to test.

Step 5 – Check the Tenant Scope (PowerShell Only)

This is the check most often missed, because there's no admin-center UI for it.

Connect-SPOService -Url https://contoso-admin.sharepoint.com
Get-SPOTenant | Select-Object KnowledgeAgentScope, KnowledgeAgentSelectedSitesList
KnowledgeAgentScopeBehaviourFix
NoSites (default)Copilot in SharePoint is off on every siteChange the scope (affects the whole tenant)
AllSitesOn everywhereNot the cause
IncludeSelectedSitesAllow-list: only listed sites get CopilotAppend the site to the list
ExcludeSelectedSitesBlock-list: listed sites don't get CopilotRemove the site from the list

The output truncates long lists, so expand it to check:

$list = (Get-SPOTenant).KnowledgeAgentSelectedSitesList
$list.Count
$list -contains "https://contoso.sharepoint.com/sites/Engineering"

Safely Adding a Site to the Allow-List

# 1. Back up the current list
(Get-SPOTenant).KnowledgeAgentSelectedSitesList | Out-File .\KnowledgeAgentSites_backup.txt
 
# 2. Append (never omit the Operation parameter)
Set-SPOTenant -KnowledgeAgentSelectedSitesList @("https://contoso.sharepoint.com/sites/Engineering") `
              -KnowledgeAgentSelectedSitesListOperation Append
 
# 3. Verify
(Get-SPOTenant).KnowledgeAgentSelectedSitesList.Count
Gotcha: the default value of -KnowledgeAgentSelectedSitesListOperation is Overwrite. If you run Set-SPOTenant -KnowledgeAgentSelectedSitesList "url" without Append, the entire allow-list is replaced with that one site, and Copilot disappears from every other listed site. Always back up first and always specify Append or Remove.

Bonus: One-Shot Read-Only Diagnostic Script

This script runs every check in one go and changes nothing. Run it in Windows PowerShell 5.1 (see the next section for why).

param(
    [Parameter(Mandatory)] [string]$AdminUrl,
    [Parameter(Mandatory)] [string]$SiteUrl,
    [Parameter(Mandatory)] [string]$UserPrincipalName
)
 
$SiteUrl = $SiteUrl.TrimEnd('/')
 
# License
Connect-MgGraph -Scopes "User.Read.All" -NoWelcome
$plan = Get-MgUserLicenseDetail -UserId $UserPrincipalName |
    ForEach-Object { $_.ServicePlans } |
    Where-Object { $_.ServicePlanId -eq "0aedf20c-091d-420b-aadf-30c042609612" }
 
# Tenant and site
Connect-SPOService -Url $AdminUrl
$tenant = Get-SPOTenant
$site   = Get-SPOSite -Identity $SiteUrl
$inList = ($tenant.KnowledgeAgentSelectedSitesList | ForEach-Object { $_.TrimEnd('/') }) -contains $SiteUrl
 
switch ($tenant.KnowledgeAgentScope) {
    "AllSites"             { $scopeOk = $true }
    "IncludeSelectedSites" { $scopeOk = $inList }
    "ExcludeSelectedSites" { $scopeOk = -not $inList }
    default                { $scopeOk = $false }
}
 
if ($plan -and $plan.ProvisioningStatus -eq "Success") {
    $licenseOk = $true
} else {
    $licenseOk = $false
}
 
[pscustomobject]@{
    LicensePlanOk          = $licenseOk
    TenantScope            = $tenant.KnowledgeAgentScope
    SiteInSelectedList     = $inList
    TenantScopeAllowsSite  = $scopeOk
    RestrictContentDiscovery = $site.RestrictContentOrgWideSearch
    LikelyVisible          = ($licenseOk -and $scopeOk -and -not $site.RestrictContentOrgWideSearch)
} | Format-List

If LikelyVisible is True and the icon is still missing, check the Site AI Main agent setting (Step 4) and the browser cache (Step 1).

Common PowerShell Hurdles (and Fixes)

ErrorCauseFix
Connect-SPOService is not recognizedThe module isn't installed or isn't loadedInstall-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser
The type initializer for 'Microsoft.Identity.Client.NativeInterop.API' threw an exception / No valid OAuth 2.0 authentication session existsThe WAM sign-in broker fails when the module runs in PowerShell 7Open Windows PowerShell 5.1 from the Start menu, not by typing powershell.exe inside PowerShell 7
Module is installed but 5.1 says "no valid module file was found"The module was installed to a folder (often a redirected Documents folder) that isn't in $env:PSModulePathUse Save-Module to a known folder and import it by path (see below)
A parameter cannot be found that matches parameter name 'KnowledgeAgentScope'An old module version is loadedUninstall all versions, then install the latest (16.0.26615.12013 or later)
# Portable module load (works even with redirected profile folders)
New-Item -Path C:\PSMods -ItemType Directory -Force | Out-Null
Save-Module -Name Microsoft.Online.SharePoint.PowerShell -Path C:\PSMods -Force
$psd1 = Get-ChildItem C:\PSMods\Microsoft.Online.SharePoint.PowerShell -Recurse `
        -Filter Microsoft.Online.SharePoint.PowerShell.psd1 | Select-Object -First 1
Import-Module $psd1.FullName -DisableNameChecking

Why does launching powershell.exe from PowerShell 7 not help? The child 5.1 process inherits PowerShell 7's PSModulePath, so its module discovery gets confused. Always start 5.1 fresh from the Start menu.

Plan Ahead: The November 1, 2026 Change

With Copilot in SharePoint now generally available, Microsoft is retiring the preview-era PowerShell controls (KnowledgeAgentScope, KnowledgeAgentSelectedSitesList, KnowledgeAgentSelectedSitesListOperation).

PeriodWhat controls visibility
Until 1 Nov 2026The PowerShell scope and allow-/block-lists are still honoured
After 1 Nov 2026Site AI settings (site owner) and Restricted Content Discovery (admin)

Recommendation: if your tenant relies on an IncludeSelectedSites allow-list to limit Copilot, that boundary goes away after the retirement date. Before then:

  1. Export the current list for your records.
  2. Decide which of the sites outside the list really need to stay hidden from Copilot.
  3. Apply Restricted Content Discovery to those sites. Catalog management can apply it in bulk.
  4. Brief site owners on the Site AI panel, since they now control the header agent.

Quick Decision Flow

QuestionIf NOIf YES
Does Copilot show in an InPrivate window?Go to the next questionClear cache / disable extensions
Is M365_COPILOT_SHAREPOINT = Success?Fix the licenseGo to the next question
Is RCD Off for the site?Review RCD with governanceGo to the next question
Does the tenant scope allow the site?Append to / remove from the listGo to the next question
Does Site AI point to a valid agent, with Copilot not hidden?Fix the Site AI settingOpen a Microsoft support case

References

Disclaimer: This article is for educational purposes. All tenant names, URLs and accounts are illustrative (contoso). Test changes in a non-production tenant and follow your organisation's change process.

Claude finished the response

Monday, October 26, 2026

How to Restore Deleted Emails in Exchange Online (Microsoft 365): The Complete Admin Guide

From the user's own "Recover deleted items" button to PowerShell bulk restores, and how to find out who deleted what, and when.

"My old emails are gone." Every Microsoft 365 admin hears this sooner or later. The good news is that in Exchange Online, a deleted email is rarely gone for good. Before it is permanently purged, it passes through several safety nets, and with the right tools you can bring it back. You can also prove how it disappeared.

This guide walks through the full process:

  1. Understand where deleted emails actually go
  2. Check whether the mail is really deleted (or just hidden or moved)
  3. Find out who deleted it, when, and from which app
  4. Restore it: self-service, then PowerShell for bulk and targeted restores
  5. Prevent it from happening again

1. The lifecycle of a deleted email

Understanding this flow is what turns "it's gone" into "it's recoverable".

Deleted email lifecycle in Exchange Online
Figure 1: Where a deleted email goes in Exchange Online, and the audit operation logged at each step.
StageWho can see itHow long it staysAudit operation
Deleted ItemsThe userUntil emptied, or removed by a retention tagMoveToDeletedItems
Recoverable Items\DeletionsThe user (via "Recover deleted items") and admins14 days by default, configurable up to 30 (RetainDeletedItemsFor)SoftDelete
Recoverable Items\PurgesAdmins onlyRemoved at the end of the retention period, unless the mailbox is on holdHardDelete
Key insight: If the mailbox is on Litigation Hold or covered by a Microsoft Purview retention policy, deleted items can stay recoverable for years, well beyond 14–30 days. Never remove or change a hold until the restore is finished. Removing it can start the purge clock.

2. Confirm the mail is really deleted

A lot of "missing email" reports are not deletions at all. Rule these out first. It takes five minutes.

CheckWhy it matters
Open Outlook on the web (outlook.office.com) and look for the mailIf it's visible on the web, the problem is the desktop client (cache, filters, collapsed date groups), not the server
Clear filters (Unread, Flagged), switch Focused/Other, and expand collapsed "Older" groupsVery common cause of "only recent mail is visible"
Search all folders for a known old messageInbox rules or Sweep may have moved it, not deleted it
Check the Online Archive (In-Place Archive)A "Move to Archive" retention tag silently moves older mail there

Server-side check with PowerShell

The quickest way to see where items actually are is to compare folder item counts and oldest-item dates:

Connect-ExchangeOnline -UserPrincipalName admin@contoso.com
$upn = "user@contoso.com"
# Item count and oldest item per folder (includes Recoverable Items)
Get-MailboxFolderStatistics $upn -IncludeOldestAndNewestItems |
    Where-Object { $_.ItemsInFolder -gt 0 } |
    Select-Object FolderPath, ItemsInFolder, OldestItemReceivedDate |
    Sort-Object ItemsInFolder -Descending | Format-Table -AutoSize
# Archive, retention policy and holds
Get-Mailbox $upn | Select-Object ArchiveStatus, RetentionPolicy, LitigationHoldEnabled, InPlaceHolds, RetainDeletedItemsFor
Sample folder statistics output
Figure 2: The tell-tale signs. The Inbox oldest date is very recent, Deleted Items is empty, and Recoverable Items\Deletions holds years of mail.
How to read it: If the Inbox's oldest date is very recent, Deleted Items is empty, and /Deletions holds a lot of items going back years, the mail was deleted and then Deleted Items was emptied. It is recoverable.
Run the same command again the next day. If the Inbox's oldest date moves forward, something is deleting mail on a rolling schedule (Sweep, a client rule, a retention policy). If it stays fixed, it was a one-time bulk delete.

3. Find out who deleted the mail, and when

Before you restore anything, find the cause. If a phone app or a client-side rule is deleting mail, it will delete the restored mail again.

Option A: Microsoft Purview audit search (portal)

  1. Go to purview.microsoft.com → Audit → Search.
  2. Set the date range (Audit Standard keeps 180 days by default).
  3. In Activities – operation names, enter: MoveToDeletedItems,SoftDelete,HardDelete
  4. Add the affected user and click Search.
  5. Open any record and check ClientInfoString, LogonType, Folder.Path and AffectedItems.
Generic audit search results
Figure 3: A generic audit search. Pairs of "Moved to Deleted Items" followed by "Deleted from Deleted Items" show a delete-then-empty pattern.

Option B: PowerShell summary (per day, folder and app)

The portal shows one event at a time. For a large investigation, this script summarizes the last 180 days by date, folder, client and logon type, keeping only the summary in memory:

$upn = "user@contoso.com"
$summary = @{}
foreach ($day in 0..179) {
    $start = (Get-Date).Date.AddDays(-180 + $day)
    $end   = $start.AddDays(1)
    $sid   = [guid]::NewGuid().ToString()
    do {
        $page = Search-UnifiedAuditLog -StartDate $start -EndDate $end -UserIds $upn `
            -Operations MoveToDeletedItems,SoftDelete,HardDelete `
            -SessionId $sid -SessionCommand ReturnLargeSet -ResultSize 5000
        foreach ($r in $page) {
            $d = $r.AuditData | ConvertFrom-Json
            $client = if ($d.ClientInfoString -match "MacOutlook") { "Outlook-Mac" } elseif ($d.ClientInfoString -match "OWA") { "OWA" } elseif ($d.ClientInfoString -match "iOS|Android") { "Mobile" } else { "Other" }
            $key = "{0}|{1}|{2}|{3}|{4}" -f $start.ToString("yyyy-MM-dd"), $r.Operations, $d.Folder.Path, $client, $d.LogonType
            if ($summary.ContainsKey($key)) {
                $summary[$key] += @($d.AffectedItems).Count
            } else {
                $summary[$key] = @($d.AffectedItems).Count
            }
        }
    } while ($page -and $page.Count -eq 5000)
    Write-Host "$($start.ToString('yyyy-MM-dd')) done"
}
$report = $summary.GetEnumerator() | ForEach-Object {
    $p = $_.Key -split "\|"
    [pscustomobject]@{ Date = $p[0]; Operation = $p[1]; Folder = $p[2]; Client = $p[3]; LogonType = $p[4]; Items = $_.Value }
}
# Biggest delete actions first
$report | Sort-Object Items -Descending | Select-Object -First 25 | Format-Table -AutoSize
FieldWhat it tells you
LogonType0 = mailbox owner, 1 = admin, 2 = delegate
ClientThe app that performed the delete: desktop Outlook, OWA, mobile, or an app using REST/Graph
FolderThe folder the items were deleted from. Filter on \Inbox to separate real mail from alert-folder cleanup.
Spike daysA single day with a huge Items value means a bulk delete. Steady daily counts mean a habit or an automated rule.
Watch out: folder moves by the mailbox owner (the Move operation) are not audited by default. If the audit log shows only a handful of Inbox deletions but the Inbox is empty, the mail may have been moved to another folder first and deleted from there.

4. Restore the emails

Option 1: Self-service in Outlook on the web (small volumes)

  1. Open outlook.office.com and select Deleted Items.
  2. Click Recover items deleted from this folder at the top of the list.
  3. Search or sort by date, select the messages, and click Restore.
Generic recover deleted items dialog
Figure 4: Generic "Recover deleted items" view in Outlook on the web. Restored items go back to their original folder.

This works well for hundreds or a few thousand items. Beyond that, use PowerShell.

Option 2: PowerShell bulk restore (targeted, admin)

Step 1: Grant the role. Get-RecoverableItems and Restore-RecoverableItems are only available with the Mailbox Import Export role, which is not assigned to anyone by default. In the Exchange admin center, go to Roles → Admin roles → Organization Management → Permissions, tick Mailbox Import Export, and save. Or run:

New-ManagementRoleAssignment -Name "MIE-Admin" -Role "Mailbox Import Export" -User "admin@contoso.com"
Tip: Exchange Online only loads cmdlets your roles allow. If you get "The term 'Get-RecoverableItems' is not recognized", the role hasn't taken effect yet. Wait 30–60 minutes, then open a new PowerShell window and reconnect. Confirm with Get-Command Get-RecoverableItems, Restore-RecoverableItems.

Step 2: Restore only what you need. A mailbox's Recoverable Items folder can hold a huge number of items, mostly notification noise. Restoring everything would flood the user's mailbox. Instead, filter by:

  • -LastParentFolderID: the folder the item was deleted from (for example, Inbox only)
  • -FilterStartTime / -FilterEndTime: these match the item's LastModifiedTime, which is roughly when it was deleted
  • -FilterItemType IPM.Note: email messages only

Step 3: Get the Inbox ID in the right format. This is the part most guides skip. Get-MailboxFolderStatistics returns a base64 FolderId, but -LastParentFolderID expects a 44-character hex string (the store GUID plus the folder counter). You can convert one to the other:

$upn = "user@contoso.com"
$inboxFid = (Get-MailboxFolderStatistics $upn -FolderScope Inbox | Select-Object -First 1).FolderId
$bytes    = [Convert]::FromBase64String($inboxFid)
$inboxId  = ($bytes[23..44] | ForEach-Object { $_.ToString("X2") }) -join ""
$inboxId   # 44 hex characters
Sanity check: Every folder in the same mailbox shares the first 32 hex characters (the store GUID). Only the last 12 (the folder counter) differ. Run the same conversion for another folder. If the two IDs share the prefix and have different endings, your conversion is right.

Step 4: Preview first, in small date windows. One query across a large Recoverable Items folder often fails with a server-side timeout. Query one folder and one day at a time, with retries:

$start   = (Get-Date).Date.AddDays(-60)
$end     = Get-Date
$preview = [System.Collections.Generic.List[object]]::new()
foreach ($src in "DeletedItems", "RecoverableItems") {
    $s = $start
    while ($s -lt $end) {
        $e  = $s.AddDays(1)
        $ok = $false
        foreach ($try in 1..3) {
            try {
                $r = Get-RecoverableItems -Identity $upn -SourceFolder $src -FilterItemType IPM.Note `
                    -LastParentFolderID $inboxId -FilterStartTime $s -FilterEndTime $e -ResultSize 1000 -ErrorAction Stop
                if ($r) { $r | ForEach-Object { $preview.Add($_) } }
                $ok = $true
                break
            } catch {
                Start-Sleep -Seconds 20
            }
        }
        if (-not $ok) {
            Write-Host "$src $($s.ToString('yyyy-MM-dd')) FAILED after 3 tries" -ForegroundColor Red
        }
        $s = $e
    }
}
$preview.Count
$preview | Group-Object LastParentFolderID | Select-Object Count, Name   # should be ONE row = $inboxId
$preview | Select-Object Subject, LastModifiedTime | Export-Csv .\restore-preview.csv -NoTypeInformation

Step 5: Restore, using the same windows. Items go back to their original folder:

foreach ($src in "DeletedItems", "RecoverableItems") {
    $s = $start
    while ($s -lt $end) {
        $e = $s.AddDays(1)
        try {
            Restore-RecoverableItems -Identity $upn -SourceFolder $src -FilterItemType IPM.Note `
                -LastParentFolderID $inboxId -FilterStartTime $s -FilterEndTime $e -ResultSize 1000 -NoOutput -ErrorAction Stop
            Write-Host "$src $($s.ToString('yyyy-MM-dd')) restored"
        } catch {
            Write-Host "$src $($s.ToString('yyyy-MM-dd')) FAILED - $($_.Exception.Message)" -ForegroundColor Red
        }
        $s = $e
    }
}

Step 6: Verify.

Get-MailboxFolderStatistics $upn -FolderScope Inbox -IncludeOldestAndNewestItems |
    Select-Object FolderPath, ItemsInFolder, OldestItemReceivedDate -First 1
PowerShell 7 gotcha: If every call fails with "[System.Net.Http.HttpResponseMessage] does not contain a method named 'GetResponseHeader'", that message comes from the module's own error handler. It is hiding the real server error. Update the module (Install-Module ExchangeOnlineManagement -Force), check $Error[0].Exception | Format-List * -Force for the underlying message, and use smaller date windows. If it still fails, run the same commands in Windows PowerShell 5.1.

Option 3: Purview eDiscovery (last resort)

If neither option works, create an eDiscovery search scoped to the mailbox (it includes Recoverable Items), export the results to PST, and import the PST back. It's slower and heavier, but it doesn't depend on the restore cmdlets.

5. Choosing the right method

ScenarioBest method
A few emails, deleted recentlyThe user restores them in Outlook on the web
Thousands of emails, one folder, a known date rangeRestore-RecoverableItems with -LastParentFolderID and date windows
Restore into a separate folder for reviewRestore-RecoverableItems -RestoreTargetFolder "/Recovered" (Exchange Online only)
Past the retention period, no holdNot recoverable from Exchange. Check backups if your organization has a third-party backup.
Legal or compliance export neededPurview eDiscovery search and export

6. Prevent it from happening again

ActionWhy
Set RetainDeletedItemsFor to 30 daysDoubles the default safety window: Set-Mailbox user@contoso.com -RetainDeletedItemsFor 30
Review client-side rules and SweepRules created in desktop or mobile Outlook, and Sweep, may not appear in Get-InboxRule
Disable "Empty Deleted Items on exit" for heavy deletersStops instant emptying of Deleted Items
Route alert and notification email to a dedicated folder or shared mailboxUsers won't mass-delete from the folders where their real mail lives
Enable an Online Archive with a sensible "Move to Archive" tagKeeps the primary mailbox lean, so users don't feel the need to bulk delete
Use a retention policy for critical mailboxesDeleted items stay recoverable for as long as the policy requires

Key takeaways

  • Deleted mail goes Deleted Items → Recoverable Items\Deletions → Purges. With a hold, it stays recoverable much longer.
  • Always investigate before restoring. The audit log tells you who, when, from which folder, and with which app.
  • Restore selectively. Filter by the original folder and date, and preview before you restore.
  • Convert the base64 FolderId to the hex LastParentFolderID to target one folder precisely.
  • Query in small date windows to avoid server-side timeouts on large mailboxes.
  • Don't touch holds until the restore is complete.

References

Saturday, October 24, 2026

How to Enable and Manage the Copilot Icon in SharePoint Online: A Complete Admin Guide

Copilot in SharePoint shows up as an agent icon in the SharePoint global header. From there, users can ask questions, summarise content, and create pages, lists and files using natural language. This guide covers every control that decides whether the icon appears, what it opens, and who manages it, at the tenant, site and user levels.

The Control Layers at a Glance

LayerControlWho manages itToolEffect on the icon
UserMicrosoft 365 Copilot license (M365_COPILOT_SHAREPOINT service plan)License adminM365 admin center / Graph PowerShellNo license, no icon
TenantKnowledgeAgentScope + selected sites listSharePoint adminPowerShell onlyDecides which sites get Copilot (honoured until 1 Nov 2026)
SiteRestricted Content Discovery (RCD)SharePoint admin (can be delegated)SharePoint admin center / PowerShellRCD On = Copilot and AI actions hidden on that site
SiteSite AI settingsSite ownerSite → Settings → Site AIChooses the agent behind the icon and can hide Copilot for visitors

Rule of thumb: the icon appears only when every layer allows it. A user-level "yes" can't override a site-level "no".

Prerequisites

RequirementDetail
LicenseAn active Microsoft 365 Copilot license. Everyday Copilot in SharePoint capabilities are included, and users don't need a separate opt-in.
Admin roleSharePoint Administrator for tenant and RCD settings. Site owner for Site AI settings.
ModuleSharePoint Online Management Shell 16.0.26615.12013 or later
CloudNot currently supported in GCC, GCC High, DoD, air-gapped clouds, or Microsoft 365 operated by 21Vianet
Advanced work (optional)Usage-based billing with Copilot Credits, set up through a spending policy in the M365 admin center

Step 1 – Assign and Verify the License

Assign the Microsoft 365 Copilot license in the M365 admin center, then confirm the SharePoint service plan is active:

Connect-MgGraph -Scopes "User.Read.All" -NoWelcome
$upn = "user@contoso.com"
Get-MgUserLicenseDetail -UserId $upn | ForEach-Object {
    $_.ServicePlans | Where-Object { $_.ServicePlanId -eq "0aedf20c-091d-420b-aadf-30c042609612" }
} | Select-Object ServicePlanName, ProvisioningStatus

Expected result: M365_COPILOT_SHAREPOINT → Success.

Step 2 – Enable Copilot at the Tenant Level (PowerShell)

There's no admin-center UI for this control. Connect from Windows PowerShell 5.1:

Connect-SPOService -Url https://contoso-admin.sharepoint.com
Get-SPOTenant | Select-Object KnowledgeAgentScope, KnowledgeAgentSelectedSitesList
Scope valueMeaningBest for
NoSites (default)Off on every siteTenants not ready to roll out
IncludeSelectedSitesOn only for listed sites (allow-list)Pilots and phased rollouts
ExcludeSelectedSitesOn everywhere except listed sites (block-list)Broad rollout with a few sensitive sites held back
AllSitesOn everywhereFull rollout

Option A – Enable on All Sites

Set-SPOTenant -KnowledgeAgentScope AllSites
Get-SPOTenant | Select-Object KnowledgeAgentScope

Option B – Pilot on Selected Sites (Allow-List)

Set-SPOTenant -KnowledgeAgentScope IncludeSelectedSites

# Initial list
Set-SPOTenant -KnowledgeAgentSelectedSitesList @(
    "https://contoso.sharepoint.com/sites/Engineering",
    "https://contoso.sharepoint.com/sites/HRPortal"
)

# Add more sites later - always use Append
Set-SPOTenant -KnowledgeAgentSelectedSitesList @("https://contoso.sharepoint.com/sites/Marketing") `
              -KnowledgeAgentSelectedSitesListOperation Append

Option C – Enable Everywhere Except Sensitive Sites (Block-List)

Set-SPOTenant -KnowledgeAgentScope ExcludeSelectedSites
Set-SPOTenant -KnowledgeAgentSelectedSitesList @("https://contoso.sharepoint.com/sites/Finance")

Turn It Off for the Whole Tenant

Set-SPOTenant -KnowledgeAgentScope NoSites

Managing the Sites List Safely

OperationEffect
Overwrite (default)Replaces the entire list
AppendAdds sites to the existing list
RemoveRemoves sites from the existing list
Gotchas:
  • Because the default operation is Overwrite, running Set-SPOTenant -KnowledgeAgentSelectedSitesList "url" without Append wipes every other site from the list.
  • The list can't hold more than 100 site URLs.
  • The list parameter only works when the scope is IncludeSelectedSites or ExcludeSelectedSites.
  • In multi-geo tenants, run the commands in each geo.
# Always back up before changing the list
$list = (Get-SPOTenant).KnowledgeAgentSelectedSitesList
$list | Out-File .\KnowledgeAgentSites_backup.txt
$list.Count
$list -contains "https://contoso.sharepoint.com/sites/Engineering"

Step 3 – Control a Single Site with Restricted Content Discovery

RCD is a site-level switch. When it's On, Copilot in SharePoint and AI actions don't appear on the site, whatever the other settings are. Site permissions stay the same.

UI: SharePoint admin center → Sites → Active sites → select the site → Settings tab → Restrict content discovery → On/Off → Save.

# Check
Get-SPOSite -Identity https://contoso.sharepoint.com/sites/Finance | Select-Object RestrictContentOrgWideSearch

# Hide Copilot on the site
Set-SPOSite -Identity https://contoso.sharepoint.com/sites/Finance -RestrictContentOrgWideSearch $true

# Show Copilot again
Set-SPOSite -Identity https://contoso.sharepoint.com/sites/Finance -RestrictContentOrgWideSearch $false

Tips:

  • For many sites at once, use Reports → Catalog management → Actions to apply RCD to catalog groups in bulk.
  • To let site admins manage RCD themselves, run Set-SPOTenant -DelegateRestrictedContentDiscoverabilityManagement $true. They'll need to give a justification for each change.
  • Changes take time to propagate, especially on large sites.

Step 4 – Manage the Icon as a Site Owner (Site AI Settings)

Go to the site → Settings (gear) → Site AI.

SettingWhat it doesWhen to use it
Main agentChooses what opens from the header icon: Copilot in SharePoint or a custom site agentPoint visitors to a curated agent, such as a training or policy assistant
Hide Copilot for visitorsHides the Copilot button on site pages for visitors only. Owners and members still see it.Read-only portals and intranet landing pages
Advanced document processing / autofillShares Copilot Credits with the site for advanced autofill columns and faster processingLibraries that need metadata extraction at scale

Tip: if you choose a custom agent, keep its .agent file in a stable library and make sure visitors can read it. If the file is moved or deleted, the header icon has nothing to open.

Step 5 – Verify with a Read-Only Script

param(
    [Parameter(Mandatory)] [string]$AdminUrl,
    [Parameter(Mandatory)] [string]$SiteUrl
)

$SiteUrl = $SiteUrl.TrimEnd('/')
Connect-SPOService -Url $AdminUrl
$tenant = Get-SPOTenant
$site   = Get-SPOSite -Identity $SiteUrl
$inList = ($tenant.KnowledgeAgentSelectedSitesList | ForEach-Object { $_.TrimEnd('/') }) -contains $SiteUrl

switch ($tenant.KnowledgeAgentScope) {
    "AllSites"             { $scopeOk = $true }
    "IncludeSelectedSites" { $scopeOk = $inList }
    "ExcludeSelectedSites" { $scopeOk = -not $inList }
    default                { $scopeOk = $false }
}

if ($scopeOk -and -not $site.RestrictContentOrgWideSearch) {
    $status = "Copilot icon enabled for licensed users"
} else {
    $status = "Copilot icon hidden - review tenant scope or RCD"
}

[pscustomobject]@{
    TenantScope              = $tenant.KnowledgeAgentScope
    SiteInSelectedList       = $inList
    RestrictContentDiscovery = $site.RestrictContentOrgWideSearch
    Status                   = $status
} | Format-List

PowerShell Connection Tips

SymptomFix
NativeInterop.API threw an exception / No valid OAuth 2.0 authentication session existsRun the module in Windows PowerShell 5.1, opened from the Start menu, not from inside PowerShell 7
Module installed but "no valid module file was found"Use Save-Module -Path C:\PSMods, then import the .psd1 by its full path
A parameter cannot be found that matches 'KnowledgeAgentScope'Remove old module versions and install the latest

Important: What Changes After 1 November 2026

With Copilot in SharePoint now generally available, Microsoft is retiring the preview-era PowerShell controls (KnowledgeAgentScope, KnowledgeAgentSelectedSitesList, KnowledgeAgentSelectedSitesListOperation).

PeriodHow availability is managed
Until 1 Nov 2026Tenant PowerShell scope + RCD + Site AI settings
After 1 Nov 2026Site AI settings + Restricted Content Discovery only

Migration checklist:

  1. Export your current scope and sites list.
  2. If you use an allow-list, find the sites outside it that must stay hidden, and apply RCD to them before the cut-off.
  3. If you use a block-list, apply RCD to every site on it.
  4. Brief site owners on the Site AI panel, since they now control the header agent.
  5. Review Copilot Credits spending policies if you plan to use advanced work.

Recommended Management Model

GoalUse
Roll Copilot out to everyoneLicense + leave RCD Off
Hide Copilot on sensitive sitesRCD On (bulk through Catalog management)
Keep a portal clean for visitorsSite AI → hide Copilot for visitors
Show a curated assistantSite AI → Main agent = custom agent
Allow large-scale AI workSpending policy with Copilot Credits

References

Disclaimer: This article is for educational purposes. All tenant names, URLs and accounts are illustrative (contoso). Test changes in a non-production tenant and follow your organisation's change process.

Featured Post

Build a Secure MCP Server on Azure Functions for Copilot Studio Agents (Entra ID OAuth)

Model Context Protocol (MCP) is quickly becoming the standard way to give AI agents real tools. Copilot Studio can call any remote MCP serve...

Popular posts