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
| Component | Purpose |
|---|---|
| Azure Function App (Flex Consumption, Python) | Hosts the remote MCP server using the Azure Functions MCP extension |
| 4 MCP tools | get_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 registration | Represents the MCP API and exposes the user_impersonation scope |
| Client app registration | Used by Copilot Studio to sign the user in (OAuth 2.0 authorization code flow) |
| Copilot Studio agent | Calls 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
| Requirement | Notes |
|---|---|
| Azure subscription | Owner or Contributor on a resource group |
| Entra ID permissions | Create app registrations; admin consent is optional but simplifies sign-in |
| Copilot Studio | A licence or trial; tools require it |
| Azure Cloud Shell (bash) | Used for deployment and app registration commands |
| Use a dev/test tenant | Recommended 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().
| Tool | What it does | Inputs |
|---|---|---|
| get_service_status | Returns health of IT services (email, VPN, HR portal, printing) | service_name (optional) |
| search_kb_articles | Keyword search over knowledge base articles | query (required) |
| create_support_ticket | Creates a simulated ticket and returns its number | title, description (required), priority (optional) |
| whoami | Returns the Entra identity of the caller, as validated by Easy Auth | none |
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
| Item | Why 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. |
MCPToolContext | Gives access to the transport, including HTTP headers forwarded by App Service authentication. |
X-MS-CLIENT-PRINCIPAL | Base64 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
| Setting | Value used |
|---|---|
| Resource group | rg-mcp-helpdesk-demo (new) |
| Function App name | func-mcp-helpdesk-demo01 |
| Region | Any supported region near you |
| Runtime stack / version | Python 3.13 |
| Instance size | 2048 MB (default) |
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.
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.
AI (preview) blade
Enter a name for the new app registration (for example mcp-helpdesk-server) and select Save.
Create the server app registration[Image_11]
MCP authentication is On
This single action does a lot for you:
| What it configures | Result |
|---|---|
| Entra app registration (server) | Exposes the scope api://<server-app-id>/user_impersonation |
| App Service authentication | Require authentication; unauthenticated requests get HTTP 401 |
| Key-based access | Turned off for the MCP endpoint |
| Federated credential via managed identity | No client secret stored on the server app |
| Protected resource metadata (PRM) | Published at /.well-known/oauth-protected-resource |
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.
| Field | Value |
|---|---|
| Server name | IT Helpdesk MCP |
| Server description | IT helpdesk tools: check IT service status, search knowledge base articles, create support tickets, and show the signed-in caller identity. |
| Server URL | https://<your-function-host>/runtime/webhooks/mcp |
| Authentication | OAuth 2.0 > Manual |
| Client ID | Client app ID |
| Client secret | Value from Step 5 |
| Authorization URL | https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize |
| Token URL template | https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token |
| Refresh URL | Same as the token URL |
| Scopes | api://<server-app-id>/user_impersonation offline_access |
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.
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.
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:
| Prompt | Tool called | Result |
|---|---|---|
| Is the VPN working right now? | get_service_status | VPN status: Degraded |
| I forgot my password and I'm locked out. Is there a KB article? | search_kb_articles | KB1001: Reset your password with SSPR |
| Please raise a high priority ticket: my laptop will not boot after a Windows update. | create_support_ticket | Ticket INC... created, priority high |
| Who am I signed in as on the helpdesk server? | whoami | Signed-in user and calling client app ID |
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
| Symptom | Cause | Fix |
|---|---|---|
| Zip deploy fails with BlobUploadFailedException (500) | Managed identity storage role not propagated yet | Wait about 2 minutes and redeploy |
defaultHostName is empty | Secure unique default hostname | Use az resource show ... properties.defaultHostName |
| HTTP 401 from the endpoint | Expected for calls without a token | Connect through OAuth; check PRM metadata |
| HTTP 403 or token rejected after sign-in | Client app not in Allowed client applications | Add the client app ID (Step 5) |
| AADSTS50011 redirect URI mismatch | Redirect URL not registered on the client app | Add the exact Copilot Studio redirect URL as a Web URI |
| AADSTS65001 consent required | No user or admin consent for the scope | Grant admin consent, or accept consent at sign-in |
| Agent says "No information was found" | Classic orchestration | Enable generative orchestration |
| Tools page asks to upgrade your licence | No Copilot Studio licence or trial | Start a trial or assign a licence |
Security best practices
| Practice | Why |
|---|---|
| Keep key-based access off when OAuth is on | Function keys are shared secrets and can leak |
| Allow only specific client applications | Stops other apps in the tenant from calling your tools |
| Use managed identity for storage and monitoring | No connection strings to rotate |
| Rotate client secrets, or use certificates | Limits the impact of a leaked secret |
| Never pass the user's token to downstream APIs | Use the On-Behalf-Of flow for Graph or other APIs |
| Govern MCP connectors with data policies | MCP servers in Copilot Studio are Power Platform connectors, so DLP policies apply |
| Use a dedicated app registration per MCP server | Do 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
| Step | Outcome |
|---|---|
| 1-3 | Python MCP server running on Azure Functions Flex Consumption |
| 4 | Endpoint protected by Entra ID; anonymous calls get 401 |
| 5 | Client app registration, consent, secret and allow-list |
| 6-7 | Copilot Studio agent connected with OAuth 2.0 and generative orchestration |
| 8 | All 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.





























