hotmail-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@hotmail-mcplist my inbox folders"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
hotmail-mcp
A local MCP server that connects Claude Desktop to a personal Hotmail/Outlook.com mailbox through the Microsoft Graph API.
Everything runs on your own machine. There is no hosted component, no shared Azure app, and no third party in the path — you register your own app, your token stays in your OS credential store, and the server talks to Graph directly.
⚠️ Use at your own risk. This tool can read your mail, move messages between folders, and create or delete Outlook inbox rules on a live mailbox. Read the source before you point it at your account. It is a personal tool, not an audited product. See Safety model for the guardrails that are actually in place.
Status
Built incrementally, one milestone at a time.
Milestone | Scope | State |
1 | Auth, platform auth gate, | ✅ verified end-to-end on Windows 11 + Windows Hello, from both the CLI and Claude Desktop |
2 |
| ✅ built, verified against a live mailbox |
3 | Rules: | ✅ verified against a live mailbox. |
4 |
| ✅ built, tuned against a live mailbox |
5 | Moves: | ✅ verified end-to-end against a live mailbox (move → restore round trip) |
6 |
| ✅ built; previews verified live, commits verified offline |
7 | Compose: | ✅ verified end-to-end against a live mailbox — draft created, reviewed, then sent |
Related MCP server: Claude-Read-Outlook-Attachments
Platform support
Be clear-eyed about this before you install:
Platform | Auth gate | State |
Windows | Windows Hello ( | Implemented. Requires a Hello PIN, fingerprint or face enrolled. |
Linux | polkit | Stub only. Interface exists, |
macOS | Touch ID | Stub only. Interface exists, |
On Linux and macOS the server falls back to NoOpGate, which enforces only
the session timeout and performs no user verification at all. It logs a
warning every time it grants access, and auth_status reports it as
unprotected. If you would rather the server refuse to start than run
unprotected, set HOTMAIL_MCP_REQUIRE_GATE=true.
Implementing the Linux/macOS gates is a well-marked contribution point — see
the stub classes at the bottom of auth/auth_gate.py.
Setup
1. Register your own Azure app
There is deliberately no shared app registration in this repo. Every user registers their own, so consent and revocation stay entirely under your control.
You need a directory first — read this before you start.
Since June 2024 Microsoft no longer lets you register an app that isn't inside a directory (an Entra tenant). If your Microsoft account has never had one, New registration shows:
"The ability to create applications outside of a directory has been deprecated."
This is expected, and it is not something this project can work around — a public-client PKCE flow still needs a client ID that belongs to you. The options, in order of practicality:
Sign up for a free Azure account with the same Microsoft account. This creates a "Default Directory" for you. Microsoft requires a card for identity verification, but Entra ID Free and app registrations are not charged.
Use a directory you already have, e.g. a work or school tenant you can register apps in.
The M365 Developer Program is the other option the portal suggests, but it now generally requires a Visual Studio Professional/Enterprise subscription, so it is rarely the cheaper route.
Crucially, hosting the registration in a directory does not change who can sign in. You still set the app to Personal Microsoft accounts only in step 3 below, still authenticate as your ordinary Hotmail/Outlook.com account, and still use the
/consumersauthority. The directory only holds the registration record.Apps registered before this change still work and still appear in the portal.
Related error, same cause. Before you have a directory, signing in to the portal puts you in a shared placeholder tenant named Microsoft Services. Anything directory-shaped — App registrations, Entra ID, users, groups — then fails with:
"Selected user account does not exist in tenant 'Microsoft Services' and cannot access the application '…'. The account needs to be added as an external user in the tenant first."
That is not a permissions problem to troubleshoot and there is no setting that fixes it. It means the directory does not exist yet. Complete the Azure signup at azure.microsoft.com/free — that provisions a Default Directory and makes you its Global Administrator — and then return to step 1. Checking Entra ID before signing up will always fail.
Signing up creates an Azure subscription (30-day credit, then pay-as-you-go). App registrations and Entra ID Free are not billed under either; just don't create chargeable resources like VMs or storage accounts.
Go to the Azure Portal → App registrations and choose New registration.
Name: anything, e.g.
hotmail-mcp.Supported account types: Personal Microsoft accounts only.
Redirect URI: select the Public client/native (mobile & desktop) platform and enter:
http://localhostThis is the loopback redirect used by the Authorization Code + PKCE flow. Registering bare
http://localhostis what lets MSAL pick a random free port at sign-in time — do not pin a specific port here.Click Register, then copy the Application (client) ID from the overview page. That is the only value you need.
Under Authentication, confirm Allow public client flows is set to Yes. There is no client secret — a public client must not have one.
2. Which permissions, and why
Under API permissions → Add a permission → Microsoft Graph → Delegated permissions, add:
Scope | Why this server needs it |
| Read message metadata and bodies, and move messages between folders. Graph has no read-only-plus-move scope, so moving requires ReadWrite. |
| Read and manage Outlook inbox rules. Rules live in mailbox settings, not under the Mail scopes. |
| Issues the refresh token that avoids re-logging in on every run. Added by MSAL automatically — do not list it in your config. |
Mail.Send is not in that list, and most people should leave it out.
The server can send — see Tools — but it can also compose replies
and new messages as drafts, and drafting needs only Mail.ReadWrite. A
draft lands in your Drafts folder for you to read and send yourself, which is
the more useful arrangement most of the time.
Omitting Mail.Send therefore does not remove a feature so much as change who
presses send. It also turns "this server cannot send mail as me" into a fact
about the token rather than a promise about the code — Microsoft refuses the
call, so no bug or misuse here can send anything.
If you do want it to send, you need all of:
Mail.Sendadded as a delegated permission in the portalMail.Sendadded toHOTMAIL_MCP_SCOPESpython cli.py login --forceto consentHOTMAIL_MCP_ALLOW_SEND=true
Step 4 is separate on purpose. Once a scope is consented, Microsoft keeps granting it — see Removing a scope is harder than it looks — so the config flag is the only reliable way to switch sending back off afterwards.
These are delegated scopes: the server can only ever do what you yourself could do in Outlook, and only after you consent in the browser.
Note: there is no hard-delete capability anywhere in this server, by design. "Delete" only ever means move to Deleted Items, so Outlook's own 30-day recovery window remains your safety net.
To revoke access later, go to account.live.com/consent/Manage.
3. Install
git clone <your-fork-url> hotmail-mcpcd hotmail-mcp && python -m venv .venv && .venv\Scripts\pip install -r requirements.txt4. Configure
copy .env.example .envOpen .env and set HOTMAIL_MCP_CLIENT_ID to the Application (client) ID from
step 1. Every other value has a working default. .env is gitignored.
(If you prefer JSON, copy config.example.json → config.json instead. The
real environment and .env take precedence over config.json.)
Then verify your setup — this prompts for nothing and touches no mail:
.venv\Scripts\python cli.py check5. Set up Windows Hello (Windows users)
The auth gate needs Hello enrolled. Check with cli.py check — if it reports
"no Windows Hello device is present", go to Settings → Accounts → Sign-in
options and set up a PIN (a PIN alone is enough; a fingerprint or camera
is not required). Then confirm the prompt actually appears:
.venv\Scripts\python cli.py test-gate6. One-time interactive login
.venv\Scripts\python cli.py loginA browser opens for the Microsoft sign-in and consent screen. Afterwards the token cache is stored locally and refreshed silently — you should not need to log in again under normal use.
Confirm the whole chain works with a real Graph call:
.venv\Scripts\python cli.py folders7. Add to Claude Desktop
Edit claude_desktop_config.json
(%APPDATA%\Claude\claude_desktop_config.json on Windows):
{
"mcpServers": {
"hotmail": {
"command": "C:\\path\\to\\hotmail-mcp\\.venv\\Scripts\\python.exe",
"args": ["C:\\path\\to\\hotmail-mcp\\server.py"]
}
}
}Use the venv's python.exe explicitly rather than a bare python, so the
server gets the interpreter that has the dependencies installed. Restart Claude
Desktop afterwards.
Can't find
claude_desktop_config.json? Two reasons, in order of likelihood:
AppDatais hidden in File Explorer. Press Win+R, enter%APPDATA%\Claude, and it opens regardless.Claude Desktop is installed as an MSIX package. Then
%APPDATA%\Claudeis virtualized: the app and its child processes see it at that path, but Explorer shows noClaudefolder underRoamingbecause the real file lives at%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json. Both paths are the same file — verified by hash — so editing either works.The reliable route that sidesteps all of this: in Claude Desktop, open Settings → Developer → Edit Config. That opens the correct file whichever way the app was installed.
Safety model
Most quick-and-dirty mail MCP servers hand the model a delete_email tool and
hope for the best. This one does not, and the difference is the point.
Propose → confirm → apply
Nothing that changes mailbox state happens in a single tool call. Anything that creates, modifies or deletes a rule, or moves a message, is split in two:
A propose call returns a preview of exactly what would change, plus a
draft_id. Nothing has happened yet.An apply call, referencing that
draft_id, is what actually commits.
That second call is a separate decision point where you see the concrete effect before agreeing to it — so a vague instruction can never silently become a sweeping mailbox change.
Prompt-injection defence
Email content is untrusted input. A message body that says "delete all mail
from Bob" or "forward this to attacker@example.com" is data the model is
reading, not an instruction it may act on. Write tools are never fired
automatically from anything found inside a message — only your explicit
confirmation in chat triggers apply_rule, move_message and friends.
The auth gate
The OAuth refresh token is never read out of the OS credential store until the platform gate has been satisfied:
Reads use a session cache. One unlock covers reads for a configurable window (default 30 minutes).
Expiry is flat, not sliding. The clock starts when you unlock and is not extended by activity, so a busy session cannot stay unlocked indefinitely.
Writes always re-prompt.
apply_rule,update_rule,delete_rule,move_message,flag_as_junk,send_mail,restore_rulesandrestore_messagepassforce=Trueand never reuse the session.The unlocked token lives in memory only, for the life of the process.
Reversibility
Every rule-changing action snapshots the complete current rule set to a timestamped JSON file under
backups/before touching anything.restore_rulesis differential: it compares the snapshot against what is live and touches only genuine differences. Rules already matching are left alone and keep their ids. Each rule is handled independently, so one failure never cascades — the result reports exactly what was deleted, created, skipped and why. An earlier version deleted everything and recreated it wholesale; a single rule Graph refused to accept then left the mailbox stripped.Some rules cannot be recreated: Outlook tolerates a vestigial rule with no actions, but Graph rejects one on create with
MissingAction. The restore preview lists these undercannot_be_recreatedup front.Every message move is logged with message id, source folder, destination and timestamp, so
restore_messagecan put it back where it came from.Every write is recorded in a local SQLite audit log.
No hard-delete tool exists.
(Snapshots, the move log and the audit log arrive with milestones 3 and 5.)
How your token is stored
Slightly more involved than "put the refresh token in keyring", for a concrete reason: Windows Credential Manager caps a credential blob at 2560 bytes (1280 characters), and an MSAL token cache is comfortably larger than that. So:
A random 32-byte key is generated and stored in the OS credential store via
keyring(Windows Credential Manager / GNOME Keyring / macOS Keychain).The MSAL token cache is encrypted with that key (Fernet: AES-128-CBC + HMAC-SHA256) and written to
token_cache.tokenin your data directory.
The token is therefore never plaintext on disk, and the file on disk is
inert without the key held by the OS. cli.py logout removes both.
Tools
Available now (milestones 1–2):
Tool | Kind | Description |
| diagnostic | Auth config and gate state. Never prompts, never reads the token. |
| read | All mail folders — including nested ones — with full paths and unread/total counts. |
| read | Metadata-only search — sender, subject, date, snippet. Never returns bodies. Paginated, default 25, hard cap 100. |
| read | One message in full, including its body. Explicit, one at a time. Body capped at 5000 chars by default; truncation is always reported. |
| read | Ranks senders Outlook's filter missed, with the reasons for each. Acts on nothing. |
| read | Current Outlook inbox rules as structured data. |
| preview | Drafts a rule and returns a preview plus a |
| preview | Previews changing a rule, field by field, before and after. Changes nothing. |
| preview | Previews deleting a rule, showing what stops happening. Changes nothing. |
| write | Commits any rule draft — create, update or delete. Fresh auth prompt; snapshots all rules first. |
| read | Everything undoable — rule snapshots and reversible message moves. |
| preview | Previews a restore — exactly what would be deleted and recreated. Changes nothing. |
| write | Commits a restore. Fresh auth prompt; snapshots current state first. |
| preview | Previews moving up to 50 messages to a folder. Changes nothing. |
| preview | Same, targeting Junk Email. |
| preview | Same, targeting Deleted Items — the only "delete" there is. |
| write | Commits a move. Fresh auth prompt; logs each message's source folder first. |
| write | Returns a message to the folder it was moved out of, using the move log. |
| read | Move history, showing what can still be reversed. |
| diagnostic | Whether drafting and sending are available, and precisely why not. |
| preview | Previews a reply draft. Creates nothing. |
| preview | Previews a new-message draft. Creates nothing. |
| write | Creates the draft in Drafts. Never sends. |
| preview | Previews sending an existing draft. Sends nothing. |
| write | Sends. The one irreversible action in this server. |
Drafting is safe by construction; sending is not
Creating a draft — including a reply draft — needs only Mail.ReadWrite, the
scope already required to read mail at all. Sending needs Mail.Send. Leave
that scope out and this server is physically incapable of sending mail: the
guarantee is enforced by Microsoft's token, not by care taken in this codebase.
That makes "draft it and I'll send it myself from Outlook" a genuinely strong default, and it is what this server does unless you opt out of it.
Sending requires two independent decisions to line up:
Mail.Sendpresent inHOTMAIL_MCP_SCOPESand consented in your Azure appHOTMAIL_MCP_ALLOW_SEND=true
Granting the Azure permission is deliberately not enough on its own. Beyond
that, apply_draft and apply_send are separate tools consuming separate draft
kinds, so a draft prepared for the Drafts folder cannot be committed as a send
by mistake — and only actual drafts can be sent, never a received message.
Sending is the only action here with no undo. Rules snapshot before every change; moves log their source folder; deletions go to Deleted Items. A sent message has left, and no part of this system can recall it.
Configuration is read once at startup, so changing HOTMAIL_MCP_ALLOW_SEND
needs a restart of the MCP server before it takes effect. That is deliberate for
this particular flag: a running server cannot have its send capability switched
on underneath it.
Removing a scope is harder than it looks
Microsoft's identity platform uses additive scopes: once you consent to a permission, Azure AD includes it in every later access token for that resource whether or not the request asked for it. So none of these remove a scope from your token, despite all three seeming like they should:
dropping it from
HOTMAIL_MCP_SCOPES— changes what is requested, not what is consentedremoving the API permission in your app registration — governs future consent, not past
running
cli.py login --force— new token, same consent record
To genuinely drop one, revoke the app at
account.live.com/consent/Manage and
sign in again. auth_status reports scopes_granted separately from
scopes_requested precisely so this discrepancy is visible rather than assumed
away.
This is the strongest argument for HOTMAIL_MCP_ALLOW_SEND existing at all: had
sending depended on the scope alone, a granted Mail.Send would be effectively
impossible to switch off.
Why moves are reversible
Graph assigns a new message id when a message changes folder, so the old id
stops working the moment a move succeeds. The move log therefore records both
ids alongside the source folder — without it, restore_message would have
neither a handle on the message nor anywhere to put it back.
restore_message commits directly rather than previewing, because its
destination isn't a choice: it is whatever the log recorded. There is nothing for
a preview to disambiguate.
How junk detection judges a sender
list_junk_candidates returns reasons, not just verdicts, so you can disagree
with the reasoning rather than the conclusion. Every candidate carries the signals
counted for and against it.
Only brand impersonation is strong enough to stand alone — a display name matching a known brand exactly, or within one character, while the sending domain's registrable label says otherwise. Everything else (no prior correspondence, nothing opened, domain already in Junk, high volume) is weak, because it is equally true of legitimate automated mail.
Counting against junk matters as much: transactional and security subjects,
government domains, display names their domain genuinely backs, and any sign
you've read the sender's mail before all reduce the score. Without those, every
no-reply@ address scores as unwanted — an early version flagged margin calls,
tax notices and a bounce message.
Two details worth keeping if you fork this: the brand must equal the domain's
registrable label, not merely appear in it (apple-secure-login.tk contains
"apple"), and ordinary words within one edit of a brand must match exactly, never
approximately ("cloud" is one edit from "icloud").
HOTMAIL_MCP_JUNK_ALLOWLIST exists because the strongest signal is also the
one that misfires hardest. A genuine brand sending from a non-obvious domain
looks identical to impersonation — Meta's WhatsApp Business marketing arrives
from messaging.metamail.com, and no amount of tuning distinguishes that from a
lookalike. Allowlisted senders are skipped outright and reported separately, so
your judgement overrules the score permanently rather than every time you look.
Recall is the weak point, and the tool says so. On a real mailbox it ranked three of seven hand-identified suspicious senders in the top tier; the other four — a compromised co-op mailbox, crypto-pump spam, a cryptic lead-in funnel, and a brand not in the list — scored 8–28 and sat among the weak signals. Treat it as something that surfaces candidates for review, not an authority on what is safe.
Rule guardrails
permanentDeleteis refused outright. Graph'sdeleteaction (moves to Deleted Items, keeps the 30-day recovery window) is allowed; the irreversible one is not creatable through this server at all.Forwarding rules require an explicit opt-in.
forwardTo,redirectToandforwardAsAttachmentToare rejected unlessallow_forwarding=trueis passed. A server-side forwarding rule sends your mail out of the mailbox and keeps doing so silently — and Outlook applies it without needingMail.Sendat all, so it works even on a setup deliberately configured never to send. That makes it the single most valuable thing a malicious email could try to talk the model into creating, and it can never be a side effect of a vague request.Condition and action names are validated against Graph's schema, so a typo fails loudly at preview time instead of silently creating a rule that never matches.
A rule with no conditions is refused — it would match every message.
Folder names are resolved to ids before the preview, so you confirm "Junk Email", not an opaque base64 id.
Drafts are single-use and expire after 30 minutes, and are held in memory only. A server restart discards them and
apply_rulefails closed.Updates replace fields outright. Passing a partial
conditionsobject drops every condition not listed, so the preview shows the whole field before and after rather than a diff that would hide what disappears.Read-only rules are refused for update and delete alike, with an explanation: Graph cannot represent them, so it cannot change them either. Edit those in Outlook — and note their definitions cannot be backed up.
Deleting a rule snapshots first, and the returned
backup_idis then the only remaining copy of that rule.
Metadata first, on purpose. search_mail cannot return message bodies at
all, no matter the result count, so surveying a mailbox never drags thousands of
lines of untrusted content into the conversation. Bodies arrive only via
read_message, one id per call, and every result carries an explicit
SECURITY_NOTICE marking the content as data rather than instructions.
Tests
pip install -e ".[dev]" && pytest217 tests, around 20 seconds, entirely offline — no credentials, no network,
no mailbox. That is deliberate: a suite needing a live account is one nobody can
run in CI, and one needing your account is one nobody else can run at all.
tests/fakes.py stands in for Microsoft Graph.
File | Covers |
| Session semantics, flat expiry, forced re-prompts, platform selection |
| Precedence (env → |
| Nested resolution, ambiguity, delta fallback, cycles |
| Metadata-only contract, paging, body truncation |
| Propose/apply/update/delete, guardrails, snapshots |
| Differential restore, duplicate names, partial failure |
| Impersonation detection, scoring, dampeners, allowlist |
| Move/restore, id changes, batch caps |
| Draft/send separation and its three guards |
| Audit and move logs |
| Advertised MCP surface, error handling |
Most of these encode a bug that actually happened rather than a hypothetical.
test_duplicate_names_are_counted_not_collapsed exists because a name-keyed
diff silently ignored duplicated rules — twice, on a real mailbox. The
impersonation cases are real senders, including the ones an early version
wrongly flagged: a tax authority, a payment confirmation, a bounce message.
test_moving_changes_the_message_id exists because Graph reassigns ids on move
and an earlier design would have made restores impossible.
Command-line helper
python cli.py check # diagnose config, credential store and auth gate
python cli.py test-gate # trigger the platform prompt on demand
python cli.py login # one-time interactive browser sign-in
python cli.py status # what is stored, without unlocking
python cli.py folders # list mail folders
python cli.py search "invoice" --folder Inbox --limit 10
python cli.py read <message_id>
python cli.py logout # delete the local token cache and its keyConfiguration reference
Every variable the server reads, in full. .env.example carries
the same list with longer explanations.
Variable | Default | Meaning |
| — | Required. Your Azure app's Application (client) ID. |
|
| Sign-in authority. |
|
| Loopback redirect for the PKCE flow. MSAL appends a free port. |
|
|
|
|
| Read-session window after an unlock. |
|
| If true, refuse to start rather than fall back to the unprotected gate. |
| repo root | Where |
|
| Entry name in the OS credential store. |
|
| Distinguishes credential-store entries if you run more than one instance. |
|
| Logs go to stderr; stdout is the MCP channel. |
|
| Add |
|
| Must also be true before anything can be sent, even with the scope granted. |
| (empty) | Senders |
| repo root | Where |
Possible future work
Ideas considered and deliberately deferred, kept here so the reasoning isn't lost:
Redact search terms from logs. At
INFO,httpxlogs the full Graph URL (which contains$searchterms) and the auth gate logs its reason string (likewise). Measured on Windows 11, Claude Desktop was not persisting the server's stderr —mcp-server-hotmail.logstayed empty and nothing appeared inmain.log— so nothing is currently reaching disk by that route. It does still print to your console when you runcli.pyyourself. The fix would be to send request URLs and reason strings toDEBUGand keepINFOfree of user-supplied text, soHOTMAIL_MCP_LOG_LEVEL=DEBUGremains the escape hatch for troubleshooting. Left as-is on purpose: the verbose logs are genuinely useful for debugging, and the exposure is local-only.Linux and macOS auth gates. See the stubs in
auth/auth_gate.py.Persist rule drafts across restarts.
propose_ruledrafts live in memory, so a server restart discards them andapply_rulefails closed. Safe, but a little inconvenient.
Non-goals
No cloud hosting or remote transport. No hard-delete. No write action that fires without explicit confirmation. No multi-account support. No shared Azure app.
License
MIT — see LICENSE. The disclaimer at the top of this file is the part that actually matters: this touches a live mailbox, so review the code yourself before granting it access.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/oshann/hotmail-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server