mcpbeat

Msgraph Integration Patterns

microsoft/msgraph-integration-patterns

> Hard-won patterns for probing, building, troubleshooting, and iterating against Microsoft Graph API endpoints -- especially from a browser SPA using delegated MSAL.js auth calling Graph directly with no backend (lessons generalize to any Graph integration). Covers the throwaway-probe-file methodology for de-risking before building, OData/query quirks, permission and admin-consent sequencing, recordings/transcripts access patterns (SharePoint REST, not Graph), CSP requirements for a pure-browser SPA, retry/pagination/backoff patterns, and the MSAL/EasyAuth auth-redirect-loop debugging saga. Use when integrating with Microsoft Graph, Teams APIs, MSAL.js, or EasyAuth; when hitting an unexpected Graph error (400/403/429), a silent missing-scope failure, an auth redirect loop, or a CSP violation that only appears in production; or when deciding how to validate a new Graph capability before committing it to a codebase.

4k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
10
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/microsoft/amplifier-bundle-skills --skill msgraph-integration-patterns

The instruction itself

9 sections, as written by the author

Microsoft Graph Integration Patterns

Hard-won lessons from building a Teams-data SPA (chats, channel threads,

recordings/transcripts) against Microsoft Graph with delegated MSAL.js auth.

Real evidence throughout -- not generic API advice.

0. The one practice that pays for everything else: probe before you build

Before writing any TypeScript/app code against a new Graph capability, write a

throwaway, standalone HTML file with zero build tooling:

<script type="module">
  import { PublicClientApplication } from "https://esm.sh/@azure/msal-browser";
  // hardcode the real client id -- client ids are not secrets
  const pca = new PublicClientApplication({ auth: { clientId: "...", authority: "..." } });
  // real interactive login, then a raw fetch() to the EXACT endpoint/query
  // render BOTH a summary AND the raw JSON of the first result --
  // the raw JSON is "proof of shape," including undocumented-feeling fields
  // like a replies[] array under $expand
</script>

This decouples two different risks: "does this scope/endpoint even return what

I expect" (cheap, interactive, real credentials, zero build step) from "is my

app's abstraction correct." A wrong assumption about Graph's actual behavior

gets caught in seconds instead of after a full feature is built on it. **Delete

the probe once you have the answer** -- never commit or ship it.

Treat admin-consent lead time as a hard roadmap gate: confirm consent is

available for a scope *before* starting the feature that needs it, not as a

mid-feature surprise. Run genuine investigation spikes (not ad hoc debugging)

for open questions, and record the negative result permanently (a commit

message, a design-doc line) so nobody re-tries a dead end later without

knowing why it failed.

1. OData / query quirks

  • $top can be rejected on specific sub-endpoints even when it works on

the parent -- e.g. allowed on /me/chats and /{chat}/messages, but

/me/chats/{id}/members throws Query option 'Top' is not allowed. Don't

assume a query option that works on one endpoint works on its neighbors.

  • $expand=replies is the only way to get thread replies inline on channel

messages: /teams/{teamId}/channels/{channelId}/messages?$top=50&$expand=replies.

There's no server-side "last activity" sort for threads -- compute

max(root.createdDateTime, all replies.createdDateTime) client-side.

  • No $filter on chat-message dates. Page newest-first, stop once you hit

your since boundary or run out of messages, filter client-side. This

pattern recurs everywhere Graph lacks a date filter (recordings windowing,

etc.) -- assume no server-side date filter until proven otherwise.

  • @odata.nextLink is an absolute URL -- pass it straight to fetch(),

don't re-prefix it with your base URL.

  • $count support is not universal across endpoints (e.g. transcript

endpoints don't support it) -- declare per-source capabilities and adapt

UI/warnings rather than assuming uniform support.

  • Resource IDs aren't always interchangeable even when they look similar --

e.g. Teams chat IDs are MRIs starting with 19:; passing an Exchange/Outlook

item ID (AAMk...) to a chat endpoint returns `400 "Invalid MRI, should

start with digits and colon"`. Validate ID shape client-side before firing

the request.

  • Split app registrations by trust boundary. One app for auth-gate/login

scopes only (openid, profile, email); a separate app for the actual

Graph content scopes. A leaked login credential then can't pivot to content

access.

  • Grow scopes incrementally, tied to feature order, and be willing to

*drop* scopes a prior approach needed once you replace that approach (e.g.

dropping calendar/online-meetings scopes entirely once a chat-based

discovery method replaced the calendar-based one -- see §3).

  • Transcript access needs a *different* API resource's permission --

Sites.Read.All under "Office 365 SharePoint Online" is not the same grant

as Microsoft Graph's own Sites.Read.All, and backs a *different* bearer

token (https://{spHost}/.default).

  • Fingerprint consent errors and degrade gracefully, don't retry-loop.

Match /AADSTS65001|invalid_grant|InteractionRequiredAuthError/i against the

error; on match for an optional feature, set a session-scoped flag that

suppresses further attempts for the rest of the page session (self-heals on

reload once the scope is granted) instead of spamming retries or the

console.

  • Nested-group and mail-enabled-group gotchas (general Entra knowledge):

AADSTS50105 fires when a role assignment flows through a *nested*

subgroup (must be a direct member for app-role assignment); a

mail-enabled security group silently fails to emit the roles claim even

though the portal shows the assignment as successful (recreate as a pure

security group); the groups claim is replaced by

_claim_names/_claim_sources once a user is in 200+ groups (resolving

needs an OBO call with a client secret -- generally blocked for pure SPAs,

so prefer app roles over group claims when overage is possible).

3. Recordings / transcripts -- the hardest-won findings

  • **/me/onlineMeetings transcript/recording endpoints are a confirmed dead

end for "recordings I can access" -- they only work for meetings the

signed-in user organized.** getAllTranscripts()/getAllRecordings()

enforce userId == organizerId; the meetingOrganizerUserId=null "get all"

syntax doesn't work at v1.0 or beta (both 400); passing your own oid returns

412 "not supported in delegated context." /search/query for

filetype:mp4 returns 0 results under delegated auth.

  • **The working alternative: walk /me/chats messages and read

eventDetail[\"@odata.type\"] === \"#microsoft.graph.callRecordingEventMessageDetail\"

directly out of the message payload.** This surfaces recordings regardless

of who organized the meeting, including recordings in another user's

OneDrive. Cross-join participants from sibling callEndedEventMessageDetail

events by shared callId, falling back to plain chat membership when no

callEnded event exists (ad-hoc 1:1 calls never emit one).

  • **The real access boundary is SharePoint ACL on the recording file, not

meeting organizer.** The "organizer-only" gate is an artifact of the

/me/onlineMeetings API specifically -- not a real data limit. This

reframing is the core insight: chat-membership access ≠ organizer access,

and Graph's *official* transcript API enforces the wrong boundary.

  • Transcript retrieval is SharePoint REST v2.1, not Graph:
  • GET /shares/{shareId}/driveItem (Graph, Files.Read.All) to resolve

driveId/itemId from a sharing URL. shareId = "u!" + base64url(url),

strip = padding.

  • GET https://{spHost}/_api/v2.1/drives/{driveId}/items/{itemId}?select=name,media/transcripts&$expand=media/transcripts

-- Graph does not expose media/transcripts; this is a separate

SharePoint-resource bearer token (https://{spHost}/.default), not the

Graph token.

  • Rewrite temporaryDownloadUrl to /streamContent?is=1&applymediaedits=false

to get raw VTT.

  • Try with the bearer token first, fall back to unauthenticated fetch on

401 (a hosted SPA needs the SP token; a browser extension could ride

cookies but a hosted app can't).

  • Cross-tenant recordings are a typed non-error. /shares/{id}/driveItem

returns 400 "Invalid hostname for this tenancy" when the file lives in

another org's SharePoint -- give this its own error type so callers can

count/label it, not treat it as a bug.

  • Shallow pagination silently drops data. A naive top-50 scan dropped 11

of 38 recordings in a 30-day window. Deep-scan each in-window container

until you pass the window edge or hit a page cap; if the cap fires first,

flag the result truncated: true and say so loudly in the UI -- never

silently incomplete.

  • Validate defensive assumptions empirically before keeping them. A

padded date-window buffer (in case a container's "last updated" timestamp

was stale) was later disproven with a full-scan oracle across hundreds of

containers -- the timestamp was always current, the buffer was pure

overhead. Removing it cut the scan size substantially.

  • **Different environments/tenant rings can use different SharePoint

hostname patterns.** If your app runs across multiple tenants or

environments (production, staging, internal test rings), verify the

actual SharePoint hostname per environment rather than assuming one

wildcard covers all -- each distinct host pattern needs its own

CSP/allowlist entry.

4. CSP for a pure-browser SPA calling Graph directly

Built up incrementally -- **every addition was driven by a production-only

failure the local dev server never surfaced**, since CSP is browser-enforced,

not dev-server-enforced:

default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
connect-src 'self' https://login.microsoftonline.com https://graph.microsoft.com
  https://*.sharepoint.com;
frame-src 'self' https://login.microsoftonline.com https://identity.{n}.azurestaticapps.net;
img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self';
frame-ancestors 'none'; form-action 'self'
  • style-src 'unsafe-inline' -- dynamic inline styles, invisible until prod.
  • connect-src *.sharepoint.com -- OneDrive AppFolder sync + transcript

downloads share one wildcard (add further host patterns per environment,

see §3's last point).

  • frame-src entries were added to support an ssoSilent hidden-iframe

approach that was later ripped out entirely (§6) -- left in place as

harmless once no longer load-bearing. **Lesson: a CSP entry added for one

strategy can outlive that strategy** -- don't assume every entry still has

a live purpose; don't be surprised when removing a CSP entry breaks nothing.

5. Rate limiting / retry / pagination

  • Retry only 429, 502, 503, 504. Everything else (400/401/403/404…)

throws immediately with the (truncated) response body in the error -- never

blind-retry a client error.

  • 429: honor Retry-After (seconds), clamp to a sane max (~60s) against a

misbehaving value.

  • 502/503/504: exponential backoff + jitter when no Retry-After (e.g.

~1s doubling, capped ~30s, random jitter to avoid thundering-herd retries).

  • Cap at ~5 attempts, then throw loud with status + attempt count +

truncated body.

  • Refresh the token on every retry, not just once -- a long paginated

scan across hundreds of items shouldn't die to a token that expired

mid-scan.

  • Bounded concurrency pool (e.g. 4 workers) for bulk enumeration, not

unbounded Promise.all over hundreds of items -- bounds throttling risk.

  • Start with observability-only for rate limiting (log/capture 429s and

scope failures as a signal) rather than building custom retry dashboards

before you know if you even need them.

6. MSAL.js / EasyAuth -- the auth-loop debugging saga

This was the single most-iterated area. Presented as a sequence because the

sequence *is* the lesson: **four plausible-looking root causes in a row were

wrong**, and each "fix" that didn't work still taught something.

  • Wrong-ish: assumed CSP frame-src was too strict for MSAL's hidden

ssoSilent iframe returning to the app's own origin. Added 'self' to

frame-src. Loop persisted.

  • Still incomplete: the iframe also needed the platform's own identity

host in frame-src to ride an existing session cookie. Added it. Loop

persisted.

  • The CSP fixes were a red herring. Real cause: modern browsers block

third-party session cookies in same-frame contexts, *and* the identity

provider refuses to render its login page in an iframe at all

(X-Frame-Options: deny) -- unfixable from the app side. Fix: remove

the iframe-based silent-SSO bridge entirely, fall through to a full

top-level redirect login (no iframe, so X-Frame-Options doesn't apply).

Accepted UX cost: one click instead of a broken zero-click bridge.

  • Loop persisted even without the iframe. Actual root cause: the

platform's own catch-all "require auth on every route" rule intercepted

MSAL's own redirect-return navigation (which carries the auth code in a

URL fragment) and stripped the fragment before MSAL could read it -- so

MSAL never established a session. Fix: add an anonymous callback route

(e.g. /auth-callback) *above* the catch-all auth rule; point

redirectUri at it; only navigate into the app after

handleRedirectPromise() resolves an account. Confirmed by checking the

console showed zero CSP violations during the failure -- proof it was

never a CSP problem, which is what fixes 1-2 had assumed.

  • Write the decision + dead-ends down permanently once solved, so a

future session with a similar symptom doesn't re-litigate the same wrong

hypothesis (CSP) again.

  • A related but distinct bug: an un-awaited "try silent login" call at

module top-level blocked first paint for the iframe's multi-second

timeout -- a white-screen bug, not a loop. Fix: make it non-blocking with

a visible "Signing in..." placeholder.

Net architecture: no iframe-based silent SSO. On load: init →

handleRedirectPromise() → if on the callback route with an account now

set, client-side redirect to / → else reuse a cached account if present

→ else show a sign-in button that does a full top-level redirect login.

Per-Graph-call fallback is separate: try silent token acquisition first;

on failure, redirect interactively for that scope set and let the navigation

handle it -- don't try to recover in place.

Deployed-vs-local config drift is its own bug class: when two different

env vars can each answer "which app is this" (e.g. one injected by your

deploy platform, one injected by your build tool), get the **precedence

order** right and know which one actually carries your full scope set --

getting it backwards silently breaks a *specific* feature only in the

deployed build, invisible in local dev where only one var is set. Test the

newest feature specifically against the deployed build, not just locally.

7. Other gotchas

  • **Sibling resource types (chats vs. channels) can have materially

different Graph surfaces and scope requirements** even though they feel

like the same product area to a user -- investigate each independently,

don't assume parity.

  • Client-side date-range windowing is the default assumption, not the

exception -- "page newest-first, stop once a whole page is provably older

than your window" beats hoping for a $filter that doesn't exist.

  • **A 403 on one item in a bulk enumeration should be a typed, silent

"no access" for that item** -- pattern-match 403/forbidden/access-denied

and convert to a skip or a labeled UI state, so one inaccessible item

doesn't abort enumeration of everything else.

  • Synthesize your own composite/stable ID when Graph doesn't provide one

spanning a hierarchy (e.g. teamId::channelId, or callId::filename when

one call can produce multiple recording files).

  • **Re-evaluate narrow "selected files" scopes against broad ones once admin

consent removes the security argument for going narrow** -- the narrower

scope's interactive picker can add UX friction with no remaining benefit.

  • Graph itself won't merge concurrent writes to shared state. Use

conditional writes (ETags/If-Match) on any shared blob you sync through

Graph-backed storage, and handle 412 Precondition Failed with a

re-read-and-merge (set-union for additive fields, last-write-wins per key

for scalars).

How to use it

Copy the folder

Take microsoft/msgraph-integration-patterns from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.