EWS Mailbox MCP Server
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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
accountargument.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> valueThen pick a deployment:
Behind an existing reverse proxy (Nginx Proxy Manager, Traefik, nginx, …): set
PROXY_NETWORKin.envto the proxy's Docker network and run./install.sh(ordocker compose up -d --build). No host port is published; point your proxy host (with TLS) athttp://ews-mcp:8080.Standalone with automatic TLS (host has free ports 80/443): set
MCP_PUBLIC_HOSTto a DNS name pointing at the host and rundocker 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 serverFiles
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.
Account 1 = default account —
ACCOUNT1_*group (same fields as below,ACCOUNT1_SLUGrequired). For older.envfiles the single-account namesEWS_SERVER,EWS_ENDPOINT,EWS_USERNAME,EWS_PASSWORD,MAILBOX_EMAIL,READ_ONLY,ALLOW_SENDare still accepted as fallbacks for account 1.ACCOUNTS_FILE(optional) — path to a JSON/YAML file, seeaccounts.example.json. Keep it mode 600, outside git, and mount it read-only into the container (addvolumes: ["./accounts.json:/run/secrets/accounts.json:ro"]todocker-compose.yml, thenACCOUNTS_FILE=/run/secrets/accounts.json).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=falseAdding a third account = add such a block to
.env, thendocker compose up -d, then checklist_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 |
| read_only | User-created folders only; system folders (Inbox, Sent Items, Drafts, Deleted Items, Archive, Junk Email, Outbox, root, …) are refused. Returns |
| read_only | System folders refused. Non-empty + |
| — | One restriction search per mail folder (no multi-folder AQS), run in parallel, merged newest-first. Each hit has |
| read_only |
|
| — | Flagged messages across all mail folders, with |
| — (always allowed) | Default calendar, recurring series expanded into occurrences, sorted by start, times in the account timezone ( |
| — (always allowed) | Same fields plus |
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 |
| Default calendar. With attendees, Exchange emails invitations ( |
| Attendee lists replace stored ones. With attendees, Exchange emails updates ( |
| With attendees + |
|
|
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 changesMailbox password: edit
ACCOUNT<n>_PASSWORDin.env(wrap in single quotes if it has spaces/special chars), thendocker 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 additionallyACCOUNT<n>_ALLOW_SEND=true(enables send/reply/forward); thendocker 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; formatsuser@domain,user,DOMAIN\user. Logs showEWS self-checkat startup.stale changekey: the message changed since read; tool returns
currentstate — re-read and retry.Moved messages get new ids: use
new_idfrom 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 32This 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 .envLeave 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 .env3. 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,cursorIf 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 200Success: 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"
# -> 401In 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 oldcursor:<old key>pair →docker compose up -d.Revoke immediately: delete that client's
label:keypair fromMCP_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_KEYto 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 withgrep 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
This server cannot be deployed
Maintenance
Related MCP Connectors
Give AI agents their own email inboxes: list, read and send mail via scoped, revocable OAuth grants.
Email, calendar and contacts for AI agents: Gmail, Microsoft 365 and IMAP accounts behind one API.
Gmail, Outlook, Drive, OneDrive and calendars for AI agents. Many accounts, one endpoint, audit log.
Hosted email MCP for your own Gmail, Outlook.com, Microsoft 365, iCloud or IMAP inbox: read, search, draft, reply in thread, forward and file mail. It moves or flags up to 500 messages in one call, and a send leaves exactly one copy in Sent. A calendar is a separate connection, and connecting one adds diary and scheduling tools.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables 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 npmMIT
- AlicenseNot gradedqualityAmaintenanceEnables 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
- AlicenseAqualityBmaintenanceGives 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.8MIT
- AlicenseNot gradedqualityAmaintenanceEnables 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 npmMIT