Skip to main content
Glama
jopmiddelkamp

outlook-mcp-limited

outlook-mcp-limited

A local Model Context Protocol (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 (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 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 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:

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 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:

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.

Quick start (about 30 minutes, once)

  1. Register the app in Microsoft Entra — follow 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):

    cd /path/to/outlook-mcp
    npm install
    npm run build
  3. Sign in once (device code flow; prints a URL and a code):

    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:

    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. Short version for Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json on macOS [13]):

    {
      "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 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:liveAC3 … lines

AC4

create_draft lands in Drafts, not sent

npm run smoke:liveAC4 … 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, 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

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. Upstream history and the fork changes are in CHANGELOG.md.

Sources

  1. How to register an app in Microsoft Entra ID

  2. OAuth 2.0 device authorization grant

  3. Configure desktop apps that call web APIs — enable public client flow

  4. Using device code flow in MSAL.NET — Microsoft personal accounts

  5. Microsoft Graph permissions reference

  6. user: sendMail

  7. message: send

  8. Create message (draft)

  9. Refresh tokens in the Microsoft identity platform

  10. Managing apps and services connected to our Microsoft Accounts

  11. Cursor docs — Model Context Protocol

  12. Cursor forum — Grok Bot custom remote MCP

  13. MCP — Connect to local MCP servers (Claude Desktop)

  14. Claude Code — MCP

License

MIT, same as upstream. See LICENSE.