outlook-mcp-limited
# outlook-mcp-limited
A local [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that gives an AI assistant
(Claude Desktop, Claude Code, Cursor, …) **read + draft-only** access to one personal Microsoft mailbox
(`@live.nl`, `@outlook.com`, `@hotmail.com`). It can search, list and read mail, list folders, save
attachments to a jailed folder, and create drafts. It **cannot send, delete, move or mark mail**, and it
never touches calendar, contacts or files.
This is a cut-down fork of [xdarkoy/outlook-mcp](https://github.com/xdarkoy/outlook-mcp) (MIT). The
fork removes six tools and one OAuth scope, and adds two hard-coded allowlists that make the server
refuse to start if anyone widens them. See [docs/decision-note.md](docs/decision-note.md) for why this
base was chosen.
It runs in two modes: **stdio** on your own machine (default, 6 tools), or **hosted** over Streamable
HTTP behind a bearer token for cloud agents such as Cursor's Grok Bot (5 tools, no attachment saving).
Hosted mode puts your refresh token and your mail content on other people's servers; read
[docs/hosting.md](docs/hosting.md) before you use it.
## What it can and cannot do
| Allowed (tool) | Forbidden (no tool, and no OAuth scope for it) |
|---|---|
| `search_emails` — full-text search across the mailbox | Send mail (`Mail.Send` is never requested) |
| `list_emails` — list a folder with filters | Send an existing draft |
| `read_email` — one message, body as plain text, attachment metadata | Delete mail |
| `list_folders` — folder tree, one level per call | Move or mark mail |
| `save_attachment` — write one attachment into `~/Downloads/outlook-mcp/` (stdio mode only) | Calendar, contacts, OneDrive, shared mailboxes |
| `create_draft` — new draft or reply draft in Drafts; never sends | Anything as an application (no client secret, no app-only permissions) |
## Security model
**1. The OAuth token cannot send.** The server requests exactly four delegated scopes, hard-coded in
[`src/auth/scopes.ts`](src/auth/scopes.ts):
| Scope | What Microsoft says it allows | Why we need it |
|---|---|---|
| `offline_access` | Refresh tokens | Sign in once, not every hour. Refresh tokens last 90 days by default [9]. |
| `User.Read` | Sign in and read the user's profile | Identify the signed-in account (MSA vs work) |
| `Mail.Read` | "Read user mail" [5] | search, list, read, list folders, save attachments |
| `Mail.ReadWrite` | "Create, read, update and delete email in user mailboxes" [5] | `create_draft` — Graph requires `Mail.ReadWrite` to create a draft [8] |
`Mail.Send` is the only permission that lets a token call `POST /me/sendMail` [6] or
`POST /me/messages/{id}/send` [7]. It is not requested, so both calls fail with HTTP 403. The consent
screen you see at first login therefore never says "send mail as you". `npm run smoke:live` proves this
against your real mailbox (see Acceptance checklist).
**2. The tool list cannot grow by accident.** [`src/tools/registry.ts`](src/tools/registry.ts) holds the
six allowed tool names. At startup `main()` runs both guards; on any extra, missing or duplicate tool, or
any scope outside the allowlist, the process prints `refusing to start` and exits 1 before it serves a
single request. The tests in `scripts/test-policy.mjs` and `scripts/test-mail-trust-boundary.mjs` pin
both lists a third time.
**3. Residual risk you should know about.** `Mail.ReadWrite` also covers update and delete [5]. This
server exposes no tool for that, but the *token* could do it. Anyone who steals
`~/.outlook-mcp/cache.json` can read your mail and edit or delete messages until you revoke consent.
Treat that file like a password.
**4. Token cache.** MSAL writes `~/.outlook-mcp/cache.json` (override: `OUTLOOK_MCP_CACHE_DIR`) with
mode `0600`, via atomic write-to-temp-and-rename. No cloud, no keychain, no telemetry. To wipe it:
```bash
rm -rf ~/.outlook-mcp
```
**5. Revoke.** Go to <https://microsoft.com/consent>, sign in with the mailbox account, open the app and
choose **Remove these permissions** [10]. Then delete the cache as above. Deleting the app registration
in Entra also kills every token issued for it.
**6. No client secret.** The app registration is a public client using the device code flow [2]. Nothing
secret is stored in this repo or on disk except the token cache itself.
**7. Attachments are jailed.** `save_attachment` only writes inside `OUTLOOK_MCP_ALLOWED_DIR`
(default `~/Downloads/outlook-mcp/`), never overwrites, and rejects path traversal.
**8. Hosted mode is locked and stateless.** In `http` mode, `initialize`, `ping` and `tools/list` are
public metadata (tool names and descriptions, nothing else) so a hosting platform can register the
server. Every other request to `/mcp` must carry `Authorization: Bearer <MCP_AUTH_TOKEN>` (32+
characters, compared in constant time) and is rate limited (default 60/min). Each request gets a fresh
MCP server; nothing is kept between requests. The signed-in session comes from the
`OUTLOOK_MCP_TOKEN_CACHE` variable, which you create locally with `npm run export-token`. Without a
usable `MCP_AUTH_TOKEN` the server runs **locked**: mail calls answer 503 and `GET /healthz` reports
`"status":"locked"` plus which settings are present (never their values). Full runbook and the risks
you accept: [docs/hosting.md](docs/hosting.md).
## Quick start (about 30 minutes, once)
1. **Register the app in Microsoft Entra** — follow [docs/entra-setup.md](docs/entra-setup.md). You end
with an *Application (client) ID*. Personal accounts only, public client flows on, four delegated
permissions, no secret.
2. **Install and build** (Node 20 or newer; this repo was built with Node 26):
```bash
cd /path/to/outlook-mcp
npm install
npm run build
```
3. **Sign in once** (device code flow; prints a URL and a code):
```bash
OUTLOOK_MCP_CLIENT_ID=<your-client-id> npm run login
```
Open the URL, enter the code, sign in with `you@live.nl`, and read the consent screen: it must
list only read/write mail, profile and offline access — **not** "send mail as you". Accept.
4. **Smoke test against the real mailbox:**
```bash
OUTLOOK_MCP_CLIENT_ID=<your-client-id> npm run smoke:live
```
Expected: every line `PASS`, including the two `AC5 … refused` lines with `HTTP 403`. The script
leaves one draft addressed to yourself in Drafts; check it in Outlook web, then delete it by hand.
5. **Connect a client** — see [docs/wire-up-notes.md](docs/wire-up-notes.md). Short version for Claude
Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS [13]):
```json
{
"mcpServers": {
"outlook": {
"command": "node",
"args": ["/path/to/outlook-mcp/dist/index.js"],
"env": { "OUTLOOK_MCP_CLIENT_ID": "<your-client-id>" }
}
}
}
```
## Configuration
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `OUTLOOK_MCP_CLIENT_ID` | **yes** | — | Application (client) ID of your Entra app registration. Not a secret, but keep it out of git. |
| `OUTLOOK_MCP_TENANT` | no | `consumers` | Authority tenant. `consumers` = personal Microsoft accounts only, which is what this fork is for and which blocks an accidental work-account login. `common` also accepts work accounts. |
| `OUTLOOK_MCP_ALLOWED_DIR` | no | `~/Downloads/outlook-mcp/` | Where `save_attachment` may write files. |
| `OUTLOOK_MCP_CACHE_DIR` | no | `~/.outlook-mcp/` | Token cache location. |
| `OUTLOOK_MCP_MAX_ATTACHMENT_MB` | no | `50` | Hard cap before `save_attachment` aborts. |
| `MCP_AUTH_TOKEN` | http mode: **yes** | — | Shared secret clients must send as `Authorization: Bearer …`. At least 32 characters. |
| `OUTLOOK_MCP_TOKEN_CACHE` | http mode: yes | — | Base64 of the local token cache, from `npm run export-token`. Contains the refresh token: secret. |
| `PORT` / `HOST` | no | `3000` / `0.0.0.0` | Where http mode listens. Setting `PORT` (as hosts do) selects http mode automatically. |
| `MCP_RATE_LIMIT_PER_MIN` | no | `60` | Accepted requests per minute in http mode. |
| `OUTLOOK_MCP_MODE` | no | — | Set to `http` to force hosted mode without a `PORT`. |
Copy [`.env.example`](.env.example) to `.env` if you prefer a file; nothing in this repo reads `.env`
automatically, so pass the values through your MCP client's `env` block or your shell.
## Commands
```
node dist/index.js # MCP stdio server (what your local client launches)
node dist/index.js http # hosted Streamable HTTP server on $PORT (alias: npm run start:http)
node dist/index.js login # one-time device-code sign-in (alias: npm run login)
node dist/index.js export-token # token cache as base64 for hosting (alias: npm run export-token)
node dist/index.js help # help
npm test # build + all offline tests (no account needed)
npm run smoke:live # acceptance checks against the real mailbox (local)
npm run smoke:remote # acceptance checks against a deployed URL (MCP_URL + MCP_AUTH_TOKEN)
```
`dist/` is committed on purpose, so a host that only runs `npm install && npm start` works without a
build step. After changing `src/`, run `npm run build` and commit `dist/` too (`npm run check:dist`
fails if you forget).
## Acceptance checklist
| # | Criterion | How to verify |
|---|---|---|
| AC1 | No `Mail.Send` in requested scopes | `npm test` (policy + trust-boundary tests); consent screen at login shows no "send mail" line |
| AC2 | No send tool in the MCP tool list | `npm test` (`tools/list` is asserted to be exactly the six tools) |
| AC3 | Search + read work on real mail | `npm run smoke:live` → `AC3 …` lines |
| AC4 | `create_draft` lands in Drafts, not sent | `npm run smoke:live` → `AC4 …` lines, then look in Outlook web → Drafts |
| AC5 | Graph send endpoints refuse this token | `npm run smoke:live` → both `AC5 … refused` lines show `HTTP 403` |
| AC6 | Non-expert finishes Entra + local run in ≤30 min | Follow Quick start; the runbook has one click per line |
| AC7 | Calendar/contact/send tools absent | `npm test`; `node dist/index.js help` lists six tools |
| AC8 | `.env.example` + security model, no client secret | This file, [`.env.example`](.env.example), [docs/entra-setup.md](docs/entra-setup.md) |
| H1 | Hosted `/mcp` refuses requests without the exact bearer token | `npm test` (`scripts/test-http-server.mjs`); `npm run smoke:remote` → "wrong token is refused" |
| H2 | Hosted tool list is five tools, no `save_attachment` | `npm test`; `npm run smoke:remote` → "exactly the five hosted tools" |
| H3 | Hosted server stays locked (503 on `/mcp`) without `MCP_AUTH_TOKEN` | `npm test` → "starts LOCKED without MCP_AUTH_TOKEN"; `curl /healthz` shows `"status":"locked"` |
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `AADSTS7000218` at login | "Allow public client flows" is off. Entra → your app → Authentication → Advanced settings → Yes → Save [3]. |
| `AADSTS50020` / "user account does not exist in tenant" | Wrong authority. Keep `OUTLOOK_MCP_TENANT` at `consumers` for a personal account. |
| `AADSTS90133` or `AADSTS50059` | Device code rejected for the tenant alias. Try `OUTLOOK_MCP_TENANT=common` (the app registration must then allow personal accounts) [4]. |
| `Not signed in. Run this in a terminal ONCE …` in the client | The cache is empty or expired (90 days [9]). Run `npm run login` again; the MCP server never prompts by itself. |
| HTTP 403 on `search_emails` / `read_email` | A delegated permission is missing in the app registration, or consent was declined. Re-check API permissions, then `npm run login` again. |
| `refusing to start: Refusing to request OAuth scope …` | Someone edited the scope list or tool registry. That is the guard doing its job. |
| Search returns hits from any year despite `received:` filter | Known MSA backend limitation; use `list_emails` with `since`/`until` for strict dates. |
| Claude Desktop shows no tools | Quit and restart the app fully; check `~/Library/Logs/Claude/mcp-server-outlook.log` [13]. |
## Development
```bash
npm run build # tsc → dist/
npm test # build + scripts/test-*.mjs + offline MCP smoke test
```
Trust boundaries and the rules for changing them are in [AGENTS.md](AGENTS.md). Upstream history and
the fork changes are in [CHANGELOG.md](CHANGELOG.md).
## Sources
1. [How to register an app in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)
2. [OAuth 2.0 device authorization grant](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code)
3. [Configure desktop apps that call web APIs — enable public client flow](https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-app-configuration)
4. [Using device code flow in MSAL.NET — Microsoft personal accounts](https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/desktop-mobile/device-code-flow)
5. [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference)
6. [user: sendMail](https://learn.microsoft.com/en-us/graph/api/user-sendmail)
7. [message: send](https://learn.microsoft.com/en-us/graph/api/message-send)
8. [Create message (draft)](https://learn.microsoft.com/en-us/graph/api/user-post-messages)
9. [Refresh tokens in the Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens)
10. [Managing apps and services connected to our Microsoft Accounts](https://learn.microsoft.com/en-us/answers/questions/4375979/article-managing-apps-and-services-connected-to-ou)
11. [Cursor docs — Model Context Protocol](https://cursor.com/docs/mcp)
12. [Cursor forum — Grok Bot custom remote MCP](https://forum.cursor.com/t/grok-bot-custom-remote-mcp-oauth-never-starts-fetch-failed-same-url-works-in-cursor-ide/168188)
13. [MCP — Connect to local MCP servers (Claude Desktop)](https://modelcontextprotocol.io/docs/develop/connect-local-servers)
14. [Claude Code — MCP](https://code.claude.com/docs/en/mcp)
## License
MIT, same as upstream. See [LICENSE](LICENSE).
TDQS
Scored across 6 tools
Each tool has a clearly distinct purpose: reading, listing, searching, folder browsing, downloading attachments, and drafting. Descriptions explicitly clarify boundaries, such as search_emails vs list_emails for date filtering, and save_attachment being separated from read_email's metadata-only attachment list.
All tools follow a consistent verb_noun snake_case pattern: read_email, list_emails, search_emails, list_folders, save_attachment, create_draft. No deviations or mixed conventions.
Six tools provide a focused, well-scoped set for a read-oriented email MCP server. Each tool earns its place, and the count is appropriate given the deliberate limitation of not sending emails.
The surface covers reading, listing, searching, folder navigation, attachment saving, and draft creation, but lacks delete, move, mark-as-read, or reply-draft updates. These are reasonable omissions for a limited server, but agents may hit dead ends for basic mailbox management.