Skip to main content
Glama
ejorgensen22

Entra ID On-Behalf-Of MCP

by ejorgensen22

Entra ID On-Behalf-Of MCP (FastMCP)

Example Model Context Protocol server that shows how Microsoft Entra ID On-Behalf-Of (OBO) works.

The MCP client authenticates to this server. The server does not forward that token to Microsoft Graph. It uses MSAL acquire_token_on_behalf_of to mint a new token whose audience is Graph, then calls Graph as the signed-in user.

It also runs locally and on Amazon Bedrock AgentCore Runtime, deployable with AWS CDK.

User
  |
  |  device code / MCP OAuth
  v
Entra ID  -- access token A (aud = MCP app, scp = access_as_user)
  |
  v
MCP client (Claude, VS Code, client_demo.py)
  |  Authorization: Bearer A
  v
FastMCP server  (local or AgentCore Runtime)
  |  MSAL acquire_token_on_behalf_of
  |  POST /oauth2/v2.0/token
  |    grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
  |    requested_token_use=on_behalf_of
  |    assertion=A
  |    scope=https://graph.microsoft.com/User.Read
  v
Entra ID  -- access token B (aud = Graph, same user oid, azp = MCP app)
  |
  v
Microsoft Graph  /me  /me/messages

Entra OBO is not RFC 8693 Token Exchange. It is RFC 7523 jwt-bearer plus requested_token_use=on_behalf_of.

Tools

Tool

What it proves

whoami_mcp

Inbound JWT aud is the MCP app

whoami_graph

OBO minted a different token; Graph /me returns the same user; claim pair is validated

list_recent_mail

Second OBO with Mail.Read

explain_obo

Protocol, endpoint, and the rules that cause AADSTS50013 / 65001

Related MCP server: MCP Credentials Broker

1. Entra app registration

One confidential client is enough for this example. It plays two roles: resource (MCP audience) and client (the app that performs OBO).

  1. Entra admin center → App registrations → New registration

    • Name: entra-id-obo-mcp

    • Accounts in this organizational directory only

  2. Certificates & secrets → New client secret. Copy the value.

  3. Expose an API

    • Application ID URI: accept api://<application-client-id>

    • Add scope

      • Name: access_as_user

      • Who can consent: Admins and users

      • Admin consent display name / description: allow the MCP server to take actions as the signed-in user

  4. API permissions → Add a permission → Microsoft Graph → Delegated

    • User.Read

    • Mail.Read

    • Grant admin consent for the tenant (required if the MCP client cannot request those Graph scopes at login)

  5. Authentication

    • Platform: Mobile and desktop applications

    • Enable Allow public client flows (needed for the device-code demo client)

    • If you use AUTH_MODE=proxy, also add a Web redirect URI: http://127.0.0.1:8000/auth/callback

  6. Manifest → set "requestedAccessTokenVersion": 2 inside api → Save

  7. Copy Application (client) ID and Directory (tenant) ID

Optional but useful: under Expose an API → Authorized client applications, add any first-party client you control (VS Code, a custom SPA) so users skip a second consent prompt for access_as_user.

2. Run the server locally

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# edit .env
python server.py

Server listens at http://127.0.0.1:8000/mcp (streamable HTTP).

Auth modes

AUTH_MODE

When to use it

bearer (default)

You already have an Entra access token for api://<app-id>/access_as_user. Used by client_demo.py.

proxy

MCP hosts that speak the MCP OAuth spec and expect Dynamic Client Registration. FastMCP's AzureProvider proxies Entra.

agentcore

Amazon Bedrock AgentCore Runtime validates the Entra JWT at the edge. FastMCP does not attach a second verifier.

3. Call it with the demo client

In a second terminal, same .venv and .env:

python client_demo.py

Complete the device-code login in a browser. The client requests only the MCP scope. Graph access happens later, inside the server, via OBO.

You should see whoami_mcp report audience_matches_mcp_app: true, then whoami_graph report a Graph aud, the same oid, and claim_validation.ok: true.

MCP host config (bearer)

{
  "mcpServers": {
    "entra-obo": {
      "url": "http://127.0.0.1:8000/mcp",
      "headers": {
        "Authorization": "Bearer <entra-access-token-for-mcp-app>"
      }
    }
  }
}

Do not paste a Graph token into that header. Entra will reject the OBO exchange with AADSTS50013.

4. OBO claim validation

After MSAL returns the Graph token, EntraOboClient.validate_obo_pair checks:

Check

Expect

inbound idtyp

not app

inbound vs Graph oid

same user

inbound vs Graph tid

same tenant

Graph aud

https://graph.microsoft.com or Graph app id 00000003-0000-0000-c000-000000000000

Graph azp

this MCP app client id

Inbound signature / issuer / lifetime for AUTH_MODE=bearer is FastMCP AzureJWTVerifier. On AgentCore that job is the platform custom JWT authorizer. inspect_assertion is decode-only and is not the security gate.

5. Deploy to Amazon Bedrock AgentCore with AWS CDK

AgentCore hosts FastMCP when the container listens on 0.0.0.0:8000 and serves POST /mcp over stateless streamable HTTP. The image is linux/arm64.

Inbound auth is Entra v2 OIDC:

https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration

allowedAudience is the MCP app client id / api://<client-id>. The runtime allowlists Authorization so the same user token reaches MSAL OBO.

Prerequisites

  • AWS CDK v2 (npm i -g aws-cdk), Docker, AWS credentials

  • Entra app from section 1

  • A Secrets Manager secret whose string is the Entra client secret (plain string or JSON with client_secret)

aws secretsmanager create-secret \
  --name entra-obo-mcp/client-secret \
  --secret-string 'your-entra-client-secret'

Deploy

cd infrastructure/cdk
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cdk bootstrap   # first time in the account/region

cdk deploy EntraOboMcpStack \
  -c entra_tenant_id=<TENANT_ID> \
  -c entra_client_id=<CLIENT_ID> \
  -c entra_identifier_uri=api://<CLIENT_ID> \
  -c entra_secret_name=entra-obo-mcp/client-secret

Outputs include AgentRuntimeArn and InvokeUrl:

https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<url-encoded-arn>/invocations?qualifier=DEFAULT

Point client_demo.py at that URL by setting MCP_BASE_URL to the invoke URL without a trailing /mcp if the AgentCore data plane already maps /invocations to the container /mcp. If your client requires an explicit /mcp path, append it and test with MCP Inspector.

export MCP_BASE_URL='https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/<encoded-arn>/invocations?qualifier=DEFAULT'
export AUTH_MODE=bearer   # client still uses device code against Entra
python client_demo.py

The runtime itself must run with AUTH_MODE=agentcore (the Dockerfile and CDK stack set that).

Why the extra hop

  • Tokens are resource-bound. Graph will not accept a token whose aud is your MCP app.

  • Forwarding the user's raw token is a confused-deputy risk and breaks Conditional Access / token binding.

  • OBO keeps oid / sub as the user and records the MCP app as azp.

  • Consent for Graph lives on the MCP app registration, not on whatever host launched the agent.

  • AgentCore Identity validating the inbound JWT is not a substitute for Entra OBO.

Common errors

Symptom

Cause

AADSTS50013

Assertion aud is Graph or some other API. Get a token for api://<mcp-app>/access_as_user.

AADSTS65001 / interaction_required / IDW10502

No delegated grant for Graph on this app. Admin-consent User.Read / Mail.Read.

AADSTS70011

Mixed .default with named scopes in one OBO call.

Guest user gets a token for the wrong tenant

OBO used /common. This server uses the tid claim from the inbound token.

OBO on a daemon token

OBO is user-delegation only. App-only traffic should use client credentials.

No bearer token on AgentCore

Runtime did not allowlist Authorization.

Image pull / exec format error

Image was not built for linux/arm64.

Code map

  • server.py — FastMCP tools and auth wiring (bearer / proxy / agentcore)

  • obo.py — MSAL acquire_token_on_behalf_of, audience check, claim pair validation, Graph calls

  • client_demo.py — device-code login for the MCP audience, then tool calls

  • config.py — environment, including Secrets Manager secret ARN

  • Dockerfile — AgentCore contract (0.0.0.0:8000, AUTH_MODE=agentcore)

  • infrastructure/cdk — ARM64 image, IAM role, CfnRuntime with Entra custom JWT

The raw Entra form MSAL sends:

POST https://login.microsoftonline.com/{tid}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&client_id={mcp-app-id}
&client_secret=***
&assertion={inbound-mcp-access-token}
&scope=https://graph.microsoft.com/User.Read
&requested_token_use=on_behalf_of

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Implements the on-behalf-of flow with Entra ID to enable authorized calls to Microsoft Graph API via MCP tools.
    9
    -
  • A
    license
    A
    quality
    F
    maintenance
    Provides secure OAuth2-based credential management for MCP servers, allowing agents to obtain short-lived token references without exposing raw secrets.
    7
    3 npm
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients to fetch the signed-in Microsoft Entra user's profile from Microsoft Graph via the GET /me endpoint using the provided bearer token.
    Apache 2.0