hotmail-mcp
# 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](#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, `list_folders` | ✅ verified end-to-end on Windows 11 + Windows Hello, from both the CLI and Claude Desktop |
| 2 | `search_mail`, `read_message` | ✅ built, verified against a live mailbox |
| 3 | Rules: `list_rules`, `propose_rule`, `apply_rule`, snapshots, `restore_rules` | ✅ verified against a live mailbox. `restore_rules` failed destructively on first use, was rewritten as a differential restore, and is covered by regression tests |
| 4 | `list_junk_candidates` | ✅ built, tuned against a live mailbox |
| 5 | Moves: `propose_move`, `apply_move`, `propose_flag_as_junk`, `propose_delete`, `restore_message`, `list_moves` | ✅ verified end-to-end against a live mailbox (move → restore round trip) |
| 6 | `propose_update_rule`, `propose_delete_rule` | ✅ built; previews verified live, commits verified offline |
| 7 | Compose: `propose_draft_reply`, `propose_draft_mail`, `apply_draft`, `propose_send_draft`, `apply_send` | ✅ verified end-to-end against a live mailbox — draft created, reviewed, then sent |
---
## Platform support
Be clear-eyed about this before you install:
| Platform | Auth gate | State |
|---|---|---|
| **Windows** | Windows Hello (`UserConsentVerifier`) | **Implemented.** Requires a Hello PIN, fingerprint or face enrolled. |
| Linux | polkit | **Stub only.** Interface exists, `_challenge()` is not implemented. |
| macOS | Touch ID | **Stub only.** Interface exists, `_challenge()` is not implemented. |
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`](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](https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/microsoft-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 `/consumers` authority. 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](https://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.
1. Go to the [Azure Portal → App registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)
and choose **New registration**.
2. **Name:** anything, e.g. `hotmail-mcp`.
3. **Supported account types:** *Personal Microsoft accounts only*.
4. **Redirect URI:** select the **Public client/native (mobile & desktop)**
platform and enter:
```
http://localhost
```
This is the loopback redirect used by the Authorization Code + PKCE flow.
Registering bare `http://localhost` is what lets MSAL pick a random free
port at sign-in time — do **not** pin a specific port here.
5. Click **Register**, then copy the **Application (client) ID** from the
overview page. That is the only value you need.
6. 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 |
|---|---|
| `Mail.ReadWrite` | Read message metadata and bodies, and move messages between folders. Graph has no read-only-plus-move scope, so moving requires ReadWrite. |
| `MailboxSettings.ReadWrite` | Read and manage Outlook inbox rules. Rules live in mailbox settings, not under the Mail scopes. |
| `offline_access` | 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](#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:
1. `Mail.Send` added as a delegated permission in the portal
2. `Mail.Send` added to `HOTMAIL_MCP_SCOPES`
3. `python cli.py login --force` to consent
4. `HOTMAIL_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](#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](https://account.live.com/consent/Manage).
### 3. Install
```bash
git clone <your-fork-url> hotmail-mcp
```
```bash
cd hotmail-mcp && python -m venv .venv && .venv\Scripts\pip install -r requirements.txt
```
### 4. Configure
```bash
copy .env.example .env
```
Open `.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:
```bash
.venv\Scripts\python cli.py check
```
### 5. 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:
```bash
.venv\Scripts\python cli.py test-gate
```
### 6. One-time interactive login
```bash
.venv\Scripts\python cli.py login
```
A 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:
```bash
.venv\Scripts\python cli.py folders
```
### 7. Add to Claude Desktop
Edit `claude_desktop_config.json`
(`%APPDATA%\Claude\claude_desktop_config.json` on Windows):
```json
{
"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:
>
> 1. `AppData` is hidden in File Explorer. Press **Win+R**, enter
> `%APPDATA%\Claude`, and it opens regardless.
> 2. **Claude Desktop is installed as an MSIX package.** Then `%APPDATA%\Claude`
> is virtualized: the app and its child processes see it at that path, but
> Explorer shows no `Claude` folder under `Roaming` because 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:
1. A **propose** call returns a *preview* of exactly what would change, plus a
`draft_id`. Nothing has happened yet.
2. 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_rules` and
`restore_message` pass `force=True` and 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_rules` is **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 under `cannot_be_recreated` up front.
- Every message move is logged with message id, source folder, destination and
timestamp, so `restore_message` can 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.token` in 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 |
|---|---|---|
| `auth_status` | diagnostic | Auth config and gate state. Never prompts, never reads the token. |
| `list_folders` | read | All mail folders — including nested ones — with full paths and unread/total counts. |
| `search_mail` | read | Metadata-only search — sender, subject, date, snippet. Never returns bodies. Paginated, default 25, hard cap 100. |
| `read_message` | read | One message in full, including its body. Explicit, one at a time. Body capped at 5000 chars by default; truncation is always reported. |
| `list_junk_candidates` | read | Ranks senders Outlook's filter missed, with the reasons for each. Acts on nothing. |
| `list_rules` | read | Current Outlook inbox rules as structured data. |
| `propose_rule` | preview | Drafts a rule and returns a preview plus a `draft_id`. **Changes nothing.** |
| `propose_update_rule` | preview | Previews changing a rule, field by field, before and after. **Changes nothing.** |
| `propose_delete_rule` | preview | Previews deleting a rule, showing what stops happening. **Changes nothing.** |
| `apply_rule` | **write** | Commits any rule draft — create, update or delete. Fresh auth prompt; snapshots all rules first. |
| `list_backups` | read | Everything undoable — rule snapshots *and* reversible message moves. |
| `restore_rules` | preview | Previews a restore — exactly what would be deleted and recreated. **Changes nothing.** |
| `confirm_restore_rules` | **write** | Commits a restore. Fresh auth prompt; snapshots current state first. |
| `propose_move` | preview | Previews moving up to 50 messages to a folder. **Changes nothing.** |
| `propose_flag_as_junk` | preview | Same, targeting Junk Email. |
| `propose_delete` | preview | Same, targeting Deleted Items — the only "delete" there is. |
| `apply_move` | **write** | Commits a move. Fresh auth prompt; logs each message's source folder first. |
| `restore_message` | **write** | Returns a message to the folder it was moved out of, using the move log. |
| `list_moves` | read | Move history, showing what can still be reversed. |
| `compose_status` | diagnostic | Whether drafting and sending are available, and precisely why not. |
| `propose_draft_reply` | preview | Previews a reply draft. **Creates nothing.** |
| `propose_draft_mail` | preview | Previews a new-message draft. **Creates nothing.** |
| `apply_draft` | **write** | Creates the draft in Drafts. **Never sends.** |
| `propose_send_draft` | preview | Previews sending an existing draft. **Sends nothing.** |
| `apply_send` | **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:
1. `Mail.Send` present in `HOTMAIL_MCP_SCOPES` *and* consented in your Azure app
2. `HOTMAIL_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 consented
- removing 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](https://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
- **`permanentDelete` is refused outright.** Graph's `delete` action (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`, `redirectTo` and
`forwardAsAttachmentTo` are rejected unless `allow_forwarding=true` is passed.
A server-side forwarding rule sends your mail out of the mailbox and keeps
doing so silently — and Outlook applies it without needing `Mail.Send` at 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_rule` fails closed.
- **Updates replace fields outright.** Passing a partial `conditions` object
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_id` is 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
```bash
pip install -e ".[dev]" && pytest
```
217 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 |
|---|---|
| `test_auth_gate.py` | Session semantics, flat expiry, forced re-prompts, platform selection |
| `test_config.py` | Precedence (env → `.env` → `config.json`), validation |
| `test_folders.py` | Nested resolution, ambiguity, delta fallback, cycles |
| `test_mail_tools.py` | Metadata-only contract, paging, body truncation |
| `test_rule_tools.py` | Propose/apply/update/delete, guardrails, snapshots |
| `test_backup_tools.py` | Differential restore, duplicate names, partial failure |
| `test_junk_tools.py` | Impersonation detection, scoring, dampeners, allowlist |
| `test_move_tools.py` | Move/restore, id changes, batch caps |
| `test_compose_tools.py` | Draft/send separation and its three guards |
| `test_audit_log.py` | Audit and move logs |
| `test_server_tools.py` | 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
```bash
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 key
```
---
## Configuration reference
Every variable the server reads, in full. [`.env.example`](.env.example) carries
the same list with longer explanations.
| Variable | Default | Meaning |
|---|---|---|
| `HOTMAIL_MCP_CLIENT_ID` | — | **Required.** Your Azure app's Application (client) ID. |
| `HOTMAIL_MCP_AUTHORITY` | `…/consumers` | Sign-in authority. `consumers` is correct for personal accounts. |
| `HOTMAIL_MCP_REDIRECT_URI` | `http://localhost` | Loopback redirect for the PKCE flow. MSAL appends a free port. |
| `HOTMAIL_MCP_AUTH_GATE` | `auto` | `auto` \| `hello` (insist) \| `none` (disable). |
| `HOTMAIL_MCP_SESSION_MINUTES` | `30` | Read-session window after an unlock. |
| `HOTMAIL_MCP_REQUIRE_GATE` | `false` | If true, refuse to start rather than fall back to the unprotected gate. |
| `HOTMAIL_MCP_DATA_DIR` | repo root | Where `backups/`, `audit_log.db` and the token cache live. |
| `HOTMAIL_MCP_KEYRING_SERVICE` | `hotmail-mcp` | Entry name in the OS credential store. |
| `HOTMAIL_MCP_ACCOUNT` | `default` | Distinguishes credential-store entries if you run more than one instance. |
| `HOTMAIL_MCP_LOG_LEVEL` | `INFO` | Logs go to stderr; stdout is the MCP channel. |
| `HOTMAIL_MCP_SCOPES` | `Mail.ReadWrite MailboxSettings.ReadWrite` | Add `Mail.Send` only if you want the server to send. Drafting works without it. |
| `HOTMAIL_MCP_ALLOW_SEND` | `false` | Must also be true before anything can be sent, even with the scope granted. |
| `HOTMAIL_MCP_JUNK_ALLOWLIST` | *(empty)* | Senders `list_junk_candidates` must never flag. Addresses or domains. |
| `HOTMAIL_MCP_DATA_DIR` | repo root | Where `backups/`, `audit_log.db` and the token cache live. |
---
## Possible future work
Ideas considered and deliberately deferred, kept here so the reasoning isn't
lost:
- **Redact search terms from logs.** At `INFO`, `httpx` logs the full Graph URL
(which contains `$search` terms) 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.log` stayed empty and nothing appeared
in `main.log` — so nothing is currently reaching disk by that route. It does
still print to your console when you run `cli.py` yourself. The fix would be
to send request URLs and reason strings to `DEBUG` and keep `INFO` free of
user-supplied text, so `HOTMAIL_MCP_LOG_LEVEL=DEBUG` remains 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_rule` drafts live in memory,
so a server restart discards them and `apply_rule` fails 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](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.
TDQS
Scored across 25 tools
Each tool targets a distinct action or resource. The PREVIEW/COMMIT pattern clearly separates proposal from execution, and list/read/search tools are unambiguous. No significant overlap between tools like propose_move, propose_delete, and propose_flag_as_junk is resolved by explicit folder targeting.
Most tools follow a consistent 'verb_noun' pattern (list_folders, propose_rule, apply_move). However, there are minor deviations: auth_status and compose_status lack a verb, and confirm_restore_rules breaks the 'apply_' convention. These are isolated exceptions in an otherwise orderly naming scheme.
25 tools is on the higher end, but each tool serves a distinct purpose within mailbox, rules, and junk management. The complexity of email workflows justifies the count, and no tool feels redundant. Slight over-scoping is acceptable given the domain.
The tool set covers core CRUD for rules, messages (search, read, move, delete), drafts (propose, apply, send), and junk detection. Missing features like marking read/unread or flagging are minor gaps. The reverse/backup mechanisms for moves and rules add robustness.