outlook-mcp
MCP server for Microsoft Outlook personal accounts via Microsoft Graph API.
Personal Microsoft accounts only —
@outlook.com,@hotmail.com,@live.com. Work/school accounts (Entra ID) are not supported: the server is built and tested on personal accounts. Fixes that make a tool behave on a work account, without changing what a personal account sees, are welcome.
Disclaimer: Independent open-source project. Not affiliated with, endorsed by, or supported by Microsoft Corporation. "Outlook" and "Microsoft Graph" are trademarks of Microsoft.
Who this is for
You'll like this if you're:
- An agent builder wiring Outlook into your own infra (OpenClaw, Claude Code, Cursor, custom MCP host) and want a typed tool surface — not stdout you have to parse
- Building on personal Microsoft accounts (Outlook.com / Hotmail / Live) and want full control: BYO Azure app, no enterprise consent flow, no shared client ID
- Looking for real coverage — mail, calendar, contacts, to-do, drafts, folders, batch ops, threading — instead of a mail-only or calendar-only wrapper
- Security-conscious: tokens in the OS keyring (Keychain on macOS, libsecret on Linux -- never cleartext unless you opt in), granular
allow_categories, optionalread_onlymode, zero telemetry
This isn't for you if you need work/school M365 accounts (use Microsoft's official tooling — Entra ID auth and admin-consent flows are out of scope here), or if a basic mail-only client would suffice (this has 72 tools — way more than you need for "read my inbox").
How it differs from other Outlook tools you'll find
This is the only first-class MCP server in the personal-Outlook space — most alternatives are bash scripts or skill-shaped CLI wrappers the agent shells out to. That distinction matters: the agent gets typed tool schemas with structured args/returns, not stdout it has to parse. Other things you won't find elsewhere: /$batch-optimized triage (10-20× faster on bulk ops), recursive folder ops with name resolution, granular per-category permissions, multiple mailboxes (one server per account via OUTLOOK_MCP_CONFIG_DIR), and full attachment write paths including >3MB upload sessions for drafts and calendar events.
What This Enables
Give your AI agent full Outlook access. Example prompts that just work:
- "Summarize my unread email from the past 24 hours and flag anything time-sensitive."
- "What's in my Focused Inbox right now? Anything in Other that looks like it belongs up top?"
- "Any shipping updates in my inbox? Track what I'm waiting on and when it's supposed to arrive."
- "Scan my email for upcoming subscription renewals — what's about to auto-charge in the next two weeks?"
- "I've got a trip to Seattle next week — check my calendar for the itinerary and create a To Do task with a packing checklist."
- "Draft a reply to the last message from my sister saying I'll call her this weekend."
- "Move all newsletter and promotional email from this week to a 'Read Later' folder — batch 20 at a time."
The server exposes 72 discrete tools (71 load by default; the document reader is opt-in) so the agent can compose its own workflow — read, triage, write, schedule, track tasks — without hardcoded macros.
Works With
- OpenClaw — native MCP support, available via ClawHub
- Claude Code —
claude mcp add outlook -- outlook-mcp - Cursor — MCP-compatible
- Any MCP client — it's a standard stdio MCP server, with an opt-in streamable-HTTP transport for clients that cannot spawn a local process
Listed on the official MCP Registry as io.github.mpalermiti/outlook-mcp.
Features
72 tools across 13 categories. 71 load by default; outlook_read_attachment is in the opt-in documents group (see Toolset selection).
- Auth (1) -- auth status check (login is via CLI)
- Mail Read (7) -- list inbox (with Focused Inbox and uncategorized filters), read message, bulk read by ID via
$batch, search (KQL), list folders, delta-sync inbox changes, composed "since last call" digest across mail/events/contacts - Mail Write (3) -- send, reply/reply-all, forward
- Mail Triage (9) -- move, delete (soft by default), flag, categorize, mark read/unread, reclassify (Focused Inbox), list/set/delete per-sender Focused Inbox overrides
- Calendar Read (4) -- list events (with recurring expansion), get event details, find events by key, delta-sync event changes
- Calendar Write (6) -- create, update, delete, RSVP (accept/decline/tentative), attach and remove event files
- Contacts (7) -- list, search, get, create, update, delete, delta-sync changes
- To Do (14) -- task lists, tasks (list/get/create/update/complete/delete), checklist items (add/update/delete), task attachments (list/download/upload/delete)
- Drafts (5) -- list, create (new, or a reply, reply-all or forward in the message's thread), update, send, delete
- Attachments (6) -- list, download, read document text (opt-in), send-with-attachments, attach-to-draft, remove-draft-attachment
- Folder Management (3) -- create, rename, delete mail folders
- Threading and Batch (3) -- list thread, copy message, batch triage
- User and Admin (4) -- whoami, list calendars, list categories, mail tips
Design principles:
- BYOID -- Bring Your Own ID. You register your own Azure AD app. No shared client ID.
- Zero telemetry -- no analytics, no local caching, no third-party calls.
- Token storage -- OS keyring via
azure-identity(macOS Keychain, Windows Credential Store, Linux Secret Service). - Input validation -- all inputs validated (email, Graph IDs, OData, KQL, datetimes) before any API call.
- Read-only mode -- set
read_only: truein config to block all write operations. Note this limits the tools, not the token -- see Whatread_onlydoes and does not do. - Soft delete -- delete moves to Deleted Items by default. Hard delete requires explicit
permanent: true. - Timezone-aware -- calendar operations respect your configured IANA timezone.
- Relative dates -- every datetime parameter takes ISO 8601 or an offset:
7dis seven days ago,+7dis seven days from now,nowis this moment. Units:m,h,d,w. - Bounded attachments -- attachment reads and writes are confined to
attachments_dir, so a message that asks an agent to mail a file elsewhere on disk cannot be obeyed. - Bounded delta cursors -- a
delta_tokenis caller-held state, so it is untrusted input. Every URL that would carry a Graph bearer token is parsed and required to be https ongraph.microsoft.com, which is what stops a poisoned cursor from redirecting your mailbox token to someone else. Its path must also be the delta endpoint of the tool it was handed to, so a cursor cannot point a delta tool at some other part of the mailbox. - Workflow prompts --
morning_brief,triage_folderandcatch_upship as MCP prompts, so the common sequences do not have to be reconstructed call by call.
Agent-friendly shape (1.8.0)
Two pure-code upgrades that make the tools cheaper and more recoverable for AI agents:
-
Concise mode — pass
concise=Trueto the six high-volume read tools (outlook_list_inbox,outlook_read_message,outlook_read_messages,outlook_search_mail,outlook_list_events,outlook_list_thread) to drop bulky fields: full message bodies, quoted prior-message text in threads, body previews and categories on inbox listings — typical payload reduction ~10×. Onoutlook_list_eventsthe trade is different and smaller:organizer,response_status,type,show_asandkeycome off,is_organizerandattendees_countare added, andis_onlineis spelledis_online_meeting, so a concise scan cannot tell a recurring occurrence from a one-off. Defaultconcise=Falsepreserves the existing response shape — strict backward compat. -
Structured Graph errors — every tool turns msgraph SDK exceptions into a tool error whose text carries Graph's message and a recovery hint: re-auth on 401, a link to the repo's ROADMAP dead-ends list on 403/
ErrorAccessDenied, re-list on 404/ErrorItemNotFound, back-off on 429, retry on 503 — except on the tools that can email someone (mail, invitations, event updates, RSVPs), where a 5xx says the write may already have gone through and where to check, because those writes are never re-sent after one.OutlookMCPErrorsubclasses and validation errors pass through unchanged.
Azure AD App Registration
You need to register a free Azure AD app to get a client ID.
Prerequisites (Personal Microsoft Accounts)
Microsoft has deprecated app registration for personal accounts without an Azure AD tenant. You need to create a free Azure account first:
- Go to azure.microsoft.com/free and sign up with your personal
@outlook.comaccount. Requires a credit card for identity verification but won't charge you. This creates a proper Azure AD tenant.
Register the App
-
Go to App Registrations and sign in with your
@outlook.comaccount. -
Click "+ New registration" and fill in:
- Name: anything except Microsoft-branded terms (e.g.
mp-outlook-mcp— names like "Outlook MCP" will be rejected) - Supported account types: select "Personal Microsoft accounts only"
- Redirect URI: leave blank
- Name: anything except Microsoft-branded terms (e.g.
-
Click Register. Copy the Application (client) ID from the overview page.
-
Go to Authentication (Preview) → Settings tab → toggle "Allow public client flows" to Yes → Save.
-
Go to API permissions → Add a permission → Microsoft Graph → Delegated permissions → add:
Mail.ReadWrite,Mail.SendCalendars.ReadWriteContacts.ReadWrite,Tasks.ReadWriteMailboxSettings.ReadUser.Read,offline_access
No client secret is needed. The device code flow uses public client auth.
Quick Start
Install
Option A — from PyPI (recommended):
uv tool install outlook-graph-mcp
# or: pipx install outlook-graph-mcp
# or: pip install outlook-graph-mcp
To read PDF and XLSX attachments as text, install the optional documents extra instead
(uv tool install 'outlook-graph-mcp[documents]') and enable the opt-in documents group;
see Attachments.
Option B — from source:
git clone https://github.com/mpalermiti/outlook-mcp.git
cd outlook-mcp
uv sync
Configure
Create ~/.outlook-mcp/config.json:
{
"client_id": "YOUR_APPLICATION_CLIENT_ID",
"tenant_id": "consumers",
"timezone": "America/Los_Angeles",
"read_only": true,
"attachments_dir": "~/.outlook-mcp/attachments"
}
The only required field is client_id. Everything else has sensible defaults. Start with read_only: true — flip to false when you're comfortable.
Register with your MCP client
If installed from PyPI:
{
"mcpServers": {
"outlook": {
"command": "outlook-mcp"
}
}
}
If installed from source:
{
"mcpServers": {
"outlook": {
"command": "uv",
"args": ["--directory", "/path/to/outlook-mcp", "run", "outlook-mcp"]
}
}
}
For OpenClaw, use the openclaw mcp CLI — it writes to mcp.servers in ~/.openclaw/openclaw.json for you:
# If installed from PyPI:
openclaw mcp set outlook '{"command":"outlook-mcp"}'
# If installed from source:
openclaw mcp set outlook '{"command":"uv","args":["--directory","/path/to/outlook-mcp","run","outlook-mcp"]}'
# Verify:
openclaw mcp list
openclaw mcp show outlook --json
Restart the OpenClaw gateway after registering. See the OpenClaw MCP docs for SSE/HTTP transport variants.
Authenticate
Run this once on the machine where the MCP server will run:
outlook-mcp auth # installed from PyPI
uv run outlook-mcp auth # from a source checkout
You'll get a URL and a code. Open the URL in any browser, enter the code, and sign in with your Microsoft account. Tokens are cached in the OS keyring — the MCP server picks them up automatically.
The consent screen lists the full read-write set (Mail.ReadWrite, Mail.Send, Calendars.ReadWrite, Contacts.ReadWrite, Tasks.ReadWrite, MailboxSettings.Read, User.Read) — even when the config starts with read_only: true, because read_only gates the tools, not the token (see What read_only does and does not do), and the scopes a first consent leaves out can never be granted later without logging in again. (The one exception is read_only_consent: true, which asks for the read permissions only; it exists for a separate read-only app, described under the same heading.) Every request afterwards — silent refresh and each Graph call — uses the .default scope, which on an already-consented account means exactly "the set you granted". That ordering is deliberate: on personal accounts a first consent asking only for .default can land a session with no delegated permissions, which then can't be redeemed (AADSTS70000) without logging in again (#82).
Other CLI commands (prefix uv run from a source checkout):
outlook-mcp status # Check auth status
outlook-mcp logout # Remove this instance's auth record (the OS token cache is left to age out)
outlook-mcp serve # Start MCP server (default; stdio, or HTTP with OUTLOOK_MCP_TRANSPORT)
Troubleshooting
me-token-to-replace is invalid on every call
You're on 1.22.0 installed after 2026-09-18. A fresh install of that version resolves microsoft-kiota-* 1.13 or later, which msgraph-core 1.5.1 doesn't handle, so every /me request reaches Graph as /users/me-token-to-replace. Fixed in 1.22.1, which caps kiota below 1.13 (#80). In 1.26.0 that cap is replaced by a msgraph-core>=1.5.2 floor, which carries the fix and works with kiota 1.13+:
uv tool upgrade outlook-graph-mcp
# or: pipx upgrade outlook-graph-mcp
# or: pip install --upgrade outlook-graph-mcp
Your config and sign-in are untouched; no re-auth needed.
SSL: CERTIFICATE_VERIFY_FAILED on Linux
If auth fails with [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate, your Python environment can't find the system CA bundle. This is common on minimal/container Linux images and with the isolated venv from uv tool install.
Point Python at your system CA bundle. Set both variables — auth (via azure-identity → requests) reads REQUESTS_CA_BUNDLE, while the delta/$batch paths (via httpx) read SSL_CERT_FILE:
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # httpx + Python ssl
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # azure-identity auth
The path varies by distro: Debian/Ubuntu use /etc/ssl/certs/ca-certificates.crt; RHEL/Fedora use /etc/pki/tls/certs/ca-bundle.crt. If the file is missing, install your distro's CA package (ca-certificates). Set these in the same environment your MCP client launches the server from so they apply at runtime, not just to the one-time auth command.
Token cache stored unencrypted (Linux)
On Linux without an encrypted store (libsecret/PyGObject not importable, or no running Secret Service), outlook-mcp auth and the server refuse to write the token cache in cleartext, and every tool call says why. Install the system packages for an encrypted store — see Privacy and Security — or set allow_unencrypted_token_cache: true to accept a plaintext cache file, after which the server logs a one-time warning at startup.
Tool Reference
Dates. Every datetime parameter (after, before, start, end, due,
deferred_send_datetime) accepts ISO 8601 — 2026-10-22 or 2026-10-22T14:30:00Z — or a
relative offset: 7d is seven days ago, +7d is seven days from now, and now is
this moment. Units are m, h, d, w. Bare means ago, matching the usual CLI
convention, so a due date in the future needs the +. Zone-less input is interpreted in your
configured timezone; responses are always UTC.
Folders. Every folder argument, outlook_create_folder's parent_folder and
outlook_batch_triage's move target take a well-known name (inbox, junkemail), a display
name (Junk Email), a Graph ID, or a path: Inbox/Travel/Flights is Flights under Travel under
Inbox. A / inside a folder's own name is written // (Inbox/Q1//Q2). A display name finds a
top-level folder of that name first; a name that several nested folders share is refused, and
the error lists their paths. outlook_rename_folder and outlook_delete_folder take an ID.
Auth
Note: Authentication is handled via the CLI (
outlook-mcp auth), not through MCP tools. See Authenticate above.
Mail Read
Mail Write
Bodies are plain text unless is_html=True (HTML, sent as written) or is_markdown=True. Markdown is converted to HTML for a safe subset: paragraphs, line breaks, -/*/+ and numbered lists, **bold**, *italic*, `code`, and [text](url) links to https: and mailto: addresses only. An image, , becomes a link to it, never an embedded picture. Everything else, raw HTML included, is shown as written. outlook_send_message, outlook_reply, outlook_forward (its comment), outlook_create_draft and outlook_update_draft take it; on a plain-text thread a Markdown reply is sent as written, as plain text.
Mail Triage
Calendar Read
Calendar Write
Contacts
To Do
Drafts
Attachments
Since 1.20.0, these tools only reach
attachments_dir(default~/.outlook-mcp/attachments). A bare filename resolves inside it; a path outside it is refused, including via a symlink. To email a file, move it there first — or widenattachments_dir, understanding that anything reachable from it can be sent. Before 1.20.0 these tools could read any file the server process could read, which meant an email asking an agent to attach one could be obeyed.The same fence covers the To Do attachment tools (
outlook_upload_task_attachment,outlook_download_task_attachment): uploads read fromattachments_dirand downloads write into it, so neither can sweep arbitrary files off disk. (Downloads are nottodo_write-gated — they are reads, likeoutlook_download_attachment; the fence, not the category, is what confines them.)A client that can't see the server's disk, such as a remote or sandboxed one, can carry the bytes in the call instead, and then nothing is read from or written to disk.
as_content=Trueon either download returns the attachment in the result, up to 10 MB: PNG, JPEG, GIF and WebP up to 5 MB as images, text up to 100,000 characters as text, and anything else, PDFs included, as an embedded resource holding the base64 bytes and their type. An image cut short (a PNG with no end chunk, say) comes back as that resource too. Text that carries control characters, or a lone surrogate its charset decoded to, also comes back as that resource, so it arrives unaltered.attachmentsonoutlook_send_with_attachmentsandoutlook_attach_to_drafttakes inline files,[{name, content_base64, content_type?}], up to 3 MB each (Graph's simple-attachment size). The fence still governs every path argument. An inline file is bytes the agent already holds and could already put in a message body, so it widens nothing.outlook_attach_to_eventandoutlook_upload_task_attachmenttake paths only.
For readable document contents rather than original bytes, use outlook_read_attachment.
The saved path from a download belongs to the server, so a remote client may not be able to open it.
as_content=True avoids that path and preserves the original file; the reading tool instead returns
extracted text for clients that cannot interpret the embedded binary resource. Existing downloads
and their defaults are unchanged.
PDF and XLSX parsing is optional: install outlook-graph-mcp[documents] (for example,
uv tool install 'outlook-graph-mcp[documents]', pipx install 'outlook-graph-mcp[documents]',
pip install 'outlook-graph-mcp[documents]', or uv sync --extra documents in a source
checkout). Enable the opt-in documents group with
OUTLOOK_MCP_TOOLSETS=mail,attachments,documents (plus any other groups you need) and restart.
Naming groups loads only those, so list every group you want; see
Toolset selection.
When enabled, a base install can read text in its declared charset (UTF-8 by default),
and returns an installation hint for PDF/XLSX. Parser dependencies have bounded versions
and are imported only in a short-lived worker; they add no requirement to the base install.
outlook_read_attachment(message_id, attachment_id, offset=0, max_chars=20000) returns text,
next_offset (null at the end), total_chars, file bytes / sha256, and warnings. Continue with
the same IDs and the returned offset until null. Each continuation downloads and parses the
whole file again, then returns the requested slice; there is no document cache. Use a larger
max_chars (up to 50000) to reduce repeated work. PDF output marks page numbers; a page without a
text layer is reported as blank or needing OCR. Extracted PDF text lines are prefixed with
| , so only the PDF reader can emit an unprefixed [Page N] structural marker in PDF output.
This guarantee is PDF-only: XLSX and text chunks can start mid-cell or mid-line, and apparent
cell addresses or page markers in their contents are untrusted text. No OCR is performed. XLSX output includes hidden
sheets, cell addresses, number formats, formulas and saved_result (Excel's stored value, null when
absent). Formulas are not recalculated, and charts/images are not extracted. All attachment
contents remain untrusted data.
Worksheet metadata (sheets) is returned only at offset 0, and only when its serialized list
plus at least one text character fits max_chars; otherwise the list is omitted and worksheet
names remain in the paged text. A workbook above 100 sheets is refused; displayed sheet names
are cut to Excel's 31-character limit, with … [N] marking a cut name and its one-based
worksheet position so shared prefixes remain distinguishable. Names within the limit are unchanged.
max_chars budgets text plus serialized worksheet metadata;
the fixed response envelope and bounded warnings are separate. A PDF slice starting mid-line
receives a | presentation prefix when it fits. continuation_prefix identifies that prefix:
strip it before joining chunks to reconstruct the text. Offsets and total_chars count the
canonical text, excluding this presentation prefix.
Reading is limited to 10 MiB of file bytes, 200 PDF pages, 100,000 visited spreadsheet cells, one
million extracted characters and 50 MiB / 2,000 entries of expanded XLSX content. max_chars
has a ceiling of 50,000. Text is decoded in-process; all extracted text is cleaned of control
characters before paging. PDF/XLSX use a subprocess for crash and resource containment,
not a sandbox: server credential environment variables are absent,
but it runs as the same user with filesystem, network and Keychain access. A parent watchdog
polls RSS every 10 ms and kills a parser above 256 MiB on Linux, macOS and Windows; brief
overshoot between polls is possible. Only one parser runs at a time, with a 30-second wall timeout.
OS resource limits supplement these checks where supported.
A limit is an explicit error, never a silently partial document.
Folder Management
Threading and Batch
User and Admin
Prompts
Three workflows ship as MCP prompts, so the common sequences do not have to be reconstructed call by call. Any MCP client that supports prompts will list them; in most clients they appear as slash commands or a prompt picker.
They cost nothing until invoked: prompts/list carries only a name and one line each, and
the body is fetched on use.
Configuration
Config lives at ~/.outlook-mcp/config.json (created with 0600 permissions on macOS and Linux; see Config permissions below for Windows). It is read as UTF-8 on every platform, and a byte-order mark is accepted. Set the OUTLOOK_MCP_CONFIG_DIR environment variable to move that settings directory (config.json, auth record, and the attachments default move with it) — see Two accounts, two instances below.
Environment configuration (optional)
auth, serve and status share the same loader. It first loads and validates config.json
(or the existing defaults when no file exists), then reads these optional variables. A bad,
unreadable or symlinked config still refuses startup; environment values cannot bypass it.
No override is automatically written back to the file.
Unset variables do nothing. A supported variable set to an empty value refuses startup,
including an empty Compose ${VAR} pass-through. Booleans accept only true/false
(case-insensitive, surrounding whitespace allowed); list input must be JSON, for example
["mail_drafts","contacts_write"].
An empty category list means all categories, so it cannot replace a restricted file list.
Each security setting is checked independently: enabling read-only mode does not excuse widening
another setting. Security permissions that require explicit consent in the file still require it.
Attachment roots are expanded and resolved without creating a directory. An override is kept as
the checked absolute path; .., sibling prefixes and symlinks cannot escape the configured root.
Every attachment path check rechecks that root, including when a directory becomes a symlink
after startup.
On Windows, a different drive/share or an unapproved UNC/device location is refused before
resolution can contact it.
Invalid inputs, misspelt security variables and attempts to loosen policy stop startup and name
the variable to fix in the launch environment. Existing OUTLOOK_MCP_CONFIG_DIR, toolset and
transport variables retain their meanings. With none of the new variables, file loading is unchanged.
These checks guard the supported setting variables, not the launch environment as a whole:
anyone who controls it can also retarget the settings file with OUTLOOK_MCP_CONFIG_DIR or HOME.
Toolset selection (optional) — OUTLOOK_MCP_TOOLSETS
The 71 default tool schemas load into the client's context every turn (the chars/4 proxy test_tool_surface_budget.py measures with; a different yardstick than the ~8.6k o200k figure in ROADMAP for the 62-tool surface). A client that only needs part of the surface can set the OUTLOOK_MCP_TOOLSETS environment variable to a comma-separated list of tool groups, and only those load. The account group (auth / identity) is always available.
# e.g. a recurring mail + calendar agent: 31 tools instead of 71 (about half the tool tokens per turn)
OUTLOOK_MCP_TOOLSETS="mail,calendar,digest,delta"
Groups: mail, drafts, attachments, calendar, contacts, todo, folders, digest, delta, admin, documents. Unset loads the 71 default schemas; documents is off until explicitly named. The full declared surface contains 72 tools. This only affects which tools are advertised; enabled tools behave identically.
Naming any group loads only the groups named, plus account, and a name that is not a group matches nothing. So to add documents to the default set, name all eleven: OUTLOOK_MCP_TOOLSETS=mail,drafts,attachments,calendar,contacts,todo,folders,digest,delta,admin,documents.
The groups are not the headings of the Tool Reference. mail holds the mail read, write and triage tools, outlook_list_thread, outlook_copy_message and outlook_batch_triage, but outlook_list_folders is in folders; the Focused Inbox tools (outlook_reclassify_message and the three override tools) are in admin, with categories and mail tips; the three *_delta tools are in delta and outlook_changes_since in digest; outlook_list_calendars is in calendar; the two draft-attachment tools are in drafts and outlook_read_attachment in documents; and account is outlook_auth_status and outlook_whoami.
Two accounts, two instances (optional) — OUTLOOK_MCP_CONFIG_DIR
One server process serves one mailbox. To work against two accounts, register two client entries and give each its own settings directory with OUTLOOK_MCP_CONFIG_DIR — pair it with OUTLOOK_MCP_TOOLSETS so each instance also only loads the tool groups it needs:
{
"mcpServers": {
"outlook-net": {
"command": "outlook-mcp",
"env": {
"OUTLOOK_MCP_TOOLSETS": "mail,calendar,contacts",
"OUTLOOK_MCP_CONFIG_DIR": "~/.outlook-mcp-net"
}
},
"outlook-neko": {
"command": "outlook-mcp",
"env": {
"OUTLOOK_MCP_TOOLSETS": "todo",
"OUTLOOK_MCP_CONFIG_DIR": "~/.outlook-mcp-neko"
}
}
}
}
Then run outlook-mcp auth once per instance, with the same env set, to write each auth record in its own directory. Each instance reads its own config.json (own client_id, timezone, permissions) from its own directory.
Only move the config directory — never HOME. The token cache is not in it: it stays in the OS keyring, and on macOS every azure-identity cache on the host shares one Keychain item, coordinated through a signal file — on this server, ~/.IdentityService/outlook-mcp.nocae (azure-identity appends .nocae to every non-CAE cache name; a CAE cache would be a different file over the same item). Both processes must keep consulting that same signal file so their cache writes lock and merge into the one shared entry — which is exactly what moving the config directory preserves and redirecting HOME (or the cache location) would break: two signal files that each believe they own the Keychain item overwrite each other's token. OUTLOOK_MCP_CONFIG_DIR deliberately moves only where config.json, the auth record, and attachments live; unset or empty keeps the default ~/.outlook-mcp.
Since 1.29, to require a particular sign-in for an instance, add
"expected_username": "[email protected]" to that instance's config.json. Older versions ignore
this key with a warning and do not enforce the guard. This optional guard compares Microsoft's
saved sign-in username case-insensitively; it does not resolve email aliases. A cached mismatch is
refused before a credential is built or refreshed, and outlook-mcp status shows the refusal and
remedy without either username: sign in as the expected user, or correct expected_username if it
is wrong. A new sign-in is checked after Microsoft's interactive sign-in completes but before its
record is saved or credential used. A refused sign-in leaves the previous auth record unchanged,
but azure-identity may already have added that account's refresh token to the local shared OS token
cache. On macOS this is the same Keychain item, with the previous account's tokens still present.
Nothing in outlook-mcp, including logout, removes these cached tokens.
Unset/null preserves today's behavior. Blank values and misspellings such as expected_usename
stop startup instead of silently disabling the guard.
Remote / HTTP transport (optional) — OUTLOOK_MCP_TRANSPORT
stdio is the default, and every client entry above uses it. For a server that runs on another machine or in a container, or a client that cannot spawn a local process, OUTLOOK_MCP_TRANSPORT=streamable-http serves MCP's streamable-HTTP transport at /mcp instead:
export OUTLOOK_MCP_TRANSPORT=streamable-http
export OUTLOOK_MCP_HTTP_HOST=127.0.0.1 # default 127.0.0.1
export OUTLOOK_MCP_HTTP_PORT=8000 # default 8000
export OUTLOOK_MCP_HTTP_TOKEN=... # python -c "import secrets; print(secrets.token_urlsafe(32))"
outlook-mcp serve
The client then connects to https://<your proxy>/mcp and sends Authorization: Bearer <token>.
OUTLOOK_MCP_TRANSPORT takes stdio (the default, also when unset or empty) or streamable-http. Any other value, a port outside 1–65535, a malformed OUTLOOK_MCP_HTTP_ALLOWED_HOSTS, or a missing or malformed token stops the server at startup with the reason, before anything binds.
For a container, explicitly set OUTLOOK_MCP_HTTP_HOST=0.0.0.0 and OUTLOOK_MCP_HTTP_ALLOWED_HOSTS=mcp.example.com for the public hostname your proxy forwards. Every public bind prints a plain-HTTP warning before it starts. A wildcard bind (0.0.0.0 or ::) without allowed hosts also warns that normal client Host headers will receive 421: the literal wildcard address is not the hostname or address clients use. The token protects against other OS users and browser pages; code running as your OS user can read the client configuration or launch the server itself. Forwarded headers are not trusted.
A secret file can replace the environment token: set OUTLOOK_MCP_HTTP_TOKEN_FILE to a regular file of at most 4096 bytes containing only the token (an ending newline is fine), and unset OUTLOOK_MCP_HTTP_TOKEN. Both set is refused. On POSIX the file must belong to the server user with no group or other permissions (chmod 600); symlinks are refused. On Windows, restrict the file ACL to the server account yourself; the server does not validate Windows ACLs. File contents and paths are never included in token-file errors.
Container secret mounts need the same file protections.
- Kubernetes: projected Secret keys are symlinks, so their usual volume paths are refused. A file mounted with
subPathavoids that symlink, but must still belong to the server UID and have mode0400or0600;subPathalone does not fix ownership. For a non-root server, an init container can copy the projected key into a writableemptyDiras the server UID with mode0400, then the application mounts that prepared directory read-only. Recreate the Pod after rotation to refresh the copy orsubPathmount; subPath mounts do not receive Secret updates. - Docker: Swarm secrets default to root-owned, world-readable files, which are refused. Set the secret's
uidto the server UID,gidto its group andmodeto0400. For Compose secrets backed by a local file,uid,gidandmodeare ignored: prepare a regular host file with the matching container UID and mode0400, and mount it read-only. SetOUTLOOK_MCP_HTTP_TOKEN_FILEto the resulting in-container file path. Restart the server after replacing a token; it reads the file only at startup.
This changes who can reach the mailbox. Over stdio, only the process that started the server can. Over HTTP, one request to the port is enough for anything that holds a token, so the token is the whole of the access control:
- A token is required on every bind, loopback included. The server refuses to start without a token from
OUTLOOK_MCP_HTTP_TOKENorOUTLOOK_MCP_HTTP_TOKEN_FILE. On loopback, a port with no token would let other OS users or browser pages read the mailbox with one HTTP request. Every request must carryAuthorization: Bearer <token>or gets401. The token is one value of at least 32 characters fromA–Z a–z 0–9 -._~+/, at least 8 of them different (trailing=padding is allowed). This rejects obvious low-variety values, not every predictable token; use the generator above. To change it, restart the server with the new one. - Plain HTTP sends the token in the clear. The server does not terminate TLS. On any bind other machines can reach, the token and everything the tools return cross the network readable by anything on the path, unless a reverse proxy in front of the server terminates TLS. Put it behind one, and let the proxy authenticate callers too if more than one client will reach it.
- Host and Origin checks apply to every bind. Loopback binds accept loopback hosts and origins.
OUTLOOK_MCP_HTTP_ALLOWED_HOSTSoptionally adds comma-separated hostnames or IPs, with an optional port (for examplemcp.example.comormcp.example.com:8443). On a public bind it selects the permitted hosts; unset permits only the literal bind address. Bare names and:*entries permit any port, including a bare Host on the default HTTP/HTTPS port. Invalid Host is refused with 421; a supplied Origin must match an allowed host over HTTP or HTTPS, or gets 403. Clients without Origin work. A same-host proxy can sendHost: 127.0.0.1:<port>. - File paths are on the server. Attach and send-with-attachments paths resolve inside the server’s
attachments_dir; a remote client cannot hand over its own local path. A downloaded file andsaved_tostay on the server, and a path refusal may name the server’s absolute attachments directory. Inlineattachmentsandas_content=Truecarry the bytes in the call instead (see Attachments). - It is stateless. The server issues no
Mcp-Session-Idand answers each request with JSON rather than an event stream. No tool sends anything to the client mid-call, so nothing is lost. - One process still serves one account. Pair it with
OUTLOOK_MCP_CONFIG_DIR, as for stdio (one process, one port and one settings directory per mailbox), and runoutlook-mcp authwhere the server runs, with the same environment. A container usually has no keyring; see Token cache stored unencrypted (Linux). - No token, request URL or request line is logged.
What read_only does and does not do
read_only: true stops outlook-mcp's write tools from running. Ask it to send mail and it
refuses.
It does not make your Microsoft credential read-only. The first sign-in consents the full read-write set, even with read_only: true in the config — the scopes a first consent leaves out can never be added without logging in again (every refresh afterwards uses .default -- "everything this account has already consented" -- so a session granted only read scopes would fail every write with 403 no matter what the config says). The stored token can send mail whether read_only is on or off, and a session consented before you turned read_only on keeps its write scopes.
Two consequences worth understanding:
read_onlyis a line in a text file. Anything able to edit~/.outlook-mcp/config.jsonturns it off and has write access immediately -- no re-authentication, no new consent prompt.- The enforcement lives in this server's Python code. Any other process holding the cached token is unaffected by it.
So treat read_only as a guardrail against an agent doing something rash, not as a
security boundary. If you want a credential that genuinely cannot write, have Microsoft
enforce it rather than us:
-
Register a second Azure app and give it the read permissions only:
Mail.Read,Calendars.Read,Contacts.Read,Tasks.Read,MailboxSettings.Read,User.Read. It has to be an app this account has never granted write access to. Microsoft remembers consent per app, and a refresh returns everything that app was ever granted. -
Point
client_idat it and set both keys:{ "client_id": "<the read-only app>", "read_only": true, "read_only_consent": true } -
Run
outlook-mcp auth. Withread_only_consentthe consent screen lists the read permissions only. Without it, sign-in would ask this app for the read-write set too.
read_only_consent is refused without read_only: true: a sign-in that asked only for read
access cannot write, so a server expecting writes would fail on every one. And a sign-in
belongs to the app it was made with. After client_id changes, the server will not use the
old one — outlook-mcp status and outlook_auth_status say so until you run
outlook-mcp auth again.
Granular Write Permissions (optional)
By default, read_only: false unlocks all write tools. For finer control, set allow_categories to restrict write access to specific categories. Read tools (list, search, get) are always allowed — allow_categories only narrows the write surface.
Available categories:
Example policies:
Draft-only assistant (agent can compose drafts, you review and send):
{ "read_only": false, "allow_categories": ["mail_drafts", "mail_triage", "todo_write"] }
(Note that todo_write includes the write-side task-attachment tools — file reads
from attachments_dir, uploads to Graph, and irreversible deletes. Listing and
downloading task attachments are reads and are not write-gated, like the mail
attachment reads — see the table above.)
Calendar-only (agent can manage your schedule, nothing else):
{ "read_only": false, "allow_categories": ["calendar_write"] }
(It cannot invite anyone. An invitation is an email, so attendees need mail_send too — add it
if the agent should set up meetings with other people, not just block out your own time.)
Full write access (agent can do everything):
{ "read_only": false }
Read-only (safest default, no writes):
{ "read_only": true }
When allow_categories is set, any tool in a non-allowed category returns a permission-denied error (PermissionDeniedError) naming the blocked category. When allow_categories is empty (or unset) and read_only is false, all write tools are permitted. read_only: true always takes precedence — if set, all writes are blocked regardless of allow_categories. Unknown category names are rejected at config load time with a validation error; only the seven names above are accepted.
Privacy and Security
- Zero telemetry. No analytics, no tracking, no usage data collected.
- Zero local caching. Every call goes directly to Microsoft Graph. No local email/calendar storage. (One carve-out: the To Do default-list id is resolved once per process and kept for the session — an id, not content; see
outlook_list_tasks.) - Zero third-party calls. The server only talks to
graph.microsoft.comandlogin.microsoftonline.com. - Token storage. OAuth tokens are persisted via
azure-identity'sTokenCachePersistenceOptions. On macOS the OS Keychain is used; on Windows, DPAPI; on Linux with PyGObject/libsecret available, gnome-keyring. On Linux without libsecret (e.g. the isolated venv created byuv tool install), the server refuses to persist tokens unlessallow_unencrypted_token_cacheistrue; with it, they fall back to a0600plaintext file at~/.IdentityService/and the MCP logs a one-time warning at startup. For encrypted storage on Linux, installpython3-gi gnome-keyring libsecret-1-0and re-create the venv with--system-site-packages. - No logging of sensitive data. Message bodies, recipient addresses, and tokens are never logged.
- Config permissions. On macOS and Linux the config directory is created
0700and the config file0600, and a loose mode on the file is repaired on load. On Windows those POSIX modes cannot be enforced —os.chmodthere sets only the read-only attribute — so access is governed by the path's Windows ACL, including whatever it inherits from the directory it was created under, which this server neither applies nor verifies. Symlinked configs are rejected on every platform. - Input validation. All user inputs (email addresses, Graph IDs, OData filters, KQL queries, datetimes) are validated and sanitized before reaching the Graph API.
- HTTP transport (opt-in). It changes who can reach the mailbox: a bearer token is required on every bind and checked on every request, and the server speaks plain HTTP, so TLS belongs to a proxy in front. The whole security story is in Remote / HTTP transport.
Development
# Install dev dependencies and the documents extra, as CI does (without it the PDF/XLSX tests skip)
uv sync --all-extras
# Run tests
uv run pytest
# Lint
uv run ruff check src/ tests/ scripts/
# Format (CI fails if `ruff format --check` would change a file)
uv run ruff format src/ tests/ scripts/
# Run server locally (stdio)
uv run outlook-mcp
Requirements: Python 3.10+
Roadmap
- Advanced mail -- raw MIME export, internet message headers
- Calendar -- cancel event (with attendee notification)
- Work/school-only tools -- free/busy, meeting times, automatic replies, shared mailboxes: not planned for now (#133). If any are taken later, they come as an opt-in group that personal accounts never load
Committed direction, and what was investigated and ruled out (inbox rules, mailbox settings), is in ROADMAP.md.
License
MIT. See LICENSE.