Microsoft On-Behalf-Of — the DNA MCP server acts on your M365¶
The MCP server already validates the inbound Entra bearer token and maps it to a DNA tenant/workspace. On-Behalf-Of (OBO) is the next hop: the same verified token is a delegated user token, and a confidential middle tier can exchange it at Microsoft Entra for a downstream Microsoft Graph token minted for the same user. That lets DNA MCP tools read the signed-in user's Microsoft 365 — their calendar and their files (OneDrive / SharePoint) — on their behalf, with no new sign-in.
The full design (and why this is spec-aligned, not the forbidden token-passthrough
pattern) is ADR-mcp-obo.
This guide is the operator recipe:
enable it, wire the Entra app, and call ms_calendar_list.
Off by default, opt-in, HTTP-only. With no
graph:block indna.config.yamlnot onems_*tool is registered — the OSS / stdio / self-host path never touches Microsoft. OBO needs a bearer token to exchange, so it is only available over--transport http.
The shape¶
MCP client ──token A (aud = api://dna-mcp-dnacloud)──▶ DNA MCP server
│ (confidential client)
│ OBO exchange at the
│ token's HOME tenant
▼
Microsoft Entra ── token B (aud = graph)
│
ms_calendar_list ──Bearer token B──▶ Microsoft Graph
│ GET /me/calendarView
shaped events ◀────────┘ (token B never persisted)
Token B lives only for the request: it is used on the one outbound Graph call and dropped. It is never logged, persisted, cached, or returned to the client.
1. Enable it — the graph: config block¶
Add a graph: section to dna.config.yaml (sibling to auth:). It is the
ceiling: which tool-groups are on and the exact delegated scopes each may
request. The confidential-client credential is referenced by the NAME of the
env var that holds it — never the secret value itself.
# dna.config.yaml
source: postgresql://… # your source (unchanged)
auth: # the inbound IdP layer (unchanged)
providers:
- type: entra
issuer: https://login.microsoftonline.com/common/v2.0
audience: api://dna-mcp-dnacloud
graph: # ← absent ⇒ OBO entirely off (default)
enabled: true
client_id_env: DNA_MCP_CLIENT_ID # env var holding the app-reg id
credential_env: DNA_MCP_CLIENT_SECRET # env var holding the client secret
groups:
calendar:
enabled: true
scopes: [ "Calendars.Read" ] # read-only
files: # ← OneDrive / SharePoint, opt-in
enabled: true
scopes: [ "Files.Read" ] # read-only
Each group is independent: enable only the ones you want, each with its own
delegated scopes. The files group above adds two read-only tools —
ms_files_search and ms_file_read (see §2b) — gated exactly like calendar.
Fail-closed guarantees the parser enforces:
enableddefaults tofalse. A present block withenabled: false(or a disabled group) registers nothing.- The scope allow-list is static. A tool can only ever request a scope its
group declared; anything else is refused before any exchange
(
OboScopeNotAllowedError). *_envfields must be env-var NAMES. A value that looks like an inline secret (contains~ . / = @…) is rejected — a guard against pasting a secret into config.
Then run the server with the credentials in the environment:
export DNA_MCP_CLIENT_ID=ff09090f-79e3-4dfe-975c-1a8e007112b7
export DNA_MCP_CLIENT_SECRET='<the client secret>' # from Entra (see §3)
dna mcp serve --transport http --auth config
# boot log: "graph (OBO): active groups — calendar"
# "[dna-mcp] graph tool wired: ms_calendar_list"
2. Call it — ms_calendar_list¶
Once enabled and the caller signs in with an Entra identity, the tool appears on the MCP surface:
// list_tools → includes:
{ "name": "ms_calendar_list",
"description": "List the signed-in user's Microsoft 365 calendar events …" }
// call_tool ms_calendar_list
{ "start": "2026-07-15T00:00:00Z", // optional; default = today 00:00 UTC
"end": "2026-07-22T00:00:00Z", // optional; default = start + 7 days
"top": 25 } // optional; 1–100, default 25
// → shaped result (never a token, never the raw Graph body)
{ "count": 2,
"events": [
{ "id": "…", "subject": "Standup", "start": "2026-07-15T09:00:00",
"end": "2026-07-15T09:15:00", "location": "Room 1", "organizer": "Alex",
"web_link": "https://outlook.office365.com/…" }
] }
The tool's description + input_schema are a governed Tool document
(ms_calendar_list.yaml), so what the model sees is data — overlayable like any
Tool, not hardcoded.
2b. The files group — ms_files_search + ms_file_read¶
With the files group enabled (and an Entra caller), two read-only tools appear.
Both use the delegated Files.Read scope only, over On-Behalf-Of.
ms_files_search — search the user's OneDrive / SharePoint by text
(GET /me/drive/root/search(q='…')). Returns named fields only — never file
contents, never a token, never the raw Graph body:
// call_tool ms_files_search
{ "query": "Q3 plan", "top": 25 } // top optional; 1–100, default 25
// → shaped result
{ "count": 1,
"files": [
{ "id": "01ABC…", "name": "Q3 Plan.docx",
"web_url": "https://…-my.sharepoint.com/…/Q3%20Plan.docx",
"last_modified": "2026-07-10T12:00:00Z", "size": 20480, "type": "docx" }
] }
ms_file_read — read a file's content by the id from a search
(GET /me/drive/items/{id}). Token B is used only on that Graph metadata call;
the content itself is then fetched from the driveItem's preauthenticated
@microsoft.graph.downloadUrl with no Authorization header, so the token
never leaves graph.microsoft.com.
// call_tool ms_file_read → text-convertible file
{ "item_id": "01ABC…" }
{ "id": "…", "name": "notes.md", "type": "md", "size": 1234,
"web_url": "https://…", "mode": "text",
"text": "# Notes\n…", "truncated": false }
// call_tool ms_file_read → binary Office / image / PDF
{ "id": "…", "name": "Q3 Plan.docx", "type": "docx", "size": 20480,
"web_url": "https://…", "mode": "metadata",
"note": "This file type is not text-extractable inline in this slice — open it
in the browser via web_url. …" }
Office-content limitation (this slice). Text-convertible files — plain text, Markdown, CSV, JSON, XML, YAML, HTML, source code, … — are returned as extracted text (capped at ~1 MiB,
truncated: truewhen clipped). Binary Office documents (.docx/.xlsx/.pptx), images, and PDFs are not dumped inline; you get their metadata plus aweb_urlto open them. Rich Office → text extraction is a tracked follow-up (see Deferred).
Both tools' surfaces are governed Tool documents (ms_files_search.yaml,
ms_file_read.yaml) — overlayable like any Tool.
Honest failure modes¶
| Situation | What the caller gets |
|---|---|
| Non-Entra sign-in (Clerk/WorkOS/OIDC) or stdio | "Microsoft Graph is not available for this identity" — a capability gap, not a crash |
Consent not granted (AADSTS65001) |
"Graph access for scope(s) … has not been consented — an admin or the user must grant it" |
| Conditional Access / MFA step-up | The claims challenge is surfaced (not swallowed) so the client can step up |
| A scope outside the allow-list | Refused before any exchange (OboScopeNotAllowedError) |
| Bad credential / Graph 5xx | A sanitized error (an AADSTS/HTTP code at most) — never the raw body |
3. Entra setup — the one-time admin steps (Barna)¶
OBO needs the existing dna-mcp-dnacloud app-registration
(appId ff09090f-79e3-4dfe-975c-1a8e007112b7) to become a confidential client
with a delegated Graph permission and consent. Only an Entra admin can do
this. The code above works the moment these are done, and fails honestly until
then.
APP_ID=ff09090f-79e3-4dfe-975c-1a8e007112b7
# (a) Add a CLIENT SECRET → makes the app a confidential client (PoC credential;
# certificate / ACA managed-identity federated credential is prod-hardening).
az ad app credential reset --id "$APP_ID" \
--display-name "dna-obo-poc" --years 1 --query password -o tsv
# ^ copy the printed secret into DNA_MCP_CLIENT_SECRET (store in a Key Vault in prod).
# (b) Add the DELEGATED Microsoft Graph permission(s) for the group(s) you enabled.
# Graph resource appId = 00000003-0000-0000-c000-000000000000
# Calendars.Read delegated scope id = 465a38f9-76ea-45b9-9f34-9e8b0d4b0b42 (Scope)
# Files.Read delegated scope id = 10465720-29dd-4523-a11a-6a75c743c9d9 (Scope)
az ad app permission add --id "$APP_ID" \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions 465a38f9-76ea-45b9-9f34-9e8b0d4b0b42=Scope
# … and, for the `files` group (ms_files_search + ms_file_read), the Files.Read scope:
az ad app permission add --id "$APP_ID" \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions 10465720-29dd-4523-a11a-6a75c743c9d9=Scope
# (c) CONSENT. Either admin-consent the tenant-wide grant …
az ad app permission admin-consent --id "$APP_ID"
# … or send end users through the per-user incremental consent URL
# (per-tool-group — request only the scope(s) for the group(s) you enabled,
# e.g. Calendars.Read for calendar, Files.Read for files):
# https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize
# ?client_id=ff09090f-79e3-4dfe-975c-1a8e007112b7
# &response_type=code
# &redirect_uri=<your redirect>
# &scope=https://graph.microsoft.com/Calendars.Read%20https://graph.microsoft.com/Files.Read%20offline_access
# &prompt=consent
Optional but recommended for a one-prompt combined consent (client→MCP and
MCP→Graph in a single sign-in): add the MCP client's appId to the DNA MCP
app-registration's knownClientApplications (ADR §4.3).
Verify the live path (Barna's smoke — not CI)¶
The unit tests fake the exchange (no live Entra, no secret). To smoke the real chain once the steps above are done:
export DNA_MCP_CLIENT_ID=… DNA_MCP_CLIENT_SECRET=…- Boot
dna mcp serve --transport http --auth configwith thegraph:block on. - From an MCP client signed in as an Entra user in the consented tenant, call
ms_calendar_list— you should get that user's real events back. - If you see the consent error, step (c) has not propagated yet (or the user hasn't consented); if you see the capability-gap error, the caller is not an Entra identity.
Deferred (follow-on stories)¶
Shipped since the first slice: the files read group (ms_files_search +
ms_file_read, s-mcp-obo-files-group).
Still not in scope, tracked as s-mcp-obo-read-groups +
s-mcp-obo-prod-hardening (+ a rich-Office-extraction follow-up): the mail read
group, rich Office (.docx/.xlsx/.pptx) → text extraction for
ms_file_read (this slice returns metadata + a web link for binary Office), write
tools (ms_mail_send), a bounded in-memory token cache, the certificate / ACA
managed-identity credential (this slice uses a plain secret), the step-up consent
round-trip surfaced through to the client, and guest (B2B) / personal (MSA) identity
support.