Skip to main content
Glama
README.md
# EWS Mailbox MCP Server

An [MCP](https://modelcontextprotocol.io) server that gives AI clients (Claude, ChatGPT, Cursor, …) controlled access
to one or more **Microsoft Exchange** mailboxes over **EWS** (Exchange Web Services): read, search, file, flag, send,
manage inbox rules and the calendar. Works with on-premises Exchange 2013+ (and any server that still exposes EWS
with Basic auth). No browser automation, and reads never change a message's read state.

- 31 tools, multiple mailboxes on one endpoint, every tool takes an optional `account` argument.
- **Safe by default:** every account starts read-only; sending and calendar writes are separate opt-in gates per account.
- Streamable HTTP transport (`/mcp`), Bearer API-key auth with multiple labelled client keys.
- Single small Docker container: non-root, read-only filesystem, no database.

## Quick start
```bash
git clone <this repo> ews-mcp && cd ews-mcp
cp .env.example .env && chmod 600 .env
sed -i "s|^MCP_API_KEY=.*|MCP_API_KEY=$(openssl rand -hex 32)|" .env
nano .env        # fill every <TO BE FILLED BY OPERATOR> value
```
Then pick a deployment:

- **Behind an existing reverse proxy** (Nginx Proxy Manager, Traefik, nginx, …): set `PROXY_NETWORK` in `.env` to
  the proxy's Docker network and run `./install.sh` (or `docker compose up -d --build`). No host port is published;
  point your proxy host (with TLS) at `http://ews-mcp:8080`.
- **Standalone with automatic TLS** (host has free ports 80/443): set `MCP_PUBLIC_HOST` to a DNS name pointing at
  the host and run `docker compose -f docker-compose.caddy.yml up -d --build`. Caddy obtains the certificate.

Client configuration: URL `https://<your-host>/mcp`, header `Authorization: Bearer <MCP_API_KEY>`.

## Architecture
```
[AI client] --HTTPS, Bearer--> [reverse proxy, TLS] --> http://ews-mcp:8080 (docker network, no host port)
     --> EWS Basic auth over HTTPS --> each account's Exchange server
```

## Files
`app/server.py` (MCP + auth + REST), `app/ews.py` (EWS logic), `app/config.py`, `Dockerfile`,
`docker-compose.yml` (behind a proxy), `docker-compose.caddy.yml` + `Caddyfile` (standalone), `install.sh`,
`.env.example`, `accounts.example.json`, test scripts (`smoke_test.py`, `reply_test.py`, `v2_test.py`, `v3_test.py`).

Stack: Python 3.12, `mcp` 1.30 (FastMCP, Streamable HTTP, stateless JSON mode, protocol 2025-03-26 / 06-18 / 11-25),
`exchangelib` 5.6.

## Multiple accounts
The server serves several Exchange mailboxes on the same `/mcp` endpoint. `list_accounts` (no args) returns each
account's `slug`, `email`, `timezone`, `read_only`, `allow_send`, `is_default` — never usernames, passwords or hosts.

**Every tool takes an optional `account` argument** (slug or mailbox email, case-insensitive). Omitted = the default
account (account 1, `is_default: true`), i.e. exactly the original behaviour. Every result carries
`"account": "<slug>"`; `mailbox_status` without `account` also returns an `accounts` array. Ids are per account —
an id from one mailbox is "not found" in another; there is no cross-account fallback. Unknown account → error
listing valid slugs.

### How accounts are configured (no code changes needed)
Nothing is hardcoded: every account, including the default one, is defined in configuration.

1. **Account 1 = default account** — `ACCOUNT1_*` group (same fields as below, `ACCOUNT1_SLUG` required).
   For older `.env` files the single-account names `EWS_SERVER`, `EWS_ENDPOINT`, `EWS_USERNAME`, `EWS_PASSWORD`,
   `MAILBOX_EMAIL`, `READ_ONLY`, `ALLOW_SEND` are still accepted as fallbacks for account 1.
2. **`ACCOUNTS_FILE`** (optional) — path to a JSON/YAML file, see `accounts.example.json`. Keep it mode 600, outside
   git, and mount it read-only into the container (add `volumes: ["./accounts.json:/run/secrets/accounts.json:ro"]`
   to `docker-compose.yml`, then `ACCOUNTS_FILE=/run/secrets/accounts.json`).
3. **`ACCOUNT<n>_*` env groups** for n = 2, 3, … :
   ```
   ACCOUNT3_SLUG=acme                     # lowercase id the client passes as `account`
   ACCOUNT3_EMAIL=user@acme.example
   ACCOUNT3_EWS_SERVER=mail.acme.example  # or ACCOUNT3_EWS_ENDPOINT=https://mail.acme.example/EWS/Exchange.asmx
   ACCOUNT3_USERNAME='ACME\user'
   ACCOUNT3_PASSWORD='secret with spaces'
   ACCOUNT3_TZ=Europe/Berlin           # IANA name; default TZ (UTC)
   ACCOUNT3_READ_ONLY=true
   ACCOUNT3_ALLOW_SEND=false
   ```
   **Adding a third account** = add such a block to `.env`, then `docker compose up -d`, then check `list_accounts`.

Startup fails fast (naming the field, never the value) on duplicate/invalid slugs or missing fields. Each account
has its own cached EWS connection, created on first use; a broken account only makes *its* tool calls fail.

### Per-account gates
`read_only` / `allow_send` are per account. Account n uses `ACCOUNT<n>_READ_ONLY` / `ACCOUNT<n>_ALLOW_SEND` (or the file fields). New accounts **default to closed**
(`read_only=true`, `allow_send=false`) if unset. To open one: set e.g. `ACCOUNT2_READ_ONLY=false` (filing/marking/rules)
and `ACCOUNT2_ALLOW_SEND=true` (sending), then `docker compose up -d`. Refusals name the account slug.

## Tools
`list_accounts`, plus (all with optional `account`):
Read: `mailbox_status`, `list_folders`, `list_messages`, `search_messages`, `get_message`, `get_thread`,
`download_attachment`. Write (READ_ONLY gate): `mark_read`, `move_messages`, `archive_messages`, `delete_messages`
(default soft delete → Deleted Items; `permanent:true` = hard delete), `create_folder`, `create_rule`,
`set_rule_enabled`, `delete_rule` (no undo). Send (READ_ONLY **and** ALLOW_SEND gates): `send_message`,
`reply_message` (EWS ReplyToItem, stays in thread), `forward_message`.

REST: `GET /health` (no auth) → `{"status":"ok"}`; `GET /status` (Bearer) → mailbox_status payload. Anything else → 404.

Reads use only EWS FindItem/GetItem, which never change `is_read` (verified by acceptance test 6).
Search tries AQS first and falls back to restriction-based search if the server rejects AQS (some servers reject
multi-folder AQS); result field `search_mode` says which ran.

### Folders, cross-folder search, flags, calendar (read)
| Tool | Gate | Notes |
|---|---|---|
| `rename_folder(folder, new_name)` | read_only | User-created folders only; system folders (Inbox, Sent Items, Drafts, Deleted Items, Archive, Junk Email, Outbox, root, …) are refused. Returns `id`, `old_path`, `new_path`. |
| `delete_folder(folder, force=false)` | read_only | System folders refused. Non-empty + `force=false` → refused with the message count, nothing touched. `force=true` **hard-deletes the folder and its contents** (observed on Exchange 2019: messages are gone, not moved to Deleted Items). Result reports `contents_outcome`. |
| `search_all_folders(query, from_contains, subject_contains, unread_only, received_after, received_before, limit)` | — | One restriction search per mail folder (no multi-folder AQS), run in parallel, merged newest-first. Each hit has `folder`. Returns `folders_searched`, `folders_skipped`, `total_matching` (counted up to `per_folder_cap` = 200 newest per folder). |
| `set_flag(message_ids, flag)` | read_only | `flagged` / `complete` / `none` via MAPI `PidTagFlagStatus`; per-item results like `mark_read`; never changes `is_read`. |
| `list_flagged(include_completed=false, limit=50)` | — | Flagged messages across all mail folders, with `folder` and `flag`. |
| `list_calendar_events(start, end, limit=50)` | — (always allowed) | Default calendar, recurring series expanded into occurrences, sorted by start, times in the account timezone (`timezone` field). Default window now → +7 days; max 62 days. |
| `get_calendar_event(event_id)` | — (always allowed) | Same fields plus `body_text` and `recurrence`. |

`list_calendar_events` / `get_calendar_event` are pure reads and ignore all write gates.

### Calendar writes
New per-account gate **`allow_calendar_write`** (ships `false`). Global default `ALLOW_CALENDAR_WRITE`, per-account
override `ACCOUNT<n>_ALLOW_CALENDAR_WRITE` (or `allow_calendar_write` in the accounts file). Shown in
`list_accounts` and `mailbox_status`. The four tools below refuse unless the account has `read_only=false` **and**
`allow_calendar_write=true`; the refusal names the account and the gate. Any calendar operation that makes Exchange send
mail (invitations, updates, cancellations, responses) additionally requires `allow_send=true` on that account. To open/close it for one account:
`ACCOUNT2_ALLOW_CALENDAR_WRITE=true|false` in `.env`, then `docker compose up -d`.

| Tool | Notes |
|---|---|
| `create_calendar_event(subject, start, end, is_all_day, location, body_text, required_attendees, optional_attendees, reminder_minutes_before, recurrence)` | Default calendar. With attendees, **Exchange emails invitations** (`invitations_sent=true`). `recurrence`: `{pattern: daily\|weekly, interval, days_of_week: [MO..SU], end_date \| occurrences}`. |
| `update_calendar_event(event_id, scope, …patch fields)` | Attendee lists replace stored ones. With attendees, Exchange emails updates (`updates_sent`). `scope`: `series` (default; an occurrence id resolves to its master) or `occurrence`; result reports `scope_applied`. |
| `delete_calendar_event(event_id, scope, send_cancellation=true)` | With attendees + `send_cancellation`, Exchange emails cancellations (`cancellation_sent`). |
| `respond_to_event(event_id, response, comment, send_response=true)` | `accept` / `decline` / `tentative` for a meeting in this account's calendar. |

Times: ISO 8601; without an offset they are read in the account timezone; results carry offsets and `timezone`.
All-day events take dates. Side effects: meetings generate invitation/response/cancellation **mail** in both
mailboxes, and an attendee's calendar keeps a "Cancelled" copy of a cancelled meeting until it is deleted.
Test script: `v3_test.py --phase closed|open` (open phase needs the gate opened temporarily; it removes all tagged
events and mail afterwards).

Acceptance scripts (inside the container): `smoke_test.py` (read-only), `reply_test.py` and `v2_test.py`.
The last two send real, tagged test mail between two configured mailboxes and hard-delete everything they
created afterwards (`--a1 <slug> --a2 <slug>`).

## Operations
```bash
cd /path/to/ews-mcp
docker compose logs -f --tail 100        # logs (rotated: 5 x 10 MB)
docker compose up -d                     # after editing .env (restart does NOT reload .env)
docker compose up -d --build             # after code changes
```
- **Mailbox password**: edit `ACCOUNT<n>_PASSWORD` in `.env` (wrap in single quotes if it has spaces/special chars), then `docker compose up -d`.
- **API key rotation**: `sed -i "s|^MCP_API_KEY=.*|MCP_API_KEY=$(openssl rand -hex 32)|" .env && docker compose up -d`, then update the client.
- **Gates** (per account): set `ACCOUNT<n>_READ_ONLY=false` (enables file/move/mark/rules) and additionally `ACCOUNT<n>_ALLOW_SEND=true` (enables send/reply/forward); then `docker compose up -d`.
- **Backup/restore**: config only — back up the deployment directory (incl. `.env`) and your proxy config. Mail stays on Exchange; no database.

## Tests
```bash
docker exec ews-mcp python smoke_test.py --base https://mcp.example.com
# write tests (operator go-ahead, READ_ONLY=false):
docker exec ews-mcp python smoke_test.py --base https://mcp.example.com --write --test-message-id <id>
```
Test 13b (ALLOW_SEND gate) runs automatically when READ_ONLY=false and ALLOW_SEND=false. No email is sent by tests.

## Security model
- The API key is the only barrier between the internet and the mailbox. Compared in constant time; missing/wrong → 401
  with no body. 20 failures per IP in 10 min → 429 for 10 min (client IP from `CF-Connecting-IP`/`X-Real-IP`, trusted only from private peers).
- The mailbox password is used only for EWS Basic auth; never logged or returned.
- Logs contain: timestamp, tool, status, duration, counts, folder names. Never: password, API key, bodies, attachments, third-party addresses.
- The API key does NOT protect against a compromised server or leaked `.env`; whoever holds it has full gated mailbox access.
- Container: non-root, read-only rootfs, no-new-privileges, no published ports, no CORS.

## Troubleshooting
- **401 from /mcp**: wrong/missing Bearer key. **429**: IP blocked after repeated failures; wait 10 min or restart.
- **"credentials rejected or server throttling"**: check `ACCOUNT<n>_USERNAME`/`ACCOUNT<n>_PASSWORD`; formats `user@domain`, `user`, `DOMAIN\user`. Logs show `EWS self-check` at startup.
- **stale changekey**: the message changed since read; tool returns `current` state — re-read and retry.
- **Moved messages get new ids**: use `new_id` from move/archive results.
- **Throttling**: exchangelib backs off automatically (max 15 s); tools time out at 38 s with an explicit (and for writes, "UNCERTAIN") error.

## Adding a new client API key
Use this to give a **new AI agent or client** its own access. Each client gets its own key, so you can revoke one
without affecting the others.

**What a key grants:** full access to every tool on **all configured mailboxes**. What it can actually change is
limited only by each account's gates (`read_only`, `allow_send`). Treat a key like the mailbox passwords.

### 1. Generate a key and pick a label
On the server:
```bash
openssl rand -hex 32
```
This prints a 64-character key. Pick a **label**: short, names the client, letters/digits/`-`/`_`/`.` only, no
spaces, unique (e.g. `cursor`, `chatgpt`, `claude-secondary`). `primary` is reserved for `MCP_API_KEY`.
The entry you will add is `label:key`, for example `cursor:3f9a…e1`.

### 2. Add it to `.env`
```bash
cd /path/to/ews-mcp
nano .env
```
Leave `MCP_API_KEY=` (the primary key the existing client uses) **unchanged**. Add or extend the `MCP_API_KEYS` line —
pairs separated by commas, no spaces:
```
# before (no extra keys yet - line absent)
MCP_API_KEY=aaaa...primary...aaaa

# after adding the first client
MCP_API_KEY=aaaa...primary...aaaa
MCP_API_KEYS=cursor:1111111111111111111111111111111111111111111111111111111111111111

# after adding a second and third client (append ,label:key)
MCP_API_KEYS=cursor:1111111111111111111111111111111111111111111111111111111111111111,chatgpt:2222222222222222222222222222222222222222222222222222222222222222,claude-secondary:3333333333333333333333333333333333333333333333333333333333333333
```
`.env` must stay private and out of git:
```bash
chmod 600 .env
```

### 3. Apply it
```bash
cd /path/to/ews-mcp && docker compose up -d
```
(`docker compose restart` does **not** reload `.env`; use `up -d`.) Confirm the label is loaded:
```bash
docker logs ews-mcp 2>&1 | grep "api key labels" | tail -1
# -> ... api key labels: primary,cursor
```
If the container keeps restarting, run `docker logs ews-mcp | tail` — a malformed entry is reported by its label/position.

### 4. Verify from the client side
```bash
KEY=1111...your-new-key...1111
curl -s https://mcp.example.com/mcp \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -c 200
```
Success: HTTP 200 with JSON starting `{"jsonrpc":"2.0","id":1,"result":{"tools":[...`.
Simpler check: `curl -s -H "Authorization: Bearer $KEY" https://mcp.example.com/status` → mailbox status JSON.
Wrong or missing key → `401` with an empty body:
```bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://mcp.example.com/mcp -H "Authorization: Bearer wrong"
# -> 401
```
In the server logs every tool call shows the label: `docker logs ews-mcp 2>&1 | grep "key=cursor"`.
Then configure the client with URL `https://mcp.example.com/mcp` and header `Authorization: Bearer <key>`.

### 5. Rotate or revoke
- **Rotate** (no downtime): add a new pair with a new label (e.g. `cursor2:<new key>`) → `docker compose up -d` →
  switch the client to the new key and verify → delete the old `cursor:<old key>` pair → `docker compose up -d`.
- **Revoke immediately**: delete that client's `label:key` pair from `MCP_API_KEYS` (remove its comma too) →
  `docker compose up -d`. Only that key stops working (`401`); all other clients and the primary key are unaffected.

### 6. Security rules
- One key per agent/client. Never give the primary `MCP_API_KEY` to a new agent.
- Never send keys through chat, email or tickets; paste them directly into the client's config. Never commit them.
- **If a key leaks:** revoke it first (step 5), then investigate with `docker logs ews-mcp 2>&1 | grep "key=<label>"`
  to see which tools it called and when, and check failed attempts with `grep auth_failed`.

## Contributing
Issues and pull requests are welcome. Please never include real mailbox data, credentials or API keys in issues,
logs or test fixtures. Test scripts only touch mail they created (tagged subjects) and clean up after themselves,
but run them against test mailboxes you own.

## License
[MIT](LICENSE)