Skip to main content
Glama
jopmiddelkamp

outlook-mcp-limited

README.md
# 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

A4.4/5.0

Scored across 6 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues