Guides · 4 min read · October 27, 2026
An MCP server on Azure, protected with Entra ID
The Model Context Protocol (MCP) lets one server offer tools to many AI clients: VS Code, Claude, an agent framework, a managed agent. On your laptop it's easy: start the server, point a client at localhost, done. Put it on the internet and everything changes. Anyone who finds the address can call your tools, so the server must know who's asking.
This guide takes a Python MCP server to Azure Container Apps and protects it with Microsoft Entra ID. Every output below was captured from a real deployment in October 2026, in the agents package linked at the end.
What "protected" means for an MCP server
An MCP server on the internet needs three things. It must check a token on every request. It must tell clients where to get that token. And it must log who called which tool. The MCP specification calls the second part protected resource metadata: a small document at a well-known address that names the sign-in service and the scope to ask for.
It works like a door with a sign beside it: "Badge required. Badges from the front desk, ask for the tools pass." A client that's never seen your server reads the sign, signs the person in, and comes back with the right badge.
Step 1: register the server in Entra ID
The server needs an app registration, so tokens can be issued for it:
- an application ID URI, such as
api://<client-id>, which becomes the token's audience; - a delegated scope,
user_impersonation, for people signing in through a client; - an app role, such as
Tools.Read, given only to the people and apps allowed in.
Set "assignment required" on its enterprise app, so a token is only issued to someone with the role. Then check the role in your code too. A valid token from your tenant isn't enough on its own.
Step 2: check the token on every request
With the official mcp Python SDK (2.x), the server takes a token verifier and auth settings. The verifier checks the signature, the issuer, the audience, the expiry, and the role:
verifier = EntraTokenVerifier(tenant_id, client_id) # your class: signature, issuer, audience, expiry, role
mcp = MCPServer(
"fieldnotes",
token_verifier=verifier,
auth=AuthSettings(
issuer_url=f"https://login.microsoftonline.com/{tenant_id}/v2.0",
resource_server_url=f"https://{public_host}/mcp",
required_scopes=[f"api://{client_id}/user_impersonation"], # what clients are told to ask for
validate_token_resource=False, # Entra puts the client ID in aud: the verifier checks that
),
)
Two Entra details catch everyone:
- v2 tokens put your client ID in
aud, not theapi://URI. Check for the client ID. - People and apps look different. A person signed in through a client sends
scp: user_impersonation. An app with a managed identity sendsroles: ["Tools.Read"]and no scope. Accept both, deliberately.
Step 3: deploy, and fix the host check
On Container Apps, the server answered every request with an error until one setting changed. The SDK's default protection against DNS rebinding allows only localhost as the host. Behind Azure's ingress, the host is your app's public name, so list it explicitly in the transport security settings. It's a good default with a surprising failure mode.
What a client sees
An unsigned request gets a 401 that says where to read the rules:
HTTP/2 401
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="https://fieldnotes-mcp.<environment>.eastus2.azurecontainerapps.io/.well-known/oauth-protected-resource/mcp"
The metadata names Entra ID and the scope:
{"resource":"https://fieldnotes-mcp.<environment>.eastus2.azurecontainerapps.io/mcp",
"authorization_servers":["https://login.microsoftonline.com/<tenant-id>/v2.0"],
"scopes_supported":["api://<client-id>/user_impersonation"],
"bearer_methods_supported":["header"]}
A token from the Azure CLI, which Entra lets you pre-authorize for your server, carries what the server checks:
{'aud': '<client-id>', 'scp': 'user_impersonation', 'roles': ['Tools.Read'], 'ver': '2.0'}
With it, the SDK client connects and calls a tool. A token for a different API, Azure Resource Manager in our test, is refused and logged as token refused: Invalid issuer.
Errors you'll hit
AADSTS500011, resource not found. A client asked for a token with the wrong scope form. Apps usingDefaultAzureCredentialneedapi://<client-id>/.default.- Every request fails behind Azure, but works locally. The host check from Step 3.
- The first call after a quiet spell times out. With scale to zero, the app had to start: 18 seconds in our run. Give callers a timeout longer than your measured cold start.
- Terraform fails at plan with
for_eachover unknown values. Key role assignments by fixed names, not by principal IDs that only exist after apply.
Let other agents in, with their own identity
A Foundry Agent Service agent can call the same server through a project connection that signs in as the project's managed identity. Give that identity the app role, and your audit log shows exactly which caller used which tool: you, VS Code, or the managed agent.
The package below builds all of this. You write the server, deploy it, sign it in from VS Code and from an agent, then add evaluation, monitoring and injection defences on top.