Microsoft 365 remote MCP server
by usedrobot
README.md
# Microsoft 365 remote MCP server
A remote [MCP](https://modelcontextprotocol.io) server that gives Claude access
to Microsoft 365 — mail, calendar, files, and Teams chat — with **per-user
identity**. Each person signs in through Microsoft as themselves, and the server
exchanges that sign-in for a Graph token via the On-Behalf-Of flow, so every call
runs with exactly that person's permissions. It does not hold one app identity
with standing access to the whole tenant: if a user cannot see a message, neither
can the server acting for them.
Built for and running in production at a small nonprofit.
## Design stance
- **Acts as the user, not as the app.** Sign-in is a PKCE proxy to Microsoft
Entra, and every Graph call goes through On-Behalf-Of exchange of the caller's
own token. The server never holds standing access to anyone's mail — no
application permissions, no service-account mailbox, nothing to steal that
would read a mailbox on its own.
- **Drafts only.** `Mail.Send` is not requested, so it cannot be granted. The
model composes; a human opens the draft and presses send. A compromised prompt
or a confused model can at worst leave an unwanted draft in your Drafts folder.
This is a deliberate boundary, not a missing feature.
- **Tenant-pinned.** `common` and `organizations` are rejected at startup, and
the tenant must parse as a GUID. A wildcard tenant would let Entra accept
tokens issued by *any* organization — the single most damaging
misconfiguration available on a public endpoint, so it is made unreachable
rather than documented as a caution.
## Architecture
```
User → Claude → /authorize → Microsoft Entra (login + consent)
Claude → /token (two-leg PKCE proxy → Entra) ← server holds the confidential client
Claude → /mcp (Bearer = user token, aud = this app)
└─ verify.ts: validate JWT (sig/iss/aud/exp + tenant pin + scope)
└─ obo.ts: exchange for a Graph token (acts as the user)
└─ tools.ts: call Microsoft Graph
```
Entra supports neither Dynamic Client Registration nor Client ID Metadata
Documents, which is why this server is itself the OAuth authorization server
Claude talks to: it exposes `/register` and brokers every flow down to one
pre-registered Entra confidential client.
## Setup
This is not a two-minute install, and pretending otherwise would waste your
time. Before it will start you need:
- an **Entra app registration** in your own tenant, exposing an
`access_as_user` scope, with a client secret;
- **tenant-admin consent** on eleven delegated Graph permissions (`Mail.Send`
is not among them, by design);
- a **public HTTPS origin** Claude can reach — Claude connects to your server
over the internet, so localhost is not enough outside of `npm run inspector`.
`docs/entra-setup.md` walks the registration through from scratch, including
which permissions to grant and why.
## Config
Copy `.env.example` to `.env` and fill it in.
| Var | Required | Notes |
|---|---|---|
| `M365_CLIENT_ID` | ✅ | Client id of your delegated Entra app |
| `M365_CLIENT_SECRET` | ✅ | Confidential-client secret (required by OBO) |
| `M365_TENANT_ID` | ✅ | Tenant **GUID**. `common`/`organizations` are rejected at startup. |
| `M365_PUBLIC_URL` | ✅ | Public HTTPS origin Claude reaches, no trailing slash |
| `M365_ORG_NAME` | optional | Your organization's name, used in the server instructions |
| `M365_EXTRA_INSTRUCTIONS` | optional | House-style notes appended to the server instructions (tone, signatures, place names) |
| `M365_EVENTS_LIBRARY_PATH` | optional | SharePoint library the default file-search scope narrows to; unset searches the whole tenant |
| `M365_READ_ONLY` | optional | `true` disables every write tool |
| `M365_DEFAULT_TZ` | optional | IANA zone, default `America/New_York` |
| `M365_REDACT_PII` | optional | `true` scrubs tokens, secrets, and email addresses from logs |
| `M365_ALLOWED_REDIRECT_URIS` | optional | Comma-separated exact `/authorize` `redirect_uri` allowlist. Defaults to `https://claude.ai/api/mcp/auth_callback`; set this only if a different client connects. |
| `M365_TRUST_PROXY` | optional | Default off. Set to `1` (or an Express hop count) **only** when running behind a reverse proxy you control, so `X-Forwarded-For` is trusted. With the port published directly, leaving this off is what keeps the rate limits enforceable. |
| `M365_CORS_ORIGIN` | optional | CORS origin allowed to call this server, default `https://claude.ai` |
| `M365_GRAPH_TIMEOUT_MS` | optional | Microsoft Graph request timeout in milliseconds, default `30000` |
| `PORT` | optional | default `3000` |
## Run
```bash
npm install
npm run build
M365_CLIENT_ID=… M365_CLIENT_SECRET=… M365_TENANT_ID=<guid> M365_PUBLIC_URL=https://… npm start
```
Local tool inspection over stdio, without a public host:
```bash
npm run inspector
```
The included `Dockerfile` is a plain multi-stage Node build that ships to any
container host. No platform deploy configuration is included on purpose — where
you run it is your decision, and a checked-in deploy manifest would only encode
someone else's.
## Tools
**Read:** `search_email`, `get_email`, `search_calendar`, `get_event`,
`find_meeting_times`, `search_files`, `get_file`, `search_teams_chat` — with
natural-language date parsing and offset pagination. Delegated/shared access via
`mailboxOwner` / `calendarOwner` is supported by the four mailbox and calendar
tools — `search_email`, `get_email`, `search_calendar`, `get_event`.
`find_meeting_times`, `search_files`, `get_file`, and `search_teams_chat` always
act as the signed-in user.
**Write** (all gated behind `M365_READ_ONLY`): `draft_email`, `draft_reply`,
`create_event`, `update_event`, `cancel_event`. Drafts are auto-formatted —
agent hard-wraps are healed so prose does not render as a frozen narrow column
on mobile — and writes honour the same delegated `mailboxOwner` /
`calendarOwner` access as the reads.
There is no send tool. See *Drafts only*, above.
## Provenance and security
The auth and transport plumbing is adapted from
[`softeria/ms-365-mcp-server`](https://github.com/softeria/ms-365-mcp-server)
(MIT). Every copied file was reviewed line-by-line before inclusion; the
attribution is in `NOTICE.md` and the file-by-file review in
`docs/copied-code-audit.md`.
Hardening applied over upstream:
- **Tenant-pinned config** — the tenant is required and validated as a GUID;
upstream defaults to `common`.
- **Full local JWT validation** — signature, issuer, audience, expiry, tenant,
and scope are checked against Entra's JWKS with `jose` before any Graph call.
Upstream did an expiry-only pre-check and let the downstream Graph call be the
real gate.
- **OBO only** — no trust-proxy or token pass-through mode.
- **Curated tool surface** — a deliberately chosen set of tools rather than a
generated wrapper over the whole Graph API, with no send capability anywhere
in it.
Licensed MIT — see `LICENSE`.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues