MCP server by MalcolmWardlaw
fastmail-clean-mcp
A sanitizing Fastmail MCP proxy. It speaks JMAP directly to
api.fastmail.com and returns email bodies already stripped of HTML, tracking
URLs, and invisible preview padding — so the model never pays tokens for noise
it can't use.
Unofficial and independent. Not affiliated with or endorsed by Fastmail Pty Ltd. "Fastmail" is a trademark of its owner, used here only to describe what the proxy talks to.
Why
Fastmail's official MCP server (https://api.fastmail.com/mcp) is excellent —
OAuth, zero-maintenance, works on mobile — but it does no body
sanitization. It hands back the sender's text/plain part verbatim, and that
part is frequently garbage:
- HTML-stuffed
text/plain. Many ESP/transactional senders (PayPal and most billing mail) dump a full<table>+ inline CSS into the plain-text part. Measured: one "your statement is available" email = ~14,000 tokens for ~40 tokens of meaning (≈378×). - Tracking URLs. The same ~900-char base64 click-tracker repeated a dozen
times per message (
euid,tuid,pid,configId,utm_*,%opentrack%, …). - Invisible padding. Runs of U+034F / U+00AD that marketers use to blank the inbox preview line; tiktoken expands a 59-char run into ~184 tokens.
A strip only saves tokens if it happens in the retrieval path, before the bytes enter the context window. The official server won't do it and exposes no knob, so this proxy intercepts and cleans there. The goal: stream full-ish bodies of an entire inbox sweep into context and categorize with real compute — not guess from previews, and not hard-pull raw bodies that blow up every time.
Architecture
The two servers sit side by side over the same JMAP account. Neither knows the other exists; mailbox and email IDs are identical across both.
┌─ official MCP server (Fastmail-hosted, raw) ─┐
your Fastmail account ──┤ ├─→ Claude
(JMAP API) └─ server.py (this proxy, sanitizes) ──────────┘
Keep the official server connected for what this one deliberately omits (calendar, contacts, sending); route body reads here. See Tool routing.
Tools
| tool | returns |
|------|---------|
| list_folders | mailbox folders: id, name, role, totalEmails, unreadEmails |
| search_email | email summaries (subject/from/receivedAt/preview) — no bodies |
| read_email | one email with a fully sanitized body (head_chars caps length) |
| triage_bulk | N emails with sanitized, head-truncated bodies in one batched JMAP call |
This proxy is read-only by design — no send/move/flag/delete.
Files
| file | role |
|------|------|
| fastmail_clean.py | pure-Python sanitizer. clean() is the whole point. Network-free; --selftest benchmarks it on bundled fixtures. |
| server.py | FastMCP server: JMAP client + the four tools above. Imports clean() from fastmail_clean.py — keep them in the same dir. |
Both files carry PEP 723 inline metadata,
so uv run resolves dependencies automatically. No venv to manage.
Prerequisites
- Python ≥3.11
uvonPATH- A Fastmail account with a JMAP API token (created in the Run step below)
- macOS only for the login-Keychain token path; the
FASTMAIL_API_TOKENenv-var path is cross-platform
Run
# Debug the sanitizer with zero credentials (bundled fixtures):
uv run fastmail_clean.py --selftest
# Run the live server (stdio transport):
export FASTMAIL_API_TOKEN=fmu1-... # Fastmail → Settings → Privacy & Security → API tokens
uv run server.py
Create the token as JMAP protocol, read-only, Email scope — that
maps to the only JMAP capability the server requests
(urn:ietf:params:jmap:mail). The MCP protocol option is Fastmail's own
hosted server and won't work with this JMAP client.
Wire into Claude Desktop / Code
server.py resolves the token from FASTMAIL_API_TOKEN if set, otherwise from
the macOS login keychain (service fastmail-clean-mcp). Pick one of:
macOS login keychain (recommended). Seed it once — readable without a prompt
while you're signed in, so the config stays a plain uv run with no secret on
disk and no auth in the launch path:
security add-generic-password -U -s fastmail-clean-mcp -a "$USER" -w fmu1-... -A
"fastmail-clean": {
"command": "/opt/homebrew/bin/uv",
"args": ["run", "/abs/path/to/server.py"]
}
(-A lets any app read it without a prompt; the token is read-only and revocable.
Absolute uv path because the GUI app launches with a minimal PATH.)
1Password via op run (cross-platform secret manager; authorizes on each
launch). --no-masking is required so op's secret-masking doesn't corrupt
JSON-RPC over stdio:
"fastmail-clean": {
"command": "/opt/homebrew/bin/op",
"args": ["run", "--no-masking", "--",
"/opt/homebrew/bin/uv", "run", "/abs/path/to/server.py"],
"env": { "FASTMAIL_API_TOKEN": "op://Private/<item>/credential" }
}
Inline (simplest; token on disk in the config):
"fastmail-clean": {
"command": "uv",
"args": ["run", "/abs/path/to/server.py"],
"env": { "FASTMAIL_API_TOKEN": "fmu1-..." }
}
Sanitizer design (clean())
Pipeline: detect-or-forced HTML → BeautifulSoup(lxml).get_text → strip
invisibles (an explicit zero-width set plus Unicode categories Cf/Mn) → drop
URLs that exceed max_url_len or carry tracker params (euid, tuid, pid,
configId, utm_*, %opentrack%, …) while keeping short clean links →
collapse whitespace → optional head_chars truncation.
Knobs: is_html (force HTML handling), head_chars (keep only the meaningful
head), max_url_len (default 80).
Acceptance targets
From real inbox measurements (tiktoken cl100k_base):
- PayPal-class HTML-stuffed email: ~14,000 → < 100 tokens
- Airbnb-class padded marketing: ~1,000 → < 400
- Clean personal prose: unchanged (no false stripping)
- A 50-email
triage_bulk(head_chars=600): < 20K tokens total (vs ~83K raw) — 500+ bodies fit a 200K window.
uv run fastmail_clean.py --selftest currently reports 37× on the PayPal
fixture, 6.9× on padded marketing, and 1.0× on clean prose. If a change
regresses these, check _TRACKER_PARAMS and _looks_like_html first.
Tool routing
With both servers connected, the body-bearing tools collide by name. The token savings only land if reads route here. A routing rule that works:
- Reading a body / bulk triage (
read_email,triage_bulk): always use this server. The official equivalents return raw HTML and blow up context. - Search / list folders: body-free on both, either is fine.
- Calendar / contacts / send / move / flag: the official server only — this one is read-only and mail-only.
- IDs are identical across both servers, so ids from
triage_bulkcan be passed straight into the official server's write tools with no re-fetch.
JMAP notes
- Session bootstrap:
GET https://api.fastmail.com/jmap/session→apiUrl+primaryAccounts["urn:ietf:params:jmap:mail"]. - Bodies:
Email/getwithfetchTextBodyValues+fetchHTMLBodyValues, then maptextBody[].partId/htmlBody[].partIdintobodyValues. Prefer text, fall back to HTML;clean()flattens HTML-stuffed text either way. - Calendar is unavailable via API token (JMAP Calendars is still an IETF draft) — this proxy is mail-only by design.
License
MIT. See LICENSE.