This lab is designed as a story in 4 Acts. You will start by deploying a simple MCP server in the cloud and evolve your architecture until you reach a complex enterprise scenario with identity validation and the On-Behalf-Of (OBO) flow.
By the end of this journey, you will have a secure architecture where an AI Agent acts on behalf of the logged-in user to access sensitive data (Microsoft Graph), protected by an MCP Gateway.
You are a Platform Engineer tasked with making AI tools available to the company's developers.
- Act 1: Prove the concept works (Deploy).
- Act 2: Govern access to these tools (APIM MCP Gateway).
- Act 3: Secure the perimeter (Identity).
- Act 4: Ensure the AI accesses sensitive data using the user's own identity (OBO Flow).
- Azure CLI installed and logged in (
az login). - Azure Functions Core Tools (
func). - Python 3.10+.
- Visual Studio Code with the GitHub Copilot extension (or a compatible MCP client).
To ensure resource names are unique and reproducible, we will use a setup script.
-
Navigate to the lab folder:
cd mcp-lab -
Configure environment variables (run in your Bash/WSL terminal):
source env-setup.shThis command generates a random suffix and sets variables like
$GRP,$FUNC,$APIM,$AZURE_CLI_APP_IDetc. Keep this terminal open throughout the lab.
In this phase, your goal is simple: get an MCP server running in the cloud (Azure Functions) and publicly accessible.
# Create Resource Group
az group create --name $GRP --location $LOC
# Create Storage Account (Required for Functions)
az storage account create --name $STG --location $LOC --resource-group $GRP --sku Standard_LRS
# Create the Function App (Serverless Linux)
az functionapp create --name $FUNC --storage-account $STG --consumption-plan-location $LOC --resource-group $GRP --runtime python --runtime-version 3.11 --functions-version 4 --os-type LinuxThe current code has a simple tool (echo_message) and a secure tool (get_my_profile_info). Let's publish it.
# Publish the Function App
func azure functionapp publish $FUNCLet's validate if the MCP server is alive by testing the simple tool.
-
In VS Code, edit the
.vscode/mcp.jsonfile (create it at the root of the projectlab-apim-mcpif it doesn't exist):{ "mcpServers": { "azure-func-direct": { "url": "https://<YOUR_FUNCTION_NAME>.azurewebsites.net/mcp", "type": "http" } } }(Replace
<YOUR_FUNCTION_NAME>with the value of$FUNC. Runecho $FUNCin the terminal to see it). -
Restart VS Code or Reload the Window.
-
Open Copilot Chat and type:
@azure-func-direct echo_message message="Hello Azure Function"
Success: If Copilot responds "Echo from Azure: Hello Azure Function", Act 1 is complete.
Accessing the Function directly is insecure and hard to manage. Let's put Azure API Management (APIM) in front to act as an Intelligent MCP Gateway.
Note: The "Developer" tier takes about 30-45 minutes to create. Good time for a coffee.
az apim create --name $APIM --resource-group $GRP --location $LOC --publisher-name "Lab Admin" --publisher-email "admin@lab.com" --sku-name DeveloperAPIM has native support for MCP servers (GenAI Gateway). We will do this via the Portal.
-
Go to the Azure Portal > API Management (
$APIM). -
In the left menu, select MCP Servers.
-
Click + Create > Expose an existing MCP server.
-
Fill in the form fields as follows:
Backend MCP server
- MCP server base url:
https://<YOUR_FUNCTION_NAME>.azurewebsites.net/mcp(Replace<YOUR_FUNCTION_NAME>with$FUNC.
New MCP server
- Display name:
MCP Lab Gateway - Name:
mcp-lab - Base path:
lab - Description:
Azure Function acting as MCP Server
- MCP server base url:
-
Click Create.
Note: This setup uses a public backend endpoint for simplicity. In production, always isolate your Function App using Private Endpoints or VNet restrictions to prevent direct access.
Now let's point VS Code to APIM.
- Update
.vscode/mcp.json:{ "mcpServers": { "azure-apim-gw": { "url": "https://<YOUR_APIM_NAME>.azure-api.net/lab/mcp", "type": "http" } } } - Restart VS Code.
- Test again:
@azure-apim-gw echo_message message="Hello via APIM"
Success: The flow is now: VS Code -> APIM -> Function.
Now let's lock the door. No one should call the APIM without a valid badge (JWT Token).
We need to register our API in Microsoft Entra ID.
# Create App Registration
BACKEND_APP_ID=$(az ad app create --display-name "mcp-lab-backend-$RND" --sign-in-audience AzureADMyOrg --query appId -o tsv)
# Create Service Principal
az ad sp create --id $BACKEND_APP_ID
# Define API URI (api://<client_id>)
az ad app update --id $BACKEND_APP_ID --identifier-uris "api://$BACKEND_APP_ID"
# Expose "MCP.Execute" scope (Using Python for safe JSON generation)
python3 -c "import sys, uuid, json; scope_id = str(uuid.uuid4()); print(scope_id)" > scope_id.txt
SCOPE_ID=$(cat scope_id.txt) && rm scope_id.txt
python3 -c "import sys, json; scope_id = sys.argv[1]; data = {'oauth2PermissionScopes': [{'adminConsentDescription': 'Access MCP', 'adminConsentDisplayName': 'Access MCP', 'id': scope_id, 'isEnabled': True, 'type': 'User', 'userConsentDescription': 'Access MCP', 'userConsentDisplayName': 'Access MCP', 'value': 'MCP.Execute'}]}; print(json.dumps(data))" "$SCOPE_ID" > scope.json
az ad app update --id $BACKEND_APP_ID --set api=@scope.json
rm scope.json
echo "Backend App ID: $BACKEND_APP_ID"Let's configure APIM to reject calls without a token.
- In the Portal, go to MCP Servers > Click on MCP Lab Gateway.
- Look for the Policies option (or the
</>icon in Inbound processing). - Insert the validation policy in the
<inbound>block:
<inbound>
<base />
<!-- Allow CORS (No credentials/wildcard compatible) -->
<cors>
<allowed-origins><origin>*</origin></allowed-origins>
<allowed-methods><method>*</method></allowed-methods>
<allowed-headers><header>*</header></allowed-headers>
</cors>
<!-- Validate Entra ID Token -->
<validate-azure-ad-token tenant-id="{{YOUR_TENANT_ID}}" header-name="Authorization">
<audiences>
<audience>api://{{YOUR_BACKEND_APP_ID}}</audience>
</audiences>
</validate-azure-ad-token>
</inbound>(Replace {{YOUR_TENANT_ID}} and {{YOUR_BACKEND_APP_ID}} with real values).
If you don't know your Tenant ID, run az account show to retrieve it.
Try using @azure-apim-gw echo_message again in VS Code.
Expected Result: You will receive an HTTP 401 Unauthorized error immediately. This means the APIM policy is working!
The "Grand Finale". We will make VS Code send a token, APIM validate it, and the Function exchange this token for another to read your Profile.
The Function needs permission to "speak on behalf of the user" (OBO).
# 1. Generate Secret for the App
BACKEND_SECRET=$(az ad app credential reset --id $BACKEND_APP_ID --display-name "OBOSecret" --query password -o tsv)
# 2. Grant Graph permission (User.Read) using Global IDs
MS_GRAPH_ID="00000003-0000-0000-c000-000000000000" # Microsoft Graph API
USER_READ_ID="e1fe6dd8-ba31-4d61-89e7-88639da4683d" # User.Read Scope
az ad app permission add --id $BACKEND_APP_ID --api $MS_GRAPH_ID --api-permissions $USER_READ_ID=Scope
az ad app permission grant --id $BACKEND_APP_ID --api $MS_GRAPH_ID --scope User.ReadSend the credentials to the Function in the cloud.
az functionapp config appsettings set --name $FUNC --resource-group $GRP --settings "AZURE_TENANT_ID=$(az account show --query tenantId -o tsv)" "BACKEND_CLIENT_ID=$BACKEND_APP_ID" "BACKEND_CLIENT_SECRET=$BACKEND_SECRET"We need a real token.
-
Pre-authorization: Allow Azure CLI to request tokens for your API. We use
az restto be robust against CLI schema changes.# 1. Get Object ID BACKEND_OBJ_ID=$(az ad app show --id $BACKEND_APP_ID --query id -o tsv) # 2. Generate JSON Config (Python one-liner for safe copy-paste) python3 -c "import sys, json; scope_id = sys.argv[1]; cli_id = sys.argv[2]; data = {'api': {'oauth2PermissionScopes': [{'adminConsentDescription': 'Access MCP', 'adminConsentDisplayName': 'Access MCP', 'id': scope_id, 'isEnabled': True, 'type': 'User', 'userConsentDescription': 'Access MCP', 'userConsentDisplayName': 'Access MCP', 'value': 'MCP.Execute'}], 'preAuthorizedApplications': [{'appId': cli_id, 'delegatedPermissionIds': [scope_id]}]}}; print(json.dumps(data))" "$SCOPE_ID" "$AZURE_CLI_APP_ID" > patch.json # 3. Apply via Graph API az rest --method PATCH --uri "https://graph.microsoft.com/v1.0/applications/$BACKEND_OBJ_ID" --headers "Content-Type=application/json" --body @patch.json rm patch.json
-
Generate Token:
# Login (Refreshes session) az login --tenant $(az account show --query tenantId -o tsv) # Get Token az account get-access-token --resource "api://$BACKEND_APP_ID" --query accessToken -o tsv
Copy the generated token.
-
Configure MCP Client: Update
.vscode/mcp.jsonto include your token directly. (Note: We paste the token here because some VS Code extensions do not yet support interactive input prompts.){ "mcpServers": { "azure-obo-final": { "url": "https://<YOUR_APIM_NAME>.azure-api.net/lab/mcp", "type": "http", "headers": { "Authorization": "Bearer YOUR_LONG_TOKEN_HERE" } } } }
- Reload the VS Code window.
- Open Copilot Chat and call the secure tool:
@azure-obo-final get_my_profile_info
Expected Result: The tool should respond with "Success! OBO Flow worked", displaying your Name and Job Title retrieved securely from Microsoft Graph.
Congratulations! You have completed the journey.
Don't forget to delete all resources created during this lab to avoid ongoing costs.
az group delete --name $GRP --yes --no-wait