mcp-outlook
by ItayElizur
README.md
# mcp-outlook
A self-hostable **MCP server for on-prem Microsoft Exchange over EWS** (Exchange
Web Services). Built for **air-gapped / no-cloud** environments — it talks
directly to your internal Exchange server and never touches Microsoft Graph,
Azure AD, or a desktop Outlook client.
- **Backend:** [`exchangelib`](https://github.com/ecederstrand/exchangelib) (EWS client)
- **Framework:** [FastMCP](https://github.com/jlowin/fastmcp)
- **Transport:** streamable-http (a standalone HTTP server other internal hosts connect to)
- **Auth to Exchange:** basic or NTLM (selectable via config)
## Two modes
| Mode | Who it serves | How it authenticates |
|------|---------------|----------------------|
| `static` (default) | one mailbox | connects as that account (your own creds) — great for **local beta testing** |
| `jwt` | many users | validates each caller's **user JWT**, then acts on that user's mailbox via a **service account + EWS Impersonation** |
**How `jwt` mode bridges identity.** Exchange can't consume your company JWTs (Outlook is
PKINIT/Kerberos-based). So the MCP does two *separate* authentications that never mix:
```
User --(JWT)--> MCP validates the token, reads the user's email
MCP --(service account, NTLM)--> Exchange
MCP --(impersonation header = user's email)--> acts on the user's mailbox
```
The user's JWT is never sent to Exchange, and no user password/smart-card ever touches the
MCP — only the single service credential does. See [`TODO.md`](TODO.md) for what admins must
set up for `jwt` mode.
## Tools
| Tool | Purpose |
|------|---------|
| `list_emails(folder="inbox", limit=20)` | Recent messages, newest first |
| `search_emails(query, folder, start_date, end_date, sender, recipient, limit)` | Text + date-range + sender/recipient search |
| `get_email(message_id, folder="inbox")` | Full message: body, recipients, attachment names |
| `draft_email(to, subject, body)` | Open the interactive compose widget (MCP Apps); user edits and sends |
| `reply_email(message_id, folder, reply_all, body)` | Open the compose widget pre-filled as a reply |
| `forward_email(message_id, folder, to, body)` | Open the compose widget pre-filled as a forward |
| `send_email(to, subject, body, cc, bcc, html, attachments)` | Send — callable **only from the widget** (app-only visibility) |
| `search_contacts(query, limit)` | Search contacts — callable **only from the widget** (app-only) |
| `mark_email_read(message_id, folder)` / `mark_email_unread(...)` | Toggle read status |
| `delete_email(message_id, folder, permanent=False)` | Move to Deleted Items, or permanently delete |
| `flag_email_important(message_id, folder, important=True)` | Set Outlook's High/Normal importance |
| `move_email(message_id, destination, folder)` | Move a message to another folder |
| `list_folders()` | Available mail folder names, including subfolders |
| `list_events(start_date, end_date, limit)` | Calendar events in a date range |
| `get_event(event_id)` | Full event: body, attendees, location |
| `find_meeting_slots(attendees, duration_minutes, ...)` | Scheduling assistant — ranks slots by attendee availability |
| `draft_event(subject, start, end, location, body, required_attendees, optional_attendees)` | Open the interactive event-draft widget |
| `create_event(subject, start, end, required_attendees, ...)` | Create the event — callable **only from the widget** (app-only) |
| `accept_meeting(event_id)` / `decline_meeting(event_id)` | Respond to a meeting invite |
`list_emails` and `search_emails` also take `unread_only=true` to return only unread messages.
## Setup
Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync # create venv + install deps
cp .env.example .env # then edit .env with your Exchange details
uv run python -m mcp_outlook
```
The server binds to `MCP_HOST:MCP_PORT` (default `127.0.0.1:8000`) and serves the
streamable-http MCP endpoint at `/mcp`.
### Local beta testing (no admin setup needed)
Run against **your own** mailbox with your own username/password — no JWT, no impersonation,
no service account, no smart card:
```bash
# in .env:
OUTLOOK_AUTH_MODE=static # the default
OUTLOOK_EWS_ENDPOINT=https://mail.corp.local/EWS/Exchange.asmx
OUTLOOK_USERNAME=CORP\you
OUTLOOK_PASSWORD=...
uv run python -m mcp_outlook
```
## Configuration
All settings come from environment variables (or a `.env` file). See
[`.env.example`](.env.example) for the full list. The essentials:
| Var | Notes |
|-----|-------|
| `OUTLOOK_AUTH_MODE` | `static` (default, one mailbox) or `jwt` (multi-user tunnel) |
| `OUTLOOK_EWS_ENDPOINT` | Full asmx URL, e.g. `https://mail.corp.local/EWS/Exchange.asmx`. **Preferred.** |
| `OUTLOOK_SERVER` | Host-only alternative (endpoint assumed at `/EWS/Exchange.asmx`) |
| `OUTLOOK_USERNAME` | The connecting account — your own (static) or the service account (jwt). `DOMAIN\user` for NTLM, or email for basic. Not used with `sspi` |
| `OUTLOOK_EMAIL` | Mailbox to open (static mode). Optional — defaults to `OUTLOOK_USERNAME` when it's an email; ignored in jwt mode; **required** with `sspi` (no username to default from) |
| `OUTLOOK_PASSWORD` | account password. Not used with `sspi` |
| `OUTLOOK_AUTH_TYPE` | `ntlm` (default), `basic`, or `sspi` (Windows Integrated Auth — authenticates as this process's own AD identity, no username/password; Windows-only, needs `uv sync --extra sspi`) |
| `OUTLOOK_JWT_ISSUER` / `_AUDIENCE` | Required in jwt mode — token issuer and audience to require |
| `OUTLOOK_JWT_JWKS_URI` / `_PUBLIC_KEY` | jwt mode — signing keys (JWKS URI, or a static PEM for air-gapped) |
| `OUTLOOK_JWT_EMAIL_CLAIM` | jwt mode — claim holding the user's SMTP address (default `email`) |
| `OUTLOOK_CA_BUNDLE` | Path to internal CA `.pem` (for self-signed / internal CA) |
| `OUTLOOK_VERIFY_SSL` | `true` (default); `false` disables TLS verify (dev only) |
| `MCP_HOST` / `MCP_PORT` | HTTP bind (default `127.0.0.1:8000`) |
### Finding your EWS endpoint
The EWS URL is **not** the OWA (webmail) URL. On the Exchange server:
```powershell
Get-WebServicesVirtualDirectory | fl Name,InternalUrl,ExternalUrl
```
In an air-gapped setup you almost always want the **InternalUrl**.
## Testing
Unit tests need no Exchange server (config parsing + serialization only):
```bash
uv run pytest
```
**Live smoke test** (with a real `.env`): start the server, connect an MCP client
or the MCP Inspector, then call `list_folders` → `list_emails` → `send_email` (to
yourself) and confirm receipt. Flip `OUTLOOK_AUTH_TYPE` between `ntlm` and `basic`
to confirm whichever your Exchange admin has enabled.
## Compose UI (MCP Apps)
`draft_email` opens an interactive **MCP Apps** widget — a React composer built into a
single self-contained HTML file (`src/mcp_outlook/widgets/compose.html`). Any
MCP Apps-capable host renders it inline in the chat thread.
**Widget capabilities:**
- **To field with inline contact search** — type after the last comma to search contacts;
pick a result to replace the query with a chip; valid addresses render as labelled chips.
- **Send / Discard** — Send fires `send_email` directly from the widget (app-only — the
model cannot call it); Discard collapses the card.
- **Supersession** — opening a new draft greys out any older open draft widget.
- **Signature** — every draft is pre-seeded with "Written with Airchat" (editable).
**Air-gap guarantee:** the built HTML (React + bridge JS inlined) ships with the Python
package. No external asset requests are made at runtime; Node.js is only needed to
*rebuild* the widget.
### Rebuild the widget (dev only)
```bash
cd frontend
npm ci
npm run build # tsc + vite build + artifact copy → src/mcp_outlook/widgets/compose.html
```
The build script validates no external URLs ended up in the HTML before copying.
### Try it visually (standalone dev preview)
```bash
cd frontend && npm run dev
# Opens http://localhost:5173 with a mock host — no Exchange needed.
# Type in To, see chips form, contact results appear, Send/Discard collapse the card.
```
## Gotchas
- **Basic auth is often disabled** on modern Exchange — NTLM is the safer default.
- **Internal/self-signed certs** require `OUTLOOK_CA_BUNDLE`, or the connection
fails on TLS verification.
- **Multi-user (`jwt`) mode needs one Exchange grant** — the service account must hold the
`ApplicationImpersonation` RBAC role. See [`TODO.md`](TODO.md). `static` mode needs no such grant.
- **`.env` holds a plaintext password.** It is git-ignored; also lock down file
permissions (`chmod 600 .env`) on the host.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues