Microsoft 365 remote MCP server
Microsoft 365 remote MCP server
A remote MCP 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.Sendis 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.
commonandorganizationsare 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 GraphEntra 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_userscope, with a client secret;tenant-admin consent on eleven delegated Graph permissions (
Mail.Sendis 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 |
| ✅ | Client id of your delegated Entra app |
| ✅ | Confidential-client secret (required by OBO) |
| ✅ | Tenant GUID. |
| ✅ | Public HTTPS origin Claude reaches, no trailing slash |
| optional | Your organization's name, used in the server instructions |
| optional | House-style notes appended to the server instructions (tone, signatures, place names) |
| optional | SharePoint library the default file-search scope narrows to; unset searches the whole tenant |
| optional |
|
| optional | IANA zone, default |
| optional |
|
| optional | Comma-separated exact |
| optional | Default off. Set to |
| optional | CORS origin allowed to call this server, default |
| optional | Microsoft Graph request timeout in milliseconds, default |
| optional | default |
Run
npm install
npm run build
M365_CLIENT_ID=… M365_CLIENT_SECRET=… M365_TENANT_ID=<guid> M365_PUBLIC_URL=https://… npm startLocal tool inspection over stdio, without a public host:
npm run inspectorThe 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
(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
josebefore 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.