office365-mcp
README.md
# office365-mcp
A **multi-user remote MCP server for Microsoft 365** — Outlook mail, Teams and SharePoint/OneDrive over Microsoft Graph. Every user connects with their own Microsoft account through a normal browser sign-in; the server keeps that authorization, encrypted, and acts *as that user* on every subsequent tool call, so a connection made once keeps working without the client ever holding a Microsoft token. Shared and service mailboxes (`support@`, `billing@`, `info@`) are a first-class identity rather than a parameter bolted onto a few tools.
One TypeScript codebase, three deployment targets:
| Platform | Entry | Build / deploy |
| --- | --- | --- |
| AWS Lambda (Function URL) | `src/entries/lambda.ts` | `npm run build:lambda && npm run deploy:lambda` |
Validate the configuration without touching AWS first:
```bash
DRY_RUN=1 npm run deploy:lambda
```
| Azure Functions (v4 Node) | `src/entries/azure.ts` | `npm run build:azure && func azure functionapp publish …` |
| Plain Node (dev / self-host) | `src/entries/node.ts` | `npm run dev` |
## How this differs from the other Microsoft 365 MCP servers
The open-source field is large and several projects are good. This one is built around a different deployment and identity model, not around more Graph coverage.
- **The server brokers refresh tokens; the client never sees a Microsoft token.** Most existing servers keep one token for one user on one machine — `~/.outlook-mcp-tokens.json`, `~/.microsoft_mcp_token_cache.json`, `~/.office-mcp-tokens.json` — in plaintext. The one mature remote server (Softeria's `ms-365-mcp-server`, in HTTP mode) states outright that token refresh is the client's responsibility, so a session dies when the Graph access token expires after roughly an hour. Here, the per-user Entra refresh token is sealed with AES-256-GCM and stored server-side, and the server silently renews access tokens on the user's behalf.
- **It is stateless and serverless-shaped.** No SSE session affinity, no long-lived process, all session and credential state in DynamoDB. The HTTP-capable alternatives assume an always-on container (Express, Azure Container Apps, an App Service backend behind a local stdio shim).
- **Shared mailboxes are modelled, including the app-only path.** Where the caller holds Exchange rights the server uses their own delegated token against `/users/{mailbox}`; where nobody signs in to the mailbox at all it can use scoped application credentials. No other open-source server offers that second, governed path, and the Exchange-side scoping is shipped here as a script (`deploy/entra/scope-app-only.ps1`) rather than left as an exercise.
- **The user chooses which mailboxes the assistant may use.** Entra has no per-mailbox consent for delegated `Mail.*.Shared`: granting those scopes yields a token that can open every mailbox Exchange lets that person open, and Microsoft offers no way to narrow it. So sign-in is followed by an approval page served by this server, and what the user ticks there is enforced server-side on every request. See [Mailbox approval](#mailbox-approval).
- **There is a governance story.** Per-user tool allowlists that are default-deny for new tools, per-user rate limits, an admin mailbox ceiling on top of the user's own approval, a deployment-level lock on irreversible deletion, and a structured audit record for every call naming the user, the tool, the arguments and the mailbox actually touched.
- **The tool surface is deliberately small.** 27 task-shaped tools, not 300 endpoint-shaped ones. Breadth is not where this competes: Softeria covers Excel ranges and OneNote pages, and Microsoft's own Work IQ servers have semantic search and Defender-grade tracing. What neither offers is a server you host yourself, in your own region, without a Microsoft 365 Copilot licence.
Two first-party options exist and are worth knowing about. Microsoft's **MCP Server for Enterprise** is free but read-only and scoped to Entra directory data — a complement, not a competitor. **Agent 365 / Work IQ** does cover mail, calendar, Teams and SharePoint, but is preview, Microsoft-hosted only, and requires a Microsoft 365 Copilot licence.
## Quick start
**1. Register the Entra application.** This is the step that goes wrong most often. Follow [`deploy/entra/SETUP.md`](deploy/entra/SETUP.md) — in particular, register the platform as **Web**, not SPA (a SPA redirect URI silently caps refresh tokens at 24 hours, and that expiry is inherited by every token derived from them, which would destroy the connect-once premise).
**2. Generate the two keys.** They are different keys with different jobs, and neither can substitute for the other.
```bash
npm install
npm run gen:oauth-key # RS256 keypair — signs the tokens Claude presents to US
npm run gen:enc-key # AES-256-GCM key — seals the tokens WE present to Microsoft
```
Back up the output of `gen:enc-key` in a secrets manager, separately from the deployment. Losing it makes every stored connection undecryptable and forces every user to sign in again at once.
**3. Deploy.**
```bash
npm run build:lambda
npm run deploy:lambda # wraps `sam deploy` against deploy/aws/template.yaml
```
The stack prints an `EntraRedirectUri` output. Register that exact URI on the app registration — it is the one step that cannot be automated.
**4. Connect a client.** Add `https://<your-deployment>/mcp` as a custom connector. The client discovers the OAuth endpoints from `/.well-known/oauth-protected-resource`, identifies itself, and sends the user through the Microsoft sign-in. Both registration models are supported, so Claude's "Use Anthropic's hosted client metadata" option (the recommended one) works as-is: the client presents its metadata URL as its `client_id` and nothing is registered or stored — while clients without a hosted metadata document fall back to dynamic registration (RFC 7591). `OAUTH_CIMD_ALLOWED_CLIENTS` can pin the metadata URLs a deployment will accept. Microsoft's consent screen is then followed by this server's own **mailbox approval page**, where the user ticks which shared mailboxes the assistant may use; the client receives its token only after that. Then call `o365_whoami` — it reports the connection state, the granted permissions, the mailboxes the user approved, and which tools the caller currently holds.
For local development, put the variables from [Configuration](#configuration) in a `.env` file — at minimum the Entra registration, the two keys, and `MCP_USERS_FILE` + `MCP_GRAPH_FILE` + `MCP_OAUTH_FILE` for file-backed stores, which is what lets `npm run dev` run the real browser sign-in and mailbox approval flow without AWS. [`.env.example`](.env.example) is the annotated version. Then:
```bash
npm run dev # http://localhost:3000/mcp
```
Add `http://localhost:3000/oauth/callback` as a second redirect URI on the app registration.
## Architecture
```
Claude / MCP client
│ 1. POST /mcp (Bearer: our RS256 JWT)
▼
┌──────────────────────────────────────────────────────────┐
│ office365-mcp (Lambda Function URL / Azure Fn / Node) │
│ │
│ Hono ── /mcp ── JSON-RPC 2.0 ── tool registry │
│ │ │
│ ├─ OAuth 2.1 authorization server (for the MCP client) │
│ │ /.well-known/* /oauth/register /authorize │
│ │ /callback /consent /token /jwks.json │
│ │ │
│ └─ Graph token broker ── actor resolution ── client │
└───────┬──────────────────────────┬────────────────────────┘
│ │
│ 2. browser sign-in │ 5. Bearer: Graph access token
▼ ▼
Microsoft Entra ID Microsoft Graph
login.microsoftonline graph.microsoft.com/v1.0
│
│ 3. refresh token ──► AES-256-GCM ──► DynamoDB (MCP_GRAPH_TABLE)
│ (row-bound AAD)
▼
4. the browser lands back here, on /oauth/consent — the user ticks
which mailboxes the assistant may use, and only then is the
authorization code handed to the MCP client
```
**Transport.** Streamable HTTP, stateless mode. JSON-RPC 2.0 to `POST /mcp`, **one message per request** — JSON-RPC batching is refused with `-32600`, because it was removed from MCP in revision 2025-06-18 and because an array of calls would ride on a single rate-limit charge. An unauthenticated request gets a 401 with the RFC 9728 `WWW-Authenticate: Bearer realm="mcp", resource_metadata=…` challenge, which is what makes a client start the connector OAuth flow. The protected-resource document is served at both `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp`, and reports `resource` as `{origin}/mcp` — the endpoint the user actually typed, which is what a client compares against.
### The identity model
There are **two separate OAuth relationships**, and keeping them distinct is the whole design:
1. **MCP client ↔ this server.** We are the authorization server. The client registers dynamically (RFC 7591), runs an authorization-code + PKCE flow against `/oauth/authorize` and `/oauth/token`, and receives an RS256 JWT that we signed. Entra does not support dynamic client registration and never sees this exchange.
2. **This server ↔ Entra.** We are a confidential client with one static Web redirect URI. During the user's sign-in we run our *own*, independent PKCE chain toward Entra, redeem the code with our client secret or certificate, and receive an id\_token, a Graph access token and — because we request `offline_access` — a **refresh token**.
That refresh token is the product. It is sealed with AES-256-GCM under additional authenticated data bound to the row (`{tid}:{oid}:refresh`), so a blob lifted out of one user's record cannot be replayed into another's, and written to a table that exists only for credentials. Every later tool call goes: JWT → `(tid, oid)` → cached access token, or a refresh redemption → Graph. The user's key is the immutable pair `(tid, oid)` from the id\_token, never `email`, `preferred_username` or `upn`, all of which are mutable and admin-controllable.
Between the two, `/oauth/callback` **pauses**. Once the Microsoft authorization is stored it does not hand the MCP client its authorization code; it redirects the browser to `/oauth/consent` with a single-use ticket, and the user chooses which mailboxes this assistant may address. Only when they submit that page is the code minted and the client redirected home. See [Mailbox approval](#mailbox-approval).
The tenant allowlist (`O365_ALLOWED_TENANTS`) is re-checked on **every** request, not only at sign-in, so removing a tenant takes effect immediately rather than at the next login.
### Delegated vs app-only
Every Graph call resolves an **actor** first, deterministically from configuration — a model can never talk the server into escalating:
| Actor | Address | When | Sees |
| --- | --- | --- | --- |
| `delegated-self` | `/me` | No `mailbox` argument, or it is the caller's own address | Exactly what the signed-in user sees |
| `delegated-shared` | `/users/{upn}` | A different mailbox, which the user approved at connect time, the admin policy permits, and the caller has Exchange rights on | What Exchange has granted that user on that mailbox |
| `app-only` | `/users/{upn}` | The mailbox is in `O365_APP_ONLY_MAILBOXES`, app-only is enabled, and the caller's policy has `allowAppOnly` | Whatever Exchange RBAC scopes the application to |
Every actor other than `delegated-self` passes `policyAllowsMailbox` first — the user's own approval, then the administrator's ceiling — before Graph is asked anything. Delegated is the default and the normal path: past those two gates the tenant's existing permissions remain the real gate, and a denied call produces an honest 403 that the server translates into "ask an administrator for Full Access on that mailbox". App-only exists only for mailboxes nobody signs into, is off by default, and is described in full below. `/me` never means a shared mailbox — there is no `/me` path into one — and an app-only token may never use `/me` at all, because there is no signed-in user.
Teams is delegated-only by design, permanently. See [Limitations](#limitations-and-known-issues).
## Tools exposed to the model
27 tools. **W** marks a tool that changes tenant state; those are disabled by default for a newly provisioned user and additionally require `allowWrites` on their policy. Every Outlook tool takes an optional `mailbox` argument (a UPN or SMTP address) selecting the mailbox to act on; omit it for your own. All ids returned are opaque Microsoft Graph ids — pass them back verbatim, and never construct one.
### Outlook — reading
| Tool | What it does |
| --- | --- |
| `o365_mail_search` | Full-text search a mailbox using Outlook search syntax (`from:`, `subject:`, `attachment:`, `hasAttachments:true`, …). Always sorted by date, capped at 1,000 results by Microsoft, and cannot be combined with filters. |
| `o365_mail_list` | List messages in a folder with structural filters and sorting — unread only, a sender, a date range, an order. The counterpart to `o365_mail_search`: precise filtering, no keyword matching. |
| `o365_mail_get` | One message in full, body as plain text, optionally with internet message headers and attachment metadata. Long bodies are truncated with the original length reported. |
| `o365_mail_folders` | List mail folders, top level or the whole tree, with message and unread counts. Use it to resolve a folder id before moving messages. |
| `o365_mail_attachments_list` | Attachment names, types, sizes and inline flags for one message. Metadata only — never file contents. |
| `o365_mail_attachment_download` | Download an attachment and return a short-lived presigned URL. Never returns bytes inline. |
### Outlook — writing
| Tool | What it does |
| --- | --- |
| **W** `o365_mail_send` | Send a message immediately, composed inline or from a draft. Graph accepts for delivery and returns no id, so this reports "accepted", not "delivered". |
| **W** `o365_mail_reply` | Reply, reply-all or forward an existing message in one step. Your text goes above the quoted original. |
| **W** `o365_mail_draft_create` | Create an unsent draft, from scratch or as a reply/forward that already quotes the original. Returns the draft id. |
| **W** `o365_mail_draft_update` | Edit an unsent draft's subject, body or recipients. Only works on drafts. |
| **W** `o365_mail_move` | Move or copy a message to another folder. Moving **changes the message id** — the new one is returned and the old one stops working. |
| **W** `o365_mail_delete` | Delete messages: `trash` (recoverable, the default), `soft`, or `permanent` — irreversible, and additionally gated by `O365_ALLOW_PERMANENT_DELETE`. |
| **W** `o365_mail_flags` | Mark read/unread, flag, categorise and set importance on up to 20 messages at once. |
| **W** `o365_mail_folder_manage` | Create, rename, move or delete a mail folder. |
| **W** `o365_mail_attachment_add` | Attach a file to a draft. Under 3 MB inline, up to 150 MB via a chunked upload session. |
### Teams
| Tool | What it does |
| --- | --- |
| `o365_teams_list` | Your teams, or the channels in one team. The way to resolve a team or channel name to the ids the other Teams tools need. |
| `o365_teams_chats_list` | Your chats — one-to-one, group and meeting — most recently active first. One-to-one chats have no name of their own, so one is built from the participants. |
| `o365_teams_messages_list` | Read messages from a channel or a chat. Chats support a date range; channels do not, because Graph's channel API accepts no date filter. |
| **W** `o365_teams_message_send` | Post as yourself to a channel, into a channel thread, or to a chat — including to people by email, which finds or creates the one-to-one chat. Takes **no `user` argument**: Teams attributes every message to the signed-in user, so offering one would advertise an impersonation that cannot happen. |
| `o365_teams_search` | Keyword search across every chat and channel you can see. The only way to search Teams; the listing APIs have no search at all. Takes **no `user` argument** either — `/search/query` is scoped to the token's owner and has no "search as" parameter. |
### SharePoint and OneDrive
| Tool | What it does |
| --- | --- |
| `o365_files_search` | Search files across SharePoint and OneDrive, or within one site or library. Supports KQL terms (`filetype:`, `author:`, `path:`). |
| `o365_files_sites` | Find sites, or list a site's document libraries and their drive ids. The starting point for SharePoint work. |
| `o365_files_list` | List a folder in OneDrive or a document library, by drive + item id, by path, or the root of your own OneDrive. |
| `o365_files_get` | Details of one file or folder — including from a pasted sharing URL, which it resolves. Optionally reports who has access. |
| `o365_files_download` | Download a file as a presigned URL, optionally converted to PDF on the way out. Never inline. |
| **W** `o365_files_share` | Share by link or by inviting people. Reports the access **actually** granted, because the tenant's sharing policy can silently downgrade what you asked for. |
### Core
| Tool | What it does |
| --- | --- |
| `o365_whoami` | Who you are signed in as, whether the Microsoft connection is live and when it last refreshed, which permissions were granted, which tools you hold, which shared mailboxes you approved and can actually use right now, and for each of them whether a call would run as you or as the service account. Call this first when something fails. |
## Configuration
Everything is environment-only. The authoritative list is the `Env` type in [`src/config.ts`](src/config.ts).
### Entra app registration
| Var | Required | Description |
| --- | --- | --- |
| `OAUTH_ENTRA_TENANT_ID` | yes | Tenant GUID or verified domain. Every call targets this concrete tenant — `/common` causes token-cache misses and needless re-auth, and is invalid for client credentials. `common` / `organizations` turn the deployment multi-tenant. |
| `OAUTH_ENTRA_CLIENT_ID` | yes | Application (client) ID. The registration must use platform type **Web**. |
| `OAUTH_ENTRA_CLIENT_SECRET` | one of | Client secret. Simplest, but Entra caps its lifetime at 24 months. |
| `OAUTH_ENTRA_CLIENT_CERT_PEM` | one of | PKCS#8 PEM private key (literal or base64) for certificate client authentication. Preferred in production. |
| `OAUTH_ENTRA_CLIENT_CERT_THUMBPRINT` | with cert | Hex SHA-1 thumbprint as shown in the portal. Entra matches the assertion to the certificate by thumbprint, so both halves are required. |
| `O365_ALLOWED_TENANTS` | | Comma-separated tenant ids accepted when running multi-tenant. Checked at sign-in **and again on every request**, so removing a tenant here locks out its existing connections immediately rather than at their next login. Empty with `common` means *any* tenant that consents can connect; the server warns loudly at startup. |
### Keys
| Var | Required | Description |
| --- | --- | --- |
| `OAUTH_SIGNING_KEY_PRIVATE` | yes | Base64 PKCS#8 PEM of the RS256 key signing *our* MCP access tokens. `npm run gen:oauth-key`. |
| `OAUTH_SIGNING_KEY_PUBLIC` | yes | Base64 SPKI PEM of the matching public key. Published at `/.well-known/jwks.json`. |
| `OAUTH_SIGNING_KEY_KID` | | Key id in the JWKS and token headers. Change it alongside a keypair rotation. Default `primary`. |
| `O365_TOKEN_ENC_KEY` | yes | `<kid>:<base64 32 bytes>` — seals every stored Entra refresh token and cached access token. `npm run gen:enc-key`. **Losing it breaks every stored connection permanently.** |
| `O365_TOKEN_ENC_KEYS_PREVIOUS` | | Comma-separated retired keys in the same form, accepted for decryption only. This is what makes rotation a rolling operation rather than a flag day. |
| `OAUTH_CIMD_ALLOWED_CLIENTS` | | Comma-separated Client ID Metadata Document URLs accepted as `client_id`s (Claude Code's is `https://claude.ai/oauth/claude-code-client-metadata`). Empty accepts any https URL whose document validates, which is the spec default. |
### Storage
| Var | Required | Description |
| --- | --- | --- |
| `MCP_GRAPH_TABLE` | yes¹ | DynamoDB table for sealed refresh tokens (no TTL) and cached access tokens (TTL). Deliberately separate from the OAuth table so credentials get their own IAM boundary and backup policy. |
| `MCP_GRAPH_FILE` | | JSON-file fallback for self-hosting and development. Ignored when `MCP_GRAPH_TABLE` is set; not viable on Lambda. |
| `MCP_OAUTH_TABLE` | yes¹ | DynamoDB table for our own OAuth state — registered clients, login states, auth codes, refresh tokens. TTL on `expiresAt`. |
| `MCP_OAUTH_FILE` | | JSON-file fallback for the same state, so `npm run dev` can run the real browser sign-in flow without AWS. Ignored when `MCP_OAUTH_TABLE` is set; not viable on Lambda, where each container would see different state. |
| `MCP_USERS_TABLE` | | DynamoDB table for users and policies, with `keyPrefix-index` and `oid-index` GSIs. |
| `MCP_USERS_FILE` | | JSON user store for self-hosting and development. Ignored when `MCP_USERS_TABLE` is set. |
| `MCP_SHARED_SECRET` | | Legacy single-admin bearer that bypasses the user store. Useful for smoke tests. It has no Graph connection of its own, so Graph tools return a reconnect error unless it maps to a user who has signed in. |
¹ or the corresponding `_FILE` variant (`MCP_GRAPH_FILE` / `MCP_OAUTH_FILE`) for local development.
### Graph behaviour
| Var | Default | Description |
| --- | --- | --- |
| `O365_SCOPE_PROFILE` | `work` | `work` includes the shared-mailbox, SharePoint-site and Teams-channel scopes, several of which need one tenant-admin consent. `personal` requests only user-consentable scopes. |
| `O365_SCOPES` | derived | Space-separated wholesale override. Validated on load: `/.default` may not be mixed with named scopes (AADSTS70011), and `offline_access` is mandatory. |
| `O365_GRAPH_BASE` | `https://graph.microsoft.com/v1.0` | Change only for sovereign clouds, where several capabilities used here do not exist. |
| `O365_IMMUTABLE_IDS` | `true` | Sends `Prefer: IdType="ImmutableId"` on Outlook calls so ids survive a move. **Decide once at first deployment and never flip it on a live stack.** |
| `O365_BODY_FORMAT` | `text` | `text` requests plain-text bodies, which is what a model-facing server wants — HTML bodies are dominated by tracking markup. |
| `O365_GRAPH_TIMEOUT_MS` | `30000` | Per-request timeout, kept well under the client's 300-second tool timeout. |
| `O365_MAX_CONCURRENCY_PER_MAILBOX` | `4` | In-flight cap per (application, mailbox). Exchange allows exactly four; the value is clamped there because raising it only converts throughput into 429s. |
| `O365_USER_AGENT` | `NONISV\|SelfHosted\|office365-mcp/0.1.0` | Microsoft deprioritises undecorated traffic. Keep the documented shape and put your own company name in the middle field. |
| `O365_SEARCH_REGION` | auto | SharePoint geography (`NAM`, `EUR`, `APC`) for `POST /search/query`. Required for app-only search; set it explicitly on multi-geo tenants. |
### Gates and output
| Var | Default | Description |
| --- | --- | --- |
| `O365_APP_ONLY_ENABLED` | `false` | Master switch for app-only mode. While false the code path is unreachable regardless of any allowlist. |
| `O365_APP_ONLY_MAILBOXES` | empty | Comma-separated mailbox addresses reachable with application credentials. A wildcard is rejected outright. |
| `O365_APP_ONLY_SITES` | empty | Comma-separated site ids or URLs reachable with application credentials, enforced on every site- or drive-addressed call made with an app-only actor. An empty list means app-only never reaches SharePoint at all, and an app-only call that names no site is refused rather than allowed. Matching is case-insensitive and either exact or a prefix that ends on a `/` path boundary, so an entry can cover a site and everything under it without a shorter reference widening access. Pairs with a `Sites.Selected` registration. |
| `O365_ALLOW_PERMANENT_DELETE` | `false` | Deployment-level lock on `o365_mail_delete` mode `permanent`, on top of the per-user policy. |
| `MCP_OUTPUT_FORMAT` | `toon` | `toon` emits compact tabular output that materially cuts token use on listings; `json` emits pretty JSON for programmatic consumers. |
| `MCP_ARTIFACT_BUCKET` | | S3 bucket for downloads. Required by every download tool — there is no base64 fallback by design. |
| `MCP_ARTIFACT_URL_TTL_SECONDS` | `3600` | Lifetime of presigned URLs. Anyone holding one can fetch the file without authenticating, so keep it short. |
| `MCP_ARTIFACT_REGION` | `AWS_REGION` | Overrides the region for the artifact bucket. |
| `MCP_AUDIT_FILE` | | JSONL sink for audit and security records, in addition to stderr. For self-hosted deployments; on Lambda, stderr already reaches CloudWatch. |
| `MCP_AUDIT_READS` | unset | `1` audits read tools as well as writes. Off by default because reads dominate volume. Independently of this setting, every mutating call, every failure, and **every call naming another mailbox or user** is always recorded — acting on someone else's mailbox is exactly what a compliance review asks about. |
| `PORT` | `3000` | Listen port for the plain Node entry point. Unused on Lambda and Azure Functions. |
## Shared mailbox access
This is the feature the design is built around, and the capability itself depends on the tenant rather than on this server.
**Two things must both be true before Graph will do it at all.** The connection needs the delegated `.Shared` Graph scopes (`Mail.Read.Shared`, `Mail.ReadWrite.Shared`, `Mail.Send.Shared` — all present in the `work` scope profile), *and* Exchange Online must have granted the signed-in user rights on the target mailbox. The scope only unlocks the capability; **Exchange is the actual gate.** Without the Exchange grant, Graph returns 403 no matter what was consented.
An administrator grants one or more of these in the Exchange admin center (Recipients → Mailboxes → the shared mailbox → Delegation):
| Right | What it enables | Effect |
| --- | --- | --- |
| **Full Access** | Reading, listing, moving, deleting, drafting in the mailbox | Required for every read and write tool against that mailbox. Also required if a send should land a copy in the shared mailbox's Sent Items. |
| **Send As** | Sending with the shared mailbox as the sender | The recipient sees only the shared mailbox. |
| **Send on Behalf** | Sending on the mailbox's behalf | The recipient sees "*user* on behalf of *shared mailbox*". Users can grant this themselves in Outlook; only an admin can grant Send As. |
**Permissions can take up to an hour to take effect** after they are granted. A 403 immediately after a grant is usually just that, and the server says so in the error.
On top of those two, this server adds its own two: the user must have approved the mailbox when they connected, and the administrator's `allowedMailboxes` ceiling must permit it. Both are checked before Graph is asked anything — see [Mailbox approval](#mailbox-approval).
Then simply pass the address: `o365_mail_list({ mailbox: "support@contoso.com", unreadOnly: true })`. The server resolves the actor, calls `/users/support@contoso.com/…`, and never `/me` — there is no `/me` path into a shared mailbox. The address must also have been approved by the user at connect time; see [Mailbox approval](#mailbox-approval) below.
Two constraints worth knowing up front. There is **no Graph API that enumerates which mailboxes a user has rights on** — which is why the approval page *verifies* a candidate address rather than listing them for you, and why the caller still names the mailbox on a tool call. And the signed-in user generally needs their own licensed mailbox, though the shared mailbox itself does not need a licence.
**Narrowing it further.** `policy.allowedMailboxes` is the **administrator's ceiling**, on top of what the user themselves approved; effective access is the intersection of the two. It defaults to `"*"`, which defers the remaining decision to Exchange where it belongs. Set it to an explicit list to constrain a user below their Exchange rights, or to `null` to reject a `mailbox` argument outright:
```bash
npm run user -- add alice --mailboxes=support@contoso.com,billing@contoso.com
```
## Mailbox approval
Addresses are entered by hand. Microsoft exposes no API that lists the mailboxes a person can open,
and inferring it from who they correspond with produced a list that was mostly wrong — so the page
does not guess. What it does do is *verify*: every address typed in is checked against Exchange
before it can be approved, and shown as available or unavailable with the reason.
The signed-in user's own mailbox is a normal entry on that list and can be removed. An assistant
built around a shared support mailbox has no business reading the operator's personal inbox, so
that has to be expressible. Withholding it makes every Outlook tool refuse a call that does not name
another approved mailbox; Teams and SharePoint are unaffected, because neither passes through the
mailbox gate.
**Microsoft cannot restrict this grant, so this server does.** Entra offers no per-mailbox consent for delegated `Mail.*.Shared`. The moment a user grants those scopes, the resulting token can open every mailbox Exchange lets that person open, and there is no Microsoft-side way to narrow it — SharePoint gained delegated `Sites.Selected` in 2024, Exchange has no equivalent and no roadmap entry for one. The approval page below is therefore not a nicety: it is the only thing that restricts the grant, and it is enforced **server-side, on every request, before any Graph call** (`policyAllowsMailbox` in [`src/users.ts`](src/users.ts), reached from `resolveActor`).
**The flow.** `/oauth/authorize` → Entra sign-in → `/oauth/callback` stores the sealed refresh token and then, instead of handing the MCP client its authorization code, redirects to `/oauth/consent` with a single-use ticket (15 minutes). The page shows the user's own mailbox — always included, never removable — plus candidate shared mailboxes, each already probed so an address that cannot be opened is shown greyed out with the reason rather than accepted and failing later. They tick what this assistant may use, and only then is the authorization code minted and the client redirected home. Connecting again re-runs the page with the previous choice pre-ticked, which is also how a user *removes* a mailbox later.
**What the probe cannot see.** A 403 on the Inbox has three distinct causes, and only one of them is "no access at all". A user who holds only **Send As**, or who has been given access to a *single folder* rather than the whole mailbox, fails the Inbox probe even though that narrower access would work for the operation they want. The page says so where the mailbox appears; the honest summary is that the probe under-reports rather than over-reports. It never produces a false positive — an address that probes 200 is one the server can genuinely open.
**Two gates, both server-side.** `grantedMailboxes` is what the **user** approved; `allowedMailboxes` is the **administrator's** ceiling. A mailbox is reachable only when it appears in both, and `o365_whoami` reports that intersection as `usableSharedMailboxes` so the model only ever sees mailboxes it may actually use. The user's own mailbox is always permitted and appears in neither list.
An **API-key service account never sees the page** — there is no browser and no human to ask — so the consent gate does not apply to it. That is deliberate: the administrator who created the key is the consenting party, and `allowedMailboxes` governs alone. The distinction is drawn on whether the identity has an Entra `oid`, i.e. whether it ever went through a browser sign-in.
## App-only mode
App-only exists for one situation: a mailbox nobody signs into, that nobody has been delegated, which an agent should still triage. It uses the application's own credential rather than a user's, so there is no signed-in user and `/me` is invalid.
**It is off by default and should stay off unless you need it**, because an admin-consented application `Mail.ReadWrite` grants access to **every mailbox in the organisation**. Turning it on requires three independent things: `O365_APP_ONLY_ENABLED=true`, the mailbox listed in `O365_APP_ONLY_MAILBOXES`, and `allowAppOnly` on the calling user's policy. A mailbox that is allowlisted but whose caller lacks `allowAppOnly` simply falls back to delegated and lets Exchange answer. Escalating to app-only does not skip the two mailbox gates: `resolveActor` applies them before it considers the app-only branch at all, so a signed-in user still has to have approved the address on the approval page. The typical app-only caller is an API-key service account, which is never asked and for which `allowedMailboxes` is the whole control.
**`O365_APP_ONLY_MAILBOXES` is only half the control, and it is the weaker half.** It constrains what *this code* will ask for. It does nothing to the credential itself: anyone who obtains it reaches every mailbox in the tenant. The real control is **Exchange RBAC for Applications**, applied tenant-side. [`deploy/entra/scope-app-only.ps1`](deploy/entra/scope-app-only.ps1) scripts it: register the service principal in Exchange, create a management scope over a mail-enabled security group, assign the `Application Mail.*` role restricted to that scope, and verify with `Test-ServicePrincipalAuthorization`.
**The trap that defeats the whole thing:** RBAC grants are **additive** with Entra grants. If the unscoped application permission remains consented on the app registration, the union of the two is what applies and the scoping accomplishes nothing. The Entra application permission must be removed. Also budget for the permission cache: changes take **30 minutes to 2 hours** to take effect (`Test-ServicePrincipalAuthorization` bypasses the cache, which is why the script ends with it).
Teams has no app-only path here at all — see below.
## Per-user tool permissions
When a user store is configured (`MCP_USERS_TABLE`, or `MCP_USERS_FILE` for development), every user record carries a `policy`:
| Field | Meaning |
| --- | --- |
| `allowedTools` | `"*"` grants everything including tools shipped in future — the all-access setting for admins and service accounts. An array is a fixed allowlist. |
| `toolPermissions` | Per-tool `{name: boolean}` map. **Default-deny**: a tool is callable only when its entry is `true`. Supersedes an `allowedTools` array; `"*"` still wins. |
| `allowWrites` | Required on top, for every tool marked as mutating. |
| `grantedMailboxes` | What the **user** approved on the mailbox approval page at connect time. Absent means they were never asked, and only their own mailbox is reachable. Written by `/oauth/consent`, not by the admin CLI. |
| `allowedMailboxes` | The **administrator's** ceiling on top of that: `"*"` (default), an explicit list of addresses, or `null` for own mailbox only. Effective access is the intersection with `grantedMailboxes`. For an API-key identity, which never sees the approval page, this is the whole control. |
| `allowAppOnly` | Per-user gate for the app-only actor. Default `false`, so a careless or compromised user can never silently escalate past their own Exchange rights. |
| `rateLimitPerMin` | Per-user call ceiling. Default 60. |
| `disabled` | Kills the identity without deleting it. |
Tools a user cannot call are also **hidden from `tools/list`**, so the model never sees them. On every OAuth login the map is reconciled against the live registry: **newly shipped tools are added as `false`**, so a new — possibly destructive — tool is never silently granted; removed tools are pruned. The store is written only when something changed.
A newly provisioned user gets every read-only tool enabled and every mutating tool disabled.
### Admin CLIs
```bash
npm run user -- list
npm run user -- add alice --writes --mailboxes=support@contoso.com --app-only
npm run user -- tools alice # the effective 27-tool map
npm run user -- tools alice --enable=o365_mail_send
npm run user -- rotate alice # new API key, old one dead
npm run user -- disable alice
```
```bash
npm run connection -- list # who is connected, scopes, last refresh — never token material
npm run connection -- test alice@contoso.com # one live Graph call, proves the stored credential still redeems
npm run connection -- revoke alice@contoso.com # server-side kill switch: delete the row, purge cached tokens
npm run connection -- rewrap # re-seal every stored secret under the current encryption key
```
`connection revoke` stops *this server* using the credential. The authoritative tenant-side kill switch is **Revoke Sessions** on the user object in Entra — note that a password change alone does **not** invalidate a confidential-client refresh token (see the revocation matrix in [SECURITY.md](SECURITY.md)).
## Limitations and known issues
- **On AWS, the `WWW-Authenticate` challenge header is renamed.** A Lambda Function URL rewrites it
to `x-amzn-Remapped-WWW-Authenticate`, and nothing inside the function can prevent that. It does
not break discovery: the MCP specification requires clients to fall back to fetching
`/.well-known/oauth-protected-resource/mcp` (then the root variant) directly, and the reference
SDK does so unconditionally on a 401 — which is why this server serves both documents and reports
a `resource` value byte-identical to its MCP endpoint URL. If you meet a client that genuinely
needs the header, put CloudFront in front with a Lambda@Edge **origin-response** function that
copies the remapped name back; a viewer-response function will not work, because CloudFront does
not invoke those when the origin returns 400 or higher.
Stated plainly, because most of these will be experienced as bugs otherwise.
**Refresh tokens die in ways that look random.** The 90-day window is *sliding inactivity*, not a fixed expiry: a user who connects weekly effectively never expires, while one who goes quiet for 91 days comes back dead (AADSTS70008 / 700082). Independently, Conditional Access sign-in frequency forces re-authentication on its own cadence and no server-side code can prevent it. An admin resetting a password through the Entra or Microsoft 365 admin center revokes tokens immediately; a user changing their own password does **not**. Every one of these surfaces as a single clear tool error carrying the URL to reconnect.
**A client secret expiry is a cliff, not a slope.** Entra caps secret lifetime at 24 months, and when it expires *every user of the deployment fails at once* with AADSTS7000222 — not gradually, not one at a time. Certificate credentials avoid the failure mode; rotate either well before the date.
**Losing the encryption key is unrecoverable.** No key, no stored connections, and every user must sign in again simultaneously. Back it up separately and rotate it through `O365_TOKEN_ENC_KEYS_PREVIOUS` + `connection rewrap`, never by replacement.
**Teams sending is delegated-only, permanently.** Every Graph send endpoint offers `Teamwork.Migrate.All` as its sole application permission and Microsoft restricts it to migration scenarios. There is no compliant way to post Teams messages from a service account in this architecture; the alternatives are a Bot Framework bot or a Teams app package with resource-specific consent installed per team, neither of which fits a standalone remote MCP server. Unattended Teams posting is not on the table. (Teams *metering* is not a concern: the model A / model B billing regime ended on 25 August 2025, despite what most existing documentation still says.)
**Teams channel scopes need tenant-admin consent.** `ChannelMessage.Read.All` in particular cannot be self-consented. A self-hoster without admin rights running `O365_SCOPE_PROFILE=personal` gets working mail, files and chat, and channel tools that fail with an explanation rather than a mystery 403. `personal` also omits `User.ReadBasic.All`, so an @-mention of someone outside the conversation cannot be resolved: the message is still sent, the name stays in the body as plain text, and the tool returns a warning saying the person was not notified.
**The mailbox approval page under-reports, never over-reports.** It decides whether you can use a mailbox by trying to open its Inbox, and a 403 there has three causes. If you hold only *Send As*, or access to one folder rather than the whole mailbox, the address shows as unavailable and cannot be ticked even though that narrower access would have worked for what you wanted. The reverse mistake cannot happen — an address you can tick is one the server can genuinely open.
**Search has hard ceilings that look like data loss.** Outlook `$search` returns at most 1,000 results and cannot be combined with filters or custom sorting. Teams search reports a page count rather than a total, so it can never be presented as a match count. SharePoint deep paging stops beyond result 1,000, and an app-only search excludes private OneDrive content by default — enabling it provisions a new index that can take days to a week, during which results are quietly incomplete with no error at all.
**Tenant sharing policy silently rewrites what `o365_files_share` produces.** Organisation-level and per-site settings can downgrade an anonymous link to organisation-only, force an expiry, or make links view-only. Worse, `createLink` is idempotent per (application, link type), so a request for a fresh seven-day link can return a years-old never-expiring one with different scope. The tool always reads back and reports the *actual* grant, which is the only defence.
**Message ids change when messages move, and Teams ids are not globally unique.** `O365_IMMUTABLE_IDS` defaults to on for exactly this reason, but it is effectively a one-way door: ids handed out under one format do not work under the other, and flipping it on a live stack produces `ErrorInvalidIdMalformed`. Separately, a Teams message id is unique only within its chat or channel, so message ids are always returned with their conversation coordinates.
**Throttling is the most likely day-to-day failure.** Outlook permits four concurrent requests per (application, mailbox) and 10,000 per ten minutes; Teams permits roughly one request per second per channel, per chat, and per user; SharePoint charges five resource units per permissions call and throttles search far tighter than the rest of Graph. Batching does not help — Graph forwards at most four sub-requests from a batch to Outlook concurrently. The server bounds its own fan-out and honours `Retry-After` exactly, but an eager agent will still meet a 429 eventually.
**Attachments over 3 MB do not work in a shared mailbox.** Microsoft documents that a delegated caller gets a 403 attaching large files to a message in a shared or delegated mailbox. Under 3 MB is fine. The tool says so rather than surfacing a bare 403.
**Sovereign clouds quietly lack features.** Permanent delete, chat delta and the Teams export APIs are unavailable in US Government L4/L5 and China 21Vianet, and cross-geo site access can fail for reasons unrelated to app permissions. Multi-geo tenants need one search request per region, or content elsewhere is silently missing.
**Refresh-token rotation races are benign but real.** Entra issues a new refresh token on every redemption and does not revoke the old one, so two concurrent invocations for the same user each receive a valid successor. A conditional write means one wins and the loser discards its copy; a lost race never fails a tool call. The access-token cache is what keeps it rare.
## Roadmap
These are **deliberate v1 scope cuts, not oversights**:
- **Calendar.** The second thing anyone expects after mail. `Calendars.ReadWrite` is user-consentable and the actor model already built for shared mailboxes applies unchanged to shared calendars. Sequenced next.
- **File upload** to OneDrive and SharePoint. v1 covers search, list, get, download and share.
- **Contacts / people lookup** to resolve a name to an email address. Every send tool currently assumes the caller already has one.
- **Inbox rules** (`messageRules`, needs `MailboxSettings.ReadWrite`) for server-side triage automation.
Also under consideration: a pluggable audit sink (Firehose → S3 → Athena) beyond stderr, workload identity federation as a third client-credential type on AWS so no long-lived secret exists at all, and server-minted opaque page handles instead of raw `@odata.nextLink` strings.
Directory administration is deliberately out of scope — Microsoft's free MCP Server for Enterprise already covers read-only Entra queries.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Security issues: [SECURITY.md](SECURITY.md) — please do not open a public issue.
## Licence
MIT. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues