MCP server for Microsoft 365 Outlook (mail + calendar) with audit-preserving drafts and never-auto-send.
Project description
mcp-server-outlook
In one sentence: an MCP server that lets AI coding agents like Claude Code read your Microsoft 365 inbox and calendar — and draft emails and meetings on your behalf — without ever auto-sending and without breaking audit attribution.
What is this for?
You're a consultant, contractor, or operator working across multiple Microsoft 365 tenants. You'd love to let AI agents triage your inbox, summarise calendar conflicts, and draft replies — but every existing option is bad:
- Account-bound claude.ai connectors force one tenant per connector — useless if you're in three tenants at once with the same Claude account.
- IMAP/SMTP scrapers lose calendar context, lose attribution (mail appears as "anonymous client"), bypass modern auth.
- Auto-sending agents are scary in regulated environments — one wrong tool call and you've sent an email "as you" that you never wrote.
mcp-server-outlook fixes all three:
- Local process per tenant, multi-profile — run one MCP entry per customer / mailbox; tokens are namespaced.
- Microsoft Graph for full attribution — drafts and reads carry the signed-in user's identity in the audit log.
- Drafts only, send is human-only — there is no
send_emailtool, anywhere. The agent's reach ends at "draft saved".
Concretely, the agent gets these tools:
| Tool | What the agent does | What ends up in Outlook |
|---|---|---|
ol_email_search, ol_email_list_unread, ol_email_read |
finds and reads inbox | nothing changes |
ol_calendar_search, ol_calendar_list_events |
reads calendar | nothing changes |
ol_status |
shows pending drafts created by this profile | nothing changes |
(v0.2) ol_email_create_draft, ol_email_update_draft |
creates drafts in your Drafts folder | a draft appears — you review and click Send |
(v0.2) ol_calendar_create_event_draft |
creates a tentative event with responseRequested=False |
a draft event sits on your calendar — you send invites |
Every action is attributed to the human who signed in once via Microsoft's standard Device Code login. No service-account "robot" identity. The default install does not request Mail.Send — the consent prompt does NOT include "this app can send mail as you", which is the line tenant admins (and your auditor) actually care about. Sending is opt-in via OUTLOOK_ALLOW_SEND=true (v0.3+) — see Sending: opt-in — and even when enabled, the agent never auto-sends; the human reviews each draft before any send tool call.
Installation
pip install mcp-server-outlook
# or, with uv (recommended):
uv tool install mcp-server-outlook
# or, on the fly without installing globally:
uvx mcp-server-outlook --help
Requires Python 3.11+. Works on Linux, macOS, Windows.
Quickstart
1. Sign in once (out of band)
uvx mcp-server-outlook login
Output looks like:
Sign in to mcp-server-outlook via the Device Code flow:
Open the URL in a browser and type the code.
URL: https://login.microsoft.com/device
Code: D2LKUY4AV
Waiting for sign-in...
Open the URL in any browser, type the code, sign in with your M365 account. Your refresh token is cached locally — see Token storage. The MCP server itself never blocks for human interaction afterwards.
Prefer to log in inside the MCP client without leaving your agent's chat? Use the Login from an MCP client flow below — same end result, no separate terminal needed.
2. Wire it into Claude Code
In your project's .mcp.json:
{
"mcpServers": {
"outlook": {
"command": "uvx",
"args": ["mcp-server-outlook"]
}
}
}
Restart Claude Code. The agent now has the ol_* read tools available — read-only by default.
3. Try it
You: What unread mails do I have today that look actionable?
Agent: [calls ol_email_list_unread → reads recent ones with ol_email_read]
Three threads need a reply:
- Anna (XMV) — asking about your availability for a Friday call
- Markus (Customer Y) — needs sign-off on the SLA addendum
- Calendar Bot — confirms tomorrow's standup at 10:00
You: Read Anna's mail and check my Friday calendar.
Agent: [calls ol_email_read + ol_calendar_list_events]
Anna proposes Fri 14:00–15:00. You have a hard conflict 14:30–15:30
with the Customer Y review. Suggest 13:00 or after 16:00.
In v0.2, draft tools become available so the agent can write the reply email or the counter-proposal event for you. You always click Send.
What it can do, in detail
Read tools (always available)
| Tool | Purpose |
|---|---|
ol_email_search(query, folder?, from?, modified_after?, has_attachment?) |
Free-text search over the user's mailbox using Microsoft Graph $search. Returns hits with id, subject, from, received-at, snippet, web URL. |
ol_email_list_unread(folder="Inbox", limit=50) |
Unread mails in the named folder, newest first. |
ol_email_read(id, include_attachments=False) |
Full body (text + html) + headers + attachments-list for a single mail. |
ol_calendar_search(query, calendar?, from_date?, to_date?) |
Events matching a free-text query. |
ol_calendar_list_events(from_date, to_date, calendar="primary") |
Events in a date range with attendees and location. |
ol_status() |
Pending drafts created by this MCP profile (empty in v0.1; populated once draft tools land in v0.2). |
Write tools — deferred to v0.2 (opt-in via OUTLOOK_ALLOW_DRAFTS=true)
| Tool | Purpose |
|---|---|
ol_email_create_draft(to, subject, body, in_reply_to?, cc?, bcc?, attachments?) |
Creates a draft in the user's Drafts folder. Returns draft_id + web_url. |
ol_email_update_draft(draft_id, …) |
Updates a draft created by this profile. |
ol_email_list_drafts(profile_only=True) |
Lists drafts created by this profile. |
ol_email_discard_draft(draft_id) |
Deletes a draft created by this profile. |
ol_calendar_create_event_draft(subject, start, end, attendees?, body?, location?) |
Creates a tentative event with responseRequested=False so no invites auto-send. |
ol_calendar_discard_event_draft(event_id) |
Removes an event draft created by this profile. |
Explicitly NOT exposed (by design)
send_email/send_draft— human-only action, perform in Outlook UI.send_calendar_invitation— same.delete_email/archive_email— read-only on inbox; user manages their own mailbox state.- Bulk operations on mails the user did not author or did not create as drafts via this MCP — defensive against fat-finger mass changes.
Authentication
- OAuth 2.0 Device Code flow against Microsoft Identity (default). You sign in once; the refresh token is cached locally and silently renewed (~60–90 days until full re-login).
- Bring-your-own-app or use ours. XMV publishes a multi-tenant Entra app registration that's baked in as the default — same pattern as Azure CLI / GitHub CLI. Tenants with strict app-allowlisting can override via
OUTLOOK_CLIENT_IDandOUTLOOK_TENANT_IDenv vars. - Token storage is auto-detected at first use: OS keyring (macOS Keychain / Windows Credential Locker / Linux Secret Service) when available, mode-0600 plain JSON file as fallback (same convention as
gh auth,aws configure). Optional encryption withOUTLOOK_TOKEN_PASSPHRASEfor paranoid setups or CI. - Multi-customer / multi-tenant: separate
OUTLOOK_PROFILEper tenant, each with its own token cache.
Required Microsoft Graph scopes (delegated)
Mail.Read— read inbox + foldersMail.ReadWrite— create / update / discard drafts (v0.2)Mail.Send— send a draft (v0.3+). Only requested whenOUTLOOK_ALLOW_SEND=trueis set. The default install never requests this scope; the consent screen stays drafts-only.Calendars.Read— read eventsCalendars.ReadWrite— create / update / discard event drafts (v0.2)User.Read— basic profile (signed-in user identification)offline_access— refresh tokens
The default install never requests Mail.Send. Sending is opt-in via OUTLOOK_ALLOW_SEND=true (v0.3+); see the section below. Even with the opt-in active, the agent never auto-sends — every send requires an explicit ol_email_send_draft(draft_id) tool call referencing a draft the human has already reviewed in Outlook.
Sending: opt-in via OUTLOOK_ALLOW_SEND
For agent-driven workflows that close the loop ("draft, review, send" all in one MCP session), v0.3 adds an opt-in send tool. To enable it:
{
"mcpServers": {
"outlook": {
"command": "uvx",
"args": ["mcp-server-outlook"],
"env": {
"OUTLOOK_ALLOW_DRAFTS": "true",
"OUTLOOK_ALLOW_SEND": "true"
}
}
}
}
What this changes:
- The next
mcp-server-outlook loginrequest additionally asks forMail.Sendconsent. Microsoft's consent screen will explicitly include "send mail as you" — you have to click Approve. - The MCP server registers an additional tool:
ol_email_send_draft(draft_id). It refuses to send any draft that's not in the per-profile registry (i.e. only sends drafts the agent itself created). - Still no autonomous send. The agent must explicitly call
ol_email_send_draft(draft_id)with a specific draft id. Human-in-the-loop on every send: the user reviews the draft body and recipients in Outlook betweenol_email_create_draftandol_email_send_draft.
If you're unsure whether you want this, leave the flag out. The default drafts-only mode is sufficient for the most common operator workflows.
Service-principal mode (unattended automation)
For CI / scheduled jobs where no human is in the loop, run with OUTLOOK_AUTH_MODE=service-principal (or just set OUTLOOK_CLIENT_SECRET — auto-detected). Required env vars: OUTLOOK_CLIENT_ID, OUTLOOK_CLIENT_SECRET, OUTLOOK_TENANT_ID. The app registration must have Application Microsoft Graph permissions with admin consent recorded.
Tradeoff: every action is attributed to the application principal, NOT a real user. The compliance-friendly default stays delegated user auth — only switch when no human is in the loop.
Login from an MCP client
Two MCP tools — ol_login_begin and ol_login_status — let an agent drive the OAuth Device Code flow without leaving the chat. This is the recommended path for any client where shelling out to a terminal is awkward (web UIs, mobile, locked-down sandboxes). The CLI mcp-server-outlook login path stays — same token cache, fully interoperable. Use whichever fits the moment.
The two tools:
| Tool | What it does |
|---|---|
ol_login_begin(force=False) |
Initiates the Device Code flow, returns user_code + verification_url, polls Microsoft Identity in the background, blocks until terminal (success / expired / failed). Streams progress notifications during the wait when the client supports them. Idempotent: a non-expired pending session for the profile is joined, not duplicated. force=True cancels any in-flight session and starts fresh. |
ol_login_status() |
Returns one of three states: signed_in (a usable token exists for this profile, regardless of how it got there — CLI login hours ago, tool login just now), pending (a Device Code flow is in flight; response carries user_code + verification_url so the agent can re-display the prompt), or none (no token, no flow — call ol_login_begin). Read-only. |
Crucial property of ol_login_status: it actively probes the token store. A user who logged in via the CLI three days ago shows as signed_in, NOT none. The two paths don't fight; they cooperate via the shared token cache.
Typical flow as the agent sees it:
You: Help me triage my unread mail.
Agent: [ol_login_status() → "none"]
I need to sign in to your Microsoft 365 account first.
[ol_login_begin() → returns pending session]
ABCD-EFGH
https://login.microsoftonline.com/common/oauth2/deviceauth
Tap-and-hold the code to copy, then click the link.
I'm waiting…
You: [signs in in browser]
Agent: [ol_login_begin() returns success]
Signed in as anna@xmv.de.
[ol_email_list_unread() → ...]
You have 3 unread mails. The first is from …
Pending sessions are in-process state. If the MCP server restarts before the user completes the Device Code prompt (Claude Code session ends, container redeployed, …), the in-flight session is lost. The agent calls ol_login_begin again — the Microsoft side cleans up the abandoned device code automatically. Persisting pending sessions to disk is non-trivial (the asyncio polling task can't be serialised; resuming from a fresh process would need to start a new poll against the original device code) and is deferred to a later release. In practice this rarely matters — Device Code flows take 30–60 seconds when the user is active.
No ol_logout MCP tool. Logout stays CLI-only (mcp-server-outlook logout --profile <name>). An agent proactively logging users out is a footgun — the kind of "agent decided to be helpful" surprise that causes incident reports. Human-initiated logout is the expected path.
File-lock caveat (rare). If you run the CLI mcp-server-outlook login --profile foo concurrently with ol_login_begin(profile="foo") from a running MCP server, the two writes to the token cache file are not protected by a file lock at the library level. The result on collision is "last writer wins" — typically harmless because both paths produce a valid token, just one of them ends up on disk. If you care about strict correctness here, run only one of the two paths at a time.
Safety model
Four layers of "don't accidentally do something irreversible":
- Your MCP client (Claude Code) prompts before each tool call by default. Read tools are flagged read-only; draft tools are flagged "creates draft (no send)"; the v0.3+ send tool is flagged destructive — you see the difference at the prompt.
- Drafts opt-in via env (v0.2). Without
OUTLOOK_ALLOW_DRAFTS=true, the draft-creation tools aren't even registered. The agent literally can't draft. - Send opt-in via a separate env (v0.3). Without
OUTLOOK_ALLOW_SEND=true, the send tool is not registered ANDMail.Sendis not in the OAuth scope request. The default consent screen stays drafts-only. - Never autonomous send. Even with both opt-ins active, the agent must explicitly call
ol_email_send_draft(draft_id)with a specific draft id. There is no path where an agent's tool call results in a sent email without the human first being able to read the draft body and recipients in Outlook.
The threat model is "your local OS account is trusted" — same as ~/.ssh/id_rsa, gh tokens, aws config. The tool isn't designed to defend against host compromise; it's designed to keep audit trails honest under normal use.
Roadmap
| Version | Status | Theme | Highlights |
|---|---|---|---|
| v0.1 | 🛠️ in progress | Read-only inbox + calendar | The six ol_* read tools, three-layer test harness, Trusted-Publisher PyPI release pipeline, branch-protected main. |
| v0.2 | 📋 planned | Drafts | ol_email_create_draft / ol_email_update_draft / ol_email_list_drafts / ol_email_discard_draft, ol_calendar_create_event_draft / ol_calendar_discard_event_draft, attachment access, per-profile draft registry. |
| v1.0 | 🎯 stability lock-in | "API stable, production-tested" | After v0.x has been used in real customer environments for ~3–6 months without breaking changes. |
The full ticket-by-ticket plan lives at the issues page.
Multi-profile pattern
For consultancy workflows with multiple Microsoft 365 tenants, give each its own profile so the token caches don't collide:
{
"mcpServers": {
"outlook-acme": {
"command": "uvx",
"args": ["mcp-server-outlook"],
"env": { "OUTLOOK_PROFILE": "acme" }
},
"outlook-globex": {
"command": "uvx",
"args": ["mcp-server-outlook"],
"env": { "OUTLOOK_PROFILE": "globex" }
}
}
}
Sign each one in separately:
uvx mcp-server-outlook login --profile acme
uvx mcp-server-outlook login --profile globex
Tools appear in Claude as mcp__outlook-acme__ol_email_search etc. Cross-tenant accidents don't happen because the tokens are namespaced.
BYO Entra app registration
Tenants with strict app-allowlisting can override the bundled multi-tenant default:
{
"mcpServers": {
"outlook": {
"command": "uvx",
"args": ["mcp-server-outlook"],
"env": {
"OUTLOOK_TENANT_ID": "<your-tenant-guid>",
"OUTLOOK_CLIENT_ID": "<your-app-registration-guid>"
}
}
}
}
The app registration must be: multi-tenant or single-tenant, public client (no secret), Device Code flow allowed, with delegated permissions Mail.Read, Mail.ReadWrite, Calendars.Read, Calendars.ReadWrite, User.Read, offline_access. Optionally add Mail.Send if you intend to enable OUTLOOK_ALLOW_SEND=true for an opt-in send tool — the runtime auth flow only requests this scope when that env var is truthy.
Token storage
Three backends, auto-detected at first use:
| Tier | Backend | When | Setup |
|---|---|---|---|
| 1 | OS keyring | macOS Keychain / Windows Credential Locker / Linux with Secret Service | none |
| 2 | Plain file ~/.cache/outlook-mcp/<profile>/token.json mode 0600 |
Headless Linux default | none |
| 3 | Encrypted file (Fernet, Scrypt KDF) | When OUTLOOK_TOKEN_PASSPHRASE is set |
env var |
Force a specific backend with OUTLOOK_TOKEN_STORE=keyring|file|encrypted-file.
Troubleshooting
"No usable credentials"
The cached token expired (refresh tokens last ~60–90 days) or never existed. Run:
uvx mcp-server-outlook login --profile <name>
Linux: keyring fails / "Secret Service unavailable"
The plain-file backend kicks in automatically — no action needed. If you'd rather have encryption at rest:
export OUTLOOK_TOKEN_PASSPHRASE='<some-strong-passphrase>'
uvx mcp-server-outlook login --profile <name>
Development
git clone https://github.com/XMV-Solutions-GmbH/outlook-mcp.git
cd outlook-mcp
uv sync --extra dev
# Unit + integration (no real Microsoft Graph), with coverage reporting
./tests/run_tests.sh
# Harness (real Microsoft 365 sandbox; requires harness-profile login)
./tests/run_tests.sh harness
| Document | What's in it |
|---|---|
docs/app-concept.md |
Vision, MVP scope, MCP tool surface, auth, safety model |
docs/testconcept.md |
Three-layer test strategy (unit / integration / harness) |
ENGINEERING_PRINCIPLES.md |
Project-agnostic engineering baseline |
CLAUDE.md |
Project-specific overrides |
Sister project
mcp-server-sharepoint— same authorship pattern, audit-preserving SharePoint document edits with checkout/checkin.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md and the Code of Conduct first.
Bug reports and feature requests go to GitHub Issues.
Licence
Dual-licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT licence (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Contact
- Organisation: XMV Solutions GmbH
- Email: oss@xmv.de
- Website: https://xmv.de/en/oss/
- GitHub: @XMV-Solutions-GmbH
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mcp_server_outlook-0.3.0.tar.gz.
File metadata
- Download URL: mcp_server_outlook-0.3.0.tar.gz
- Upload date:
- Size: 225.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.11 {"installer":{"name":"uv","version":"0.11.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
104aa64c9bbc5fcd128c55be56f7004d7a3ed01a6d9a88f07e5f2f7f0a5b90b9
|
|
| MD5 |
49c5439cce83cd19376a892e4fea1871
|
|
| BLAKE2b-256 |
c13de71b0fa551e36fdf10713ad8310ce315192dd62e3179c56deeec65813ed5
|
File details
Details for the file mcp_server_outlook-0.3.0-py3-none-any.whl.
File metadata
- Download URL: mcp_server_outlook-0.3.0-py3-none-any.whl
- Upload date:
- Size: 70.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.11 {"installer":{"name":"uv","version":"0.11.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e027cba30265b0de7f3a57e6ec2b5714b9869dd5eeab06fb7664860549606a51
|
|
| MD5 |
60cc644a2cda4d54b8dec01ed6234cb4
|
|
| BLAKE2b-256 |
4f33516ef106e61653d60f20859cf3b2f1eb42baae782a4f98d22238a2154689
|