Skip to main content
Glama
usedrobot

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`.