This example protects an MCP server with access tokens issued by Microsoft Entra ID (formerly Azure AD). The MCP server is a pure OAuth resource server: it validates tokens, it does not take part in the login flow.
- JWT validation via
JwtTokenValidator::fromIssuer(): Entra's metadata and keys are discovered over https and cached - Audience bound to this server's app registration, so tokens for other APIs (e.g. Microsoft Graph) are rejected
- Every request needs the
mcp.accessscope, enforced with aScopePolicy - Protected Resource Metadata (RFC 9728) at
/.well-known/oauth-protected-resource/mcp - Tools read the caller's claims via
RequestContext::getAccessToken()
- Azure Portal > Entra ID > App registrations > New registration, name it
MCP Server, no redirect URI. - Copy Application (client) ID →
AZURE_CLIENT_IDand Directory (tenant) ID →AZURE_TENANT_ID. - Expose an API: set the Application ID URI to
api://<client-id>and add the scopemcp.access. - Manifest: set
"accessTokenAcceptedVersion": 2, so tokens carry the v2.0 issuer the server expects.
Create a second app registration for the client, add a redirect URI of the client's choice
(public client/native for desktop clients) and grant it the mcp.access permission of the MCP Server API.
Entra ID supports neither Dynamic Client Registration nor Client ID Metadata Documents, so the
MCP client has to be configured with this pre-registered client ID. Entra also omits
code_challenge_methods_supported from its metadata, which MCP clients following the
2026-07-28 specification treat as missing PKCE support and refuse. Serving such clients takes an
authorization server or gateway in front of Entra; that is a deployment concern, not one of this
SDK (see adr/0002-resource-server-only.md).
- Configure:
cp env.example .env
# set AZURE_TENANT_ID and AZURE_CLIENT_ID- Start the services:
docker compose up -d- Get an access token for the exposed API, e.g. with the Azure CLI (pre-authorize the
Azure CLI client
04b07795-8ddb-461a-bbee-02f9e1bf7b46on themcp.accessscope first):
TOKEN=$(az account get-access-token --scope api://your-client-id/mcp.access --query accessToken -o tsv)- Test the MCP server:
# Protected Resource Metadata
curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
# Without token: 401 with a WWW-Authenticate challenge
curl -i http://localhost:8000/mcp
# With token
curl -X POST http://localhost:8000/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'docker-compose.yml- Docker Compose configurationDockerfile- PHP-FPM containernginx/default.conf- Nginx configurationenv.example- Environment variables templateserver.php- MCP server with the authorization middlewareMcpElements.php- MCP tools reading the caller's claims
| Variable | Required | Description |
|---|---|---|
AZURE_TENANT_ID |
Yes | Entra ID tenant ID |
AZURE_CLIENT_ID |
Yes | Application (client) ID of the MCP server app registration |
The token carries the v1.0 issuer https://sts.windows.net/{tenant}/. Set
"accessTokenAcceptedVersion": 2 in the manifest of the MCP Server app registration.
The token was issued for another API, e.g. Microsoft Graph (openid/profile scopes alone yield
Graph tokens). Request the api://<client-id>/mcp.access scope instead.
The token lacks mcp.access in its scp claim; grant the client the API permission.
The challenge names the scope as the token carries it, scope="mcp.access", without the
api://<client-id>/ prefix. Entra resolves an unprefixed scope against Microsoft Graph, so a client
cannot step up with it as is; request api://<client-id>/mcp.access, as advertised in the 401
challenge and the Protected Resource Metadata.
The token this server receives is for this server only and must not be passed on. To call Graph
on behalf of the user, exchange it with the On-Behalf-Of flow, which needs a client credential
for the MCP Server app registration.
docker compose down -v