DEV Community

DenizV
DenizV

Posted on

Giving an AI assistant read-only access to Microsoft Loop — without breaking permissions

Giving an AI assistant read-only access to Microsoft Loop — without breaking permissions

I wanted my AI assistant to read my team's Microsoft Loop pages — summarize a
workspace, pull the latest OKRs into a draft, answer "what did we decide about
X." Simple ask. It turned into a genuinely interesting engineering problem, and
I ended up building a small open-source MCP server to solve it. Here's the
challenge, the solution, and the traps along the way.

The result is open source (MIT): loop-reader-mcp.

The challenge

Two walls, right at the start.

Wall 1: Microsoft Loop has no content API. As of mid-2026, there's no
Graph endpoint to read or write a Loop page's content — no "get page," no
"list workspace." Loop is positioned against Notion and Confluence, but you
can't programmatically get your own content out in a structured way. The 2026
Loop roadmap is about governance, not a content API.

Wall 2: where Loop actually stores things. Loop workspace pages live in
SharePoint Embedded (SPE) containers, and Loop components from Teams/Outlook
live as .loop files in OneDrive. There is a documented trick: Microsoft
Graph can convert a .loop file to HTML on the fly with
GET /drives/{id}/items/{id}/content?format=html. So reading is possible
through the file layer even without a Loop API.

But then the real problem showed up — the one worth writing about.

The permission trap

SharePoint Embedded does not accept delegated (per-user) tokens for content
downloads. Only an app-only identity can fetch the bytes.

If you build the naive thing — a service that uses its app identity to read
Loop — you've created a permission-flattening machine. The app can read
everything, so anyone who can talk to your service can read any Loop page in
the tenant, regardless of what they personally have access to. That's a data
leak with extra steps. Unacceptable.

So the question became: how do you let a service read content with an app
identity, while guaranteeing each user only sees what they're allowed to see?

The solution: split discovery from retrieval

The insight that cracked it: Graph Search is security-trimmed for delegated
callers, even for SPE content.
Search respects the user's permissions; only
the content download needs the app identity.

So I split the two operations across two identities:

  1. Discovery runs as the user. When the assistant searches Loop, the server
    exchanges the user's token via the OAuth On-Behalf-Of (OBO) flow and
    calls Graph Search as that user. Microsoft trims the results to exactly
    what they can access. The server records the (driveId, itemId) of every
    hit in a short-lived, per-user cache.

  2. Retrieval is gated by that discovery. When the assistant asks to read a
    page, the server refuses unless that exact (driveId, itemId) pair is in
    this user's cache — i.e. unless they personally just discovered it via
    their own trimmed search. Only then does it use the app identity to fetch
    and convert the bytes.

search  -- OBO (user identity) --> Graph Search  -> results trimmed by Microsoft
                                                  -> (driveId,itemId) cached per user
read    -- is this pair in the caller's cache? --> no  -> refuse (no Graph call)
                                                   yes -> app identity -> ?format=html
Enter fullscreen mode Exit fullscreen mode

The authorization decision is Microsoft's, not mine. A user can't discover
a page they can't access (search runs as them), and can't read a page they
didn't discover. The app identity is just a retrieval mechanism for bytes the
user already proved they can see. Permission flattening solved.

Making it a proper MCP server

I exposed this as a remote Model Context Protocol server with three
read-only tools: loop_search, loop_list_components, and loop_get_page.
Read-only isn't a policy toggle — the Graph client only permits GET and
POST /search/query, so there's structurally no way to write. (Good, because
overwriting a .loop file with anything else corrupts it, and there's no
supported write API anyway.)

For auth, I initially tried to put a platform "easy auth" gateway in front of
the server. Big mistake — the gateway intercepted the OAuth handshake and
the MCP client could never discover where to log in. The fix was to make the
server its own OAuth resource server: it publishes the discovery documents
(/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server)
pointing clients at Microsoft Entra, and it validates the incoming token itself
(signature via Entra's JWKS, audience, issuer, expiry). No gateway, no
interception, and access is still fully gated because Entra only issues tokens
to users assigned to the app.

Lessons learned (the un-glamorous half)

  • Delegated search + app-only retrieval is a legitimate, powerful pattern for any service sitting on top of SharePoint Embedded. Trim on discovery, fetch on retrieval.
  • Don't let a platform auth gateway wrap a remote MCP server — the MCP OAuth flow needs the client to reach the server's own discovery/challenge. Let the app own auth.
  • Bind the retrieval gate to (driveId, itemId) pairs, not just the item ID — otherwise a caller could replay a discovered ID against a different drive.
  • Fail closed everywhere: no token, no identity, cache miss, expired entry -> refuse. On multi-instance hosting a user just re-searches; that's a safe failure, not a leak.
  • The app credential is the crown jewel. It holds tenant-wide read. Store it in a secrets manager, prefer a certificate, rotate it, and consider IP-allowlisting the server.
  • Watch the revocation lag. Discovery results are cached for a few minutes, so losing access isn't instant. Tune the TTL to your risk tolerance.

Try it

The code is on GitHub under MIT: github.com/DenizV/loop-reader-mcp. The README covers the
Entra app registration, the one-time SharePoint Embedded guest registration,
hosting, and wiring it into an MCP client. SECURITY.md documents the model and
the residual risks to close before production.

It's community code, not a certified product — review it and run a dependency
scan before you point it at real data. But if you've been wanting to let an
assistant read your Loop content without handing it the keys to the whole
tenant, this pattern works.

If you build on it or find a sharper approach, I'd love to hear it.

Top comments (2)

Collapse
 
komo profile image
Reid Marlow

The cache gate on (driveId, itemId) is the important bit here. A lot of agent permission designs stop at “the app has Graph access,” then accidentally turn app identity into a backdoor. Tying read to a just-in-time user-discovered pair gives you a clean audit point too: who searched, what was returned, and which exact item crossed the boundary.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Strong split. I would treat the cached (driveId, itemId) pair as a short-lived capability, not merely a cache entry: bind it to tenant, user, client/session, and discovery time, then give the model an opaque server-generated handle rather than accepting raw identifiers on read. Resolve that handle server-side and log the delegated principal, app-only retrieval, and item version together. For high-sensitivity tenants, re-run trimmed discovery when the revocation-lag budget is exceeded. That makes the residual TOCTOU risk explicit and blocks confused-deputy replay across users or clients.