The problem
A manufacturer wants staff to ask an AI assistant "what's our nonconforming product procedure?" or "which supplier deliveries slipped this week?" The answers live in SharePoint, a help desk list and a shared mailbox. The easy way to connect them is an app with Sites.Read.All and Mail.Read. That lets the assistant read every site and every mailbox, HR and payroll included, and nobody can say afterwards what it looked at.
The client in this demo is fictional: Tallgrass Precision Components, a 180-person CNC shop with an AS9100 quality system and a two-person IT team.
What I built
A TypeScript MCP server for Claude Desktop, Claude Code or any MCP client, over stdio or Streamable HTTP. It has nine tools: six read tools (documents, tickets, the quality@ mailbox, the directory), two write tools (a ticket reply and an IT task) and an approval status check.
The write tools cannot write. They validate the request and return a pending approval id. A named IT person approves it with a CLI, and only then does the connector touch SharePoint. Both support a dry run.
A real Microsoft Graph adapter (client credentials, plain fetch) is the default. A mock adapter serves a fixture tenant: 16 documents, 30 tickets, 41 emails and 23 directory entries. A static replay site shows six recorded Claude sessions with the approval items and audit lines each produced.
How it works
Every call goes through one pipeline, so no tool can skip a control. The rules live in one config file: allowed and blocked sites, blocked folders and sensitivity labels, the mailbox, directory fields, rate limits and approvers.
Security and control
- Least privilege at Microsoft's layer.
Sites.Selectedwith read on Quality and Engineering and write on IT only. Mail limited to quality@ with Exchange RBAC for Applications. The directory query$selects five fields. The permission matrix lists what each tool avoids:Sites.Read.All,Files.Read.All, tenant-wideMail.Read,Mail.Send,Tasks.ReadWrite.All. - Enforced again in code. The deny-list wins over the allowlist, so adding HR to the config by mistake still fails. The mock adapter returns 403 for ungranted sites, the same as Graph, so tests cover both layers.
- Redaction of phone numbers, SSN-like numbers, card numbers and pay amounts runs on every result and every error.
- Audit. Each call writes one JSONL line: caller, tool, argument hash and key names, outcome, result count, withheld count, redactions and latency. Argument values are never logged.
- Approvals. The approver must be named, on the approver list and not the requester. Requests expire after 72 hours and re-validate before executing.
Results
- 43 vitest tests pass: redaction, allowlist, approval gating, audit log, rate limits, the Graph adapter against faked HTTP, and HTTP auth.
- Six real headless Claude Code sessions recorded on 2026-10-07 with
claude-opus-5-5: 17 tool calls, 5.6 to 19.3 seconds per session. - Connector time per call in mock mode: 0 to 5 ms (median 1 ms), from the audit lines.
- Controls fired in the recordings: one
ACCESS_DENIED(HR site), two withheld items (a labelled pay spreadsheet, a confidential email), seven redactions (six phone numbers, one hourly rate). - One reply queued, then approved through the CLI. One task dry-run, queued and left pending.
- The live Graph path is tested against faked HTTP only, not a real tenant.
Stack
TypeScript, Node 22, @modelcontextprotocol/sdk (stdio and Streamable HTTP), zod 4, Microsoft Graph REST with OAuth client credentials, mammoth for .docx, vitest, Vite and React for the replay UI, Playwright for screenshots, and the Claude Code CLI for the recordings.
What I'd do for your company
I'd start with one question your staff ask every week and the two or three data sources that answer it. I'd write the permission matrix with your admin before any code, so you see exactly what the app can reach. Then I'd deploy on your infrastructure with your Entra app registration, approvals going to your IT people and the audit log going where you already keep logs. You get the code, the config and a runbook for adding the next source.






