The meeting-summary agent#
Build a meeting-summary agent — it discovers your recent meetings, you pick the ones that matter, and it drafts the minutes: decisions, action items, owners. To do any of that it has to read your calendar, your meetings, and their transcripts, through a Microsoft Graph MCP server that exposes those reads as tools. And it has to act as you — seeing exactly the meetings and transcripts you can see, and nothing else. Get the identity wrong and the agent becomes an authorization-bypass path: it summarizes a meeting the person asking was never in.
The tempting shortcut is to take the user’s token and relay it — client to agent, agent to the Graph MCP server, MCP server straight to Graph. It even seems to work in a demo. It is also the single most-exploited flaw in the MCP ecosystem. A 2026 measurement study crawled thousands of remote MCP servers and found that every one of the 119 testable OAuth-enabled servers had at least one authentication flaw — 325 in total (Fudan/CSU, arXiv). Obsidian Security disclosed one-click account-takeovers in production MCP servers rooted in exactly this class of mistake (Obsidian Security). This is not a hardening detail. It is the design.
Bottom line: an agent reaching a user’s data through an MCP server must exchange the user’s token at each trust boundary, not forward it. The mechanism is on-behalf-of (OBO); the guardrails are Conditional Access and RBAC. This piece walks the full chain — client → agent → MCP server → Microsoft Graph — and shows where each token is minted, validated, and scoped.
Why you cannot forward the token#
A Microsoft Entra access token is issued for exactly one audience. A token whose aud is your MCP server is not valid at Microsoft Graph, and a token whose aud is Graph is not valid at your MCP server. That is not an inconvenience to route around — it is the security boundary. Microsoft’s OBO documentation states the rule bluntly: a token’s audience must match the app redeeming it, and “applications can’t redeem a token for a different app… It should instead reject the token” (OBO flow). The MCP authorization spec enforces the same with RFC 8707 resource indicators — every token must be audience-bound to the MCP server, and “MCP servers MUST NOT accept or transit any other tokens” (MCP spec, 2025-11-25).
Forwarding a token across that boundary is the token-passthrough anti-pattern, and the MCP spec forbids it in one sentence that also tells you the fix:
“If the MCP server makes requests to upstream APIs, it may act as an OAuth client to them. The access token used at the upstream API is a separate token, issued by the upstream authorization server. The MCP server MUST NOT pass through the token it received from the MCP client.” — MCP Authorization spec
Microsoft’s own implementation enforces exactly this. Azure Foundry’s Agent Service “restricts tokens scoped to a known Microsoft audience from being sent to custom or third-party MCP servers,” returning the error Cannot pass Microsoft token to untrusted MCP endpoint, and instructs you to “use custom OAuth with your own Microsoft Entra app registration” (Foundry MCP auth). The protocol and the platform say the same thing.
Why it matters — the confused deputy. When a downstream API trusts a forwarded token, it trusts a credential that was never issued for it, and it can no longer tell who is really calling. The MCP spec enumerates the damage: security controls that depend on the audience are bypassed; the audit trail breaks because the downstream sees the MCP server’s requests as if they came from a different identity; a token accepted by multiple services turns one compromise into many; and “a malicious actor in possession of a stolen token can use the server as a proxy for data exfiltration” (MCP security best practices).
confused deputy — trusts a
token not issued for it]:::ext end subgraph RIGHT[Right — on-behalf-of per audience] direction LR M2[MCP server
confidential OAuth client]:::ok -->|OBO: mints a NEW
Graph-audience token| G2[Microsoft Graph
enforces per-user authZ]:::ext end
The wrong way relays one token across audiences and produces a confused deputy. The right way treats the MCP server as a confidential client that mints a fresh, Graph-audience token for the user.
The fix: on-behalf-of at the MCP server#
The moment an MCP server calls a downstream API, it stops being only a resource server and becomes a confidential OAuth client to that API — the same role any backend web app plays when it calls Graph. It holds a token that was issued for it (audience = the MCP server), and it exchanges that token for a new token scoped to Graph, still representing the user. That exchange is the on-behalf-of flow.
Mechanically, the MCP server posts to the Entra token endpoint presenting the user’s token as the assertion and asking for downstream scopes:
POST /oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com/{tenant}
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&client_id={mcp-server-app-id}
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion={federated-credential-from-managed-identity}
&assertion={user-token-with-aud=mcp-server}
&scope=https://graph.microsoft.com/Calendars.Read
&requested_token_use=on_behalf_ofTwo properties of the resulting token are the whole point. First, its audience is now Graph, so it is valid there and nowhere else. Second — and this is the guarantee that makes the pattern safe — OBO carries only delegated scopes, never application roles. Microsoft’s docs: “Roles remain attached to the principal (the user) and never to the application operating on the user’s behalf. This occurs to prevent the user gaining permission to resources they shouldn’t have access to” (OBO flow). So even if the MCP server’s app registration separately holds broad application permissions for some autonomous job, none of them leak into an OBO token. The Graph call is bounded to the intersection of what the app was granted and what the user can already do — the app “can’t access anything the signed-in user couldn’t access” (Graph permissions).
The naming caveat, stated honestly. The general OAuth mechanism for “trade this token for a different-audience token” is RFC 8693 Token Exchange, with
act/may_actclaims for delegation. Microsoft’s OBO is an Entra-specific take on that idea: it usesgrant_type=jwt-bearer(RFC 7523), not the RFC 8693 token-exchange grant, and Entra tokens express the actor withazp/xms_par_app_azprather than a literalactclaim. Same intent — subject token in, new audience-bound token out, delegation preserved — different vocabulary on the wire.
The end-to-end flow#
Here is the chain the minutes agent travels, with the token and audience at every hop. The client signs the user in and requests a token for the Graph MCP server (audience = MCP server); the agent relays it; the MCP server validates that audience and, for each Graph read, performs OBO to mint a fresh Graph-audience token. The agent runs in two phases — discover the user’s meetings, then, once the user has chosen, read each transcript — and each phase is its own OBO exchange.
resource = Graph MCP server E-->>C: User token Tc (aud = MCP server) C->>A: prompt + Tc Note over A,G: Phase 1 — discover past meetings A->>M: tool: list_meetings + Tc M->>M: validate Tc audience == this MCP server M->>E: OBO (jwt-bearer) · assertion = Tc
scope = Calendars.Read E-->>M: Graph token Tg1 (aud = Graph · sub = user) M->>G: GET /me/calendarView (past range) + Tg1 G-->>M: only the user's own meetings M-->>A: meeting list A-->>U: pick the meetings to summarize U->>C: selects meetings Note over A,G: Phase 2 — read transcript, draft minutes C->>A: selection + Tc A->>M: tool: get_transcript(id) + Tc M->>E: OBO · scope = OnlineMeetingTranscript.Read.All E-->>M: Graph token Tg2 (aud = Graph · sub = user) M->>G: GET /me/onlineMeetings/{id}/transcripts/{id}/content + Tg2 G-->>M: the transcript (VTT) M-->>A: transcript A-->>U: drafted minutes of meeting
Two phases, two OBO exchanges, two freshly-minted Graph tokens — one scoped to Calendars.Read to discover meetings, one to OnlineMeetingTranscript.Read.All to read the chosen transcript. The user is the subject at every step, so /me/calendarView and /me/onlineMeetings/{id}/transcripts resolve to the user’s own data — and the sign-in token never leaves the MCP boundary to reach Graph.
Where the exchange happens — and where it doesn’t. The exchange lives at the MCP server because that is the tier holding a user-delegated token issued for itself. The agent, in this topology, relays a token whose audience is already the MCP server — that is not passthrough, because the MCP server is the intended audience. What the MCP server must never do is forward that token onward to Graph. If your architecture puts a protected API in front of the agent too, the same audience-per-hop rule applies at that boundary; each tier that is a resource obtains its own audience-bound token and exchanges rather than forwards. Microsoft documents the two-tier case (API A → API B) and, for agents specifically, a two-step variant — the Entra Agent ID OBO flow, where the agent identity blueprint first exchanges its managed-identity federated credential for an exchange token, then performs the OBO exchange combining that with the user’s token. There is no first-party guide for OBO across three or more conventional middle tiers, so treat deeper chains as an extrapolation, not a documented pattern.
Context survives because the user stays the subject. Throughout the chain, the token’s subject is the user; the agent and MCP server appear as the actor (azp/appid, with xms_par_app_azp naming the parent blueprint for agent identities) (agent token claims). That is what lets Graph evaluate /me/events against the user’s own calendar rather than the agent’s identity — and what lets your audit trail answer “which agent acted for which human.”
Conditional Access on the chain#
Conditional Access behaves differently depending on which pattern the agent is in, and this trips people up.
- On-behalf-of / attended — the user is the token’s subject, so “Conditional Access policies target users and groups, not agent identities” for this flow, and the OBO exchange itself is CA-evaluated (CA for agents). The MFA requirement sits on the user, satisfied during the original interactive sign-in — not on the agent.
- Autonomous / app-only — the token is issued to the agent identity, so CA is scoped to the agent identity, targeted through the dedicated Agents assignment surface in the portal.
This matters because agents cannot satisfy interactive controls. A blanket “all users must MFA” policy that reaches into an agent’s own token exchange will simply break it; Microsoft’s guidance is to write agent-specific policies keyed on identity filters, risk, and named locations, and to “create separate policies” for agents (best practices). In practice, Conditional Access for agents today is close to allow/block only — no MFA, no device compliance, no session conditions — a deliberate v1 that practitioners describe as workable but coarse (Maxime Hiez).
The claims-challenge bounce. When a downstream Conditional Access policy demands a step-up the current token can’t satisfy, the OBO exchange fails with a claims challenge rather than a flat denial:
{
"error": "interaction_required",
"error_description": "AADSTS50079: ... you must enroll in multifactor authentication ...",
"claims": "{\"access_token\":{\"polids\":{\"essential\":true,\"values\":[\"<policy-id>\"]}}}"
}The middle tier must reply 401 with a WWW-Authenticate header carrying that challenge; the client parses it, re-acquires an interactive token that satisfies the policy, and resubmits — the agent and MCP server cannot answer the challenge themselves, only the human at the front door can (OBO error handling, claims challenges). For an app to receive these challenges at all it must declare the client capability xms_cc=cp1. One more nuance worth knowing for incident response: in OBO flows, Identity Protection attributes risky activity to the user, not the agent, so remediation targets the compromised session without disabling the agent for everyone else (risky agents).
RBAC and least privilege on the MCP principal#
The MCP server’s own permissions decide the ceiling of what any OBO token can carry, so keep them minimal and delegated. For the minutes agent — discover the user’s meetings, then read the transcripts of the ones they pick — the delegated scopes are:
| Capability | Delegated scope | Endpoint | Admin consent |
|---|---|---|---|
| Discover past meetings | Calendars.Read (or Calendars.ReadBasic) | GET /me/calendarView | No |
| Resolve the online meeting | OnlineMeetings.Read | GET /me/onlineMeetings?$filter=JoinWebUrl… | No |
| Read the meeting transcript | OnlineMeetingTranscript.Read.All | …/onlineMeetings/{id}/transcripts/{id}/content | Yes |
| Resolve attendee names | People.Read (often already on the event) | GET /me/people | No |
Note the asymmetry that shapes the design. Discovery and people are user-consentable, but the transcript scope requires admin consent even though it’s delegated, and transcript access is further gated by two tenant-admin toggles (Graph API access to transcripts and Speaker attribution) that return 403 when off — though, usefully, it is available to any meeting attendee, not just the organizer (list transcripts). Recordings are stricter: delegated OnlineMeetingRecording.Read.All is organizer-only, so a minutes agent should summarize from transcripts, which every participant can read on their own behalf (get recording). The admin-consent requirement also settles a practical question: since arbitrary MCP clients can’t drive an interactive consent dialog, you grant the transcript scope admin consent once, upfront.
Everywhere else, choose the permission marked least-privileged for the API — write and .All variants only if the tool genuinely needs them (Graph permissions). Entra also hard-blocks a set of high-privilege Graph permissions from ever being granted to an agent identity — Application.ReadWrite.All, RoleManagement.ReadWrite.All, User.ReadWrite.All, Directory.AccessAsUser.All and more — so “even an administrator can’t consent to give an agent those permissions” (authorization). The blocked list evolves, so link to the live reference rather than hardcoding it.
Two more scoping rules complete the posture. Azure’s authorization guidance is explicit that user-centric access should use delegated Graph permissions, citing the exact case: “an agent scheduling meetings for Alice would use delegated calendar API permissions; Alice consents, and the agent can only manage Alice’s calendar (just as Alice could by themselves)” (authorization). And the credential under the MCP server’s app registration should be keyless — a federated credential backed by a managed identity — the same principle the Zero-Secrets series applies everywhere, and one Microsoft names as the preferred blueprint credential.
Delegated is the fix for the oversharing failure. The recurring incident is an agent wired to run on a maker’s or an app-only credential, so every user who talks to it inherits that identity’s access — returning data they should never see. Routing every downstream call through the user’s delegated context via OBO means the external system enforces its own RBAC against the user’s real permissions. (There is a fair counter-argument that oversharing is often pre-existing permission-hygiene debt that agents merely surface — both framings are worth holding — but the identity choice is the part you control at the agent layer.)
Wiring it up in Azure#
You do not implement the token exchanges by hand — Microsoft is explicit that “manual implementation of these protocols is complex and error-prone.” On the MCP server, Microsoft.Identity.Web reduces the OBO-to-Graph call to a one-liner:
// The Graph MCP server acquires a Graph token for the signed-in user via OBO,
// then reads the user's own meetings (phase 2 reads each selected transcript the
// same way). The user is the subject; the server is the actor.
var meetings = await graphServiceClient.Me.CalendarView.GetAsync(r =>
{
r.QueryParameters.StartDateTime = startIso;
r.QueryParameters.EndDateTime = endIso;
r.Options.WithAuthenticationOptions(o => o.WithAgentIdentity(agentIdentity));
});Under the hood this runs the two-step agent OBO exchange and caches the result. At the gateway, Azure API Management validates the inbound token’s audience before the request ever reaches the tool:
<validate-azure-ad-token tenant-id="{{TENANT_ID}}" header-name="Authorization"
failed-validation-httpcode="401">
<client-application-ids>
<application-id>{{MCP_CLIENT_APP_ID}}</application-id>
</client-application-ids>
</validate-azure-ad-token>If you build on Foundry Agent Service, the construct that preserves per-user identity is OAuth identity passthrough — Foundry generates a per-user consent link on first use and stores the connection per user, per tool, per project. The alternatives (a shared agent identity or the project’s managed identity) authenticate the MCP server as one identity and lose the user context, so they are for tools that act as the agent, not for the user. There is no official end-to-end sample that reads a user’s transcripts inside an MCP OBO tool; assemble it from Microsoft.Identity.Web’s OBO, the MCP C# SDK’s resource-server shape, and the AI-Gateway mcp-prm-oauth lab that implements RFC 9728 Protected Resource Metadata plus audience validation plus OBO-to-backend. One naming note: Microsoft’s own first-party Graph MCP — the Microsoft MCP Server for Enterprise (preview) — is currently scoped to Entra directory read-only scenarios, so a calendar-and-transcript agent like this one fronts Graph with a custom MCP server rather than that product (Graph MCP server).
The gotchas that bite in production#
Practitioners who have shipped this pattern flag the same sharp edges:
- Refresh races. Parallel tool calls that race to refresh the same downstream token can cause the provider to revoke the refresh token on first redemption, permanently breaking the connection for the other calls. Guard the refresh with a per-account mutex (Truto).
- Consent. Arbitrary MCP clients often can’t drive an interactive consent dialog, so admin-consent the delegated Graph scopes upfront rather than relying on per-user consent at sign-in (Pamela Fox).
- Audience validation is the #1 exploit. The most common production hole is an MCP server accepting a token minted for a sibling service instead of strictly validating
aud. Validate it on every call. - Short-lived tokens, no long caching. Keep downstream tokens short and rotate; don’t warehouse them.
- Dynamic Client Registration is dev-only in Entra-based MCP setups; production wants pre-registered, reviewed clients.
- No shared IdP, no OBO. If your MCP server proxies several upstream SaaS APIs that don’t federate with your authorization server, OBO isn’t available for those hops — fall back to per-user credential storage; reserve OBO for downstreams that trust your IdP (Graph is the ideal case).
Where to start#
The whole design collapses to a short checklist you can hold in your head:
- Bind every token to an audience (RFC 8707) and validate
audon every call at the MCP server. - Never pass a token through — the MCP server mints a new token for each downstream via OBO.
- Use delegated scopes, least-privileged — so the Graph call is bounded to the user’s own access.
- Put Conditional Access on the user for OBO flows (and on the agent identity for autonomous ones), and surface the claims challenge back to the client.
- Keep the MCP app registration minimal and keyless — a managed-identity federated credential, not a secret.
Do that and the minutes agent drafts your meeting minutes as you — discovering your meetings, reading your transcripts, under your permissions, with an audit trail that names both you and the agent that acted for you. The identity choice underneath this is its own decision (Choosing the Right Agent Identity); the keyless credential beneath the app registration is the Zero-Secrets series; and when these tools are governed at the gateway, that is the AI gateway in Azure API Management. Together they are one story: the user’s identity, propagated deliberately, one audience at a time.
