Skip to main content
Glama

EWS Mailbox MCP Server

An MCP 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

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

Related MCP server: universal-email-mcp

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

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

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:

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

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:

chmod 600 .env

3. Apply it

cd /path/to/ews-mcp && docker compose up -d

(docker compose restart does not reload .env; use up -d.) Confirm the label is loaded:

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

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:

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to run real email operations across multiple IMAP/SMTP and provider OAuth accounts — syncing, searching, reading, drafting, attaching, and sending mail — while requiring explicit human approval for sending, deleting, or forwarding and recording an inspectable audit trail.
    230,096 npm
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI assistants to search, retrieve, and send email across multiple ordinary IMAP, POP3, and SMTP mailboxes, with per-account permissions and safe sending checks.
    Apache 2.0
  • A
    license
    A
    quality
    B
    maintenance
    Gives AI assistants stdio access to a Microsoft Exchange/OWA mailbox so they can check unread mail, search messages, read and summarize threads, look up senders and organizational contacts, and send or reply to email only after the user approves the message in a local browser window.
    8
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI assistants and compatible clients to securely search, read, send, reply, forward, organize, and bulk-delete email across multiple mailboxes, with automatic SMTP configuration and AES-256-encrypted credential storage.
    1,013 npm
    MIT