multi-gmail-mcp
by dkaleganov
README.md
# multi-gmail-mcp
A self-hosted MCP server that gives Claude **read, triage, draft, export, and label
management** access to **several Gmail / Google Workspace accounts at once**, in one
connector.
Claude's official Gmail connector supports one Google account per Claude account. This
server takes as many as you configure. It runs on your machine over stdio, your mail
travels only between your machine and Google's API, and no third-party service sits in
the middle.
```
you, on any device
|
Claude (desktop app / Claude Code / any stdio MCP client)
| MCP · stdio · local-only
v
+--------------------------------------------------------+
| multi-gmail-mcp (Python) |
| search · read · label · archive · draft |
| save attachments · export .eml / .html / PDF |
| create / rename / delete / undo labels (journaled) |
| NO send by default · NO mail deletion, ever |
| per-account encrypted tokens · metadata-only audit |
+--------------------------------------------------------+
| one OAuth token per account (Fernet-encrypted,
| key in the macOS Keychain)
v
account-1 account-2 account-3 ...
\ | /
Gmail API (scope: gmail.modify, which cannot permanently delete)
```
## Tools
Every tool takes an explicit `account` (an alias from your config, or the full address).
There is no default account.
| Tool | What it does |
|---|---|
| `list_accounts` | Configured accounts and whether each is authorized (read-only) |
| `search_emails` | Gmail search syntax, `max_results` 1 to 50, paginated with `page_token` / `next_page_token` |
| `read_email` | One message, plus its attachment list with the indexes `save_attachments` accepts |
| `read_thread` | A full conversation |
| `list_labels` | Label names and ids |
| `modify_labels` | Add or remove labels on one message, a batch of 2 to 100 messages (`message_ids`), or a whole thread (`thread_id`). Archive = remove `INBOX`; mark read = remove `UNREAD` |
| `create_draft` | A draft (optionally a threaded reply) that you review and send yourself in Gmail |
| `save_attachments` | One message's attachments, byte-for-byte, into a folder you configured |
| `export_email` | One message as `.eml` (the exact raw bytes), a sanitized `.html` view, and/or a PDF of that view |
| `create_label` | A new user label (nested with `/`); an existing exact name is returned unchanged |
| `update_label` | Rename a user label or change its visibility or color; journaled, undoable |
| `delete_label` | Delete a user label, **never mail**; journaled, undoable |
| `undo_label_change` | Revert a journaled `update_label` or recreate a deleted label and reapply its recorded memberships |
| `list_label_operations` | Recent journaled label operations and their op ids (read-only) |
The five label-administration tools are registered only while `"label_admin": true` (the
default). A `send_email` tool exists but is **not registered** unless you arm it (below).
### What it deliberately can't do
- **Send mail, by default.** Arming send is two-factor: `"enable_send": true` in the config
file AND `GMAIL_MCP_ALLOW_SEND=1` in the server's environment. Google has no OAuth scope
that allows drafts without send, so drafts-only is enforced by the server, and a config
edit alone (accidental or injected) can never enable sending.
- **Delete mail.** There is no mail-deletion tool, the `gmail.modify` scope cannot
permanently delete, and adding `TRASH` or `SPAM` is refused on every path (single, batch,
and thread). Trash auto-purges after about 30 days, so adding it would be deletion on a
delay. Removing them (untrash, not-spam) is allowed.
- **Sign itself in.** Authorization is `authenticate.py`, a CLI a human runs.
- **Touch an account you didn't configure,** or write files anywhere except the export
folders you named.
### Exports
- **`save_attachments`** saves attachments exactly as Gmail stores them: `"all"` or a list
of indexes from `read_email`. Inline parts (such as images shown in the body) are saved
only with `include_inline: true`.
- **`export_email`** writes any of three formats (default: `pdf` and `eml`), and each format
reports its own result:
- `eml`: the exact RAW message bytes Gmail returns;
- `html`: a sanitized, policy-wrapped view. Scripts, remote content and active markup
are removed, and embedded `cid:` images are inlined;
- `pdf`: that same view, rendered offline by headless Chrome over a private DevTools
pipe. Remote images are never fetched. PDF is off until you enable it (below).
- **Where files go.** `destination` names a root from `export_roots` in your config, never
a path, and `subfolder` is an optional relative folder inside it. The layout is
`<root>/<alias>-<12 hex of the account address's SHA-256>/<subfolder>/<message id>/`,
with `attachments/`, `views/`, and a `.gmail-mcp/manifest.json`.
- **How they're written.** Files are never overwritten (a numbered name is used instead),
are created `0600`, carry the macOS quarantine attribute, and are published only after
a durable record, so a crash never leaves a half-written file under a final name.
- **Limits.** 50 MiB per file, and 50 files or 200 MiB per call. At least 1 GiB of disk is
kept free. A message too long for the page limit is refused (`too-many-pages`), never
truncated.
- **Roots** must already exist, be owned by you, and not be group- or world-writable. The
server never creates them. `setup.sh` creates the configured ones for you.
### Label management
- **`update_label` and `delete_label` are journaled before they act,** in
`~/.gmail-mcp-private/label-journal/`, and return an operation id for
`undo_label_change`. A changed field is reverted only if it still holds the value the
change set; a third state is reported as a conflict, never overwritten.
- **`delete_label`** needs `confirm_name` equal to the label's current name. It first
counts the Gmail filters that reference the label and refuses unless
`confirm_filter_references` equals that count. It then snapshots the label's settings
and every member message id. A label with more members than
`label_delete_max_messages` (default 10,000) is refused.
- **Undoing a deletion** recreates the label with a **new** id and reapplies the recorded
memberships. Filters that pointed at the old id are not repaired.
- **Refused, for now:** renaming or deleting a label that has nested labels
(`unsupported-cascade`), and case-only name collisions.
- **Not journaled:** `modify_labels` changes on a batch or a thread.
## Security model (the short version)
- **Official libraries only.** Anthropic's `mcp` SDK, Google's API and auth clients,
`cryptography`, and `keyring`. No community MCP code to vet or be poisoned through.
- **Per-account encrypted tokens.** Fernet (authenticated encryption), with the key in the
macOS Keychain and an atomic `0600` key-file fallback. Decryption tries every reachable
key, so a locked-Keychain session can't strand your tokens.
- **Strict account isolation.** Every call names an account, and the server re-verifies
the token's real signed-in address against your config before first use. Export
folders and journal entries bind to that verified address, never to an alias.
- **Prompt-injection fencing.** Each result starts with a trusted line that holds only
aliases, validated ids, fixed codes and counts. Everything from email, or echoed from
arguments, is inside an UNTRUSTED envelope with a per-call random nonce. This is
defense in depth, not a guarantee; drafts-only and no-delete are the backstop.
- **Contained rendering.** Chrome runs headless with scripts off, every request
intercepted, a dead proxy, a restrictive CSP and resolver rules, in its own process
group. Its home, temp and crash folders are inside a private render folder. An
independent supervisor enforces the time, memory and scratch-disk budgets and kills the
whole group.
- **Metadata-only audit log.** One JSON line per call (tool, alias, ids, counts) in
`~/.gmail-mcp-private/audit.log` (`0600`, rotating). Never content, queries, subjects or
recipients. If an audit write fails, the result says so.
- **Local only.** stdio transport; the server never opens a network port.
- **Pinned supply chain.** `requirements.lock` is hash-pinned (`pip --require-hashes`).
Full guarantees, enforcement layers and residual risks: [SECURITY.md](SECURITY.md).
## Requirements
- **macOS.** Reading, search and labels may work on Linux through `keyring`'s Secret
Service backend or the key-file fallback, but that is untested. Exports need macOS,
because every exported file must carry the macOS quarantine attribute. FileVault is
strongly recommended.
- **Python 3.10 or newer** for the server. **Python 3.14.6** for the export view, which is
the sanitized `.html`, the PDF, and inline-image detection:
- The view relies on eleven CPython 3.14.6 internals, which are fingerprinted.
- On any other version they report `stdlib-changed`: `html` and `pdf` exports are
unavailable, and inline images are saved as ordinary attachments. Reading, search,
labels, attachments and `.eml` keep working.
- Pin the interpreter so an update can't switch the view off, for example Homebrew's
`python@3.14` with `brew pin python@3.14`.
- **Google Chrome**, for PDF only, at `/Applications/Google Chrome.app` or wherever
`pdf.chrome_path` points.
- **A Google Cloud project** with a Desktop-app OAuth client. It's free, and you create it
once (below).
- **Claude Code or the Claude desktop app** as the client, or any stdio MCP client.
**Runtime state** lives in one private folder, `~/.gmail-mcp-private/` (`0700`):
- `accounts.json` and `oauth_client.json`;
- `tokens/`, holding the encrypted tokens;
- `audit.log`;
- `label-journal/`;
- the `locks/`, `render-gates/` and `render-tmp/` folders.
`GMAIL_MCP_RUNTIME_DIR` (an absolute path) moves the whole folder, and `GMAIL_MCP_CONFIG`
points at a different `accounts.json`.
## Setup: the AI-assisted way (recommended)
This repo ships a [CLAUDE.md](CLAUDE.md) that briefs Claude Code on the whole setup:
```bash
git clone https://github.com/dkaleganov/multi-gmail-mcp
cd multi-gmail-mcp
claude
```
Then say:
> Set up this Gmail MCP server for my accounts.
Claude Code walks you through everything: the Google Cloud project, the OAuth client,
your `accounts.json`, the export folder, the venv and locked dependencies, each browser
sign-in, registration, and verification. **You** do the parts only a human should:
creating credentials in the Google console, and clicking *Allow* once per account. Claude
never sees a secret. Tokens are written encrypted by the sign-in CLI, and the OAuth client
JSON is a file you download yourself.
## Setup: the manual way
1. **Google Cloud (once).**
- At console.cloud.google.com, create a project and enable the **Gmail API**.
- Configure the **OAuth consent screen**: External, then fill in the app name and your
email. Then **Publish to production**, or refresh tokens expire every 7 days.
- Under **Credentials**, choose **Create credentials → OAuth client ID → Application
type: Desktop app**, then **Download JSON**.
2. **Place the client file:**
```bash
mkdir -p ~/.gmail-mcp-private && chmod 700 ~/.gmail-mcp-private
mv ~/Downloads/client_secret_*.json ~/.gmail-mcp-private/oauth_client.json
chmod 600 ~/.gmail-mcp-private/oauth_client.json
```
3. **Configure your accounts:** copy `accounts.example.json` to
`~/.gmail-mcp-private/accounts.json` and put in your own aliases and addresses.
4. **Run the setup script:**
```bash
./setup.sh
```
It does the rest, in order:
- picks Python (3.14 if present) and builds a venv;
- installs the hash-pinned dependencies;
- creates any missing export folder;
- opens a browser sign-in for each configured account. Pick the matching inbox. On the
"Google hasn't verified this app" screen (it's *your* app), click **Advanced →
Continue**;
- registers the server with Claude Code as `multi-gmail-mcp`;
- verifies the setup and shows the PDF readiness state.
5. **Test:** in a new Claude session, ask for `list_accounts`.
### Google Workspace accounts
If an account is on Google Workspace, the admin may need to allow the app: Admin console →
Security → API controls → App access control, then mark your OAuth client trusted. The
trade-off is that this trusts the client org-wide, so guard your `oauth_client.json`.
## Configuration
`accounts.json` (see [`accounts.example.json`](accounts.example.json)):
| Key | Default | Meaning |
|---|---|---|
| `accounts` | | One entry per account: `alias` (`[A-Za-z0-9_-]+`, unique), `email` (unique), `scope` (`modify` or `readonly`) |
| `enable_send` | `false` | Half of the two-factor send arming; also needs `GMAIL_MCP_ALLOW_SEND=1` |
| `export_roots` | `{"default": "~/Downloads/gmail-exports"}` | Named export folders (up to 16; names `[A-Za-z0-9_-]{1,32}`) |
| `pdf.chrome_path` | `null` | Absolute path to Chrome; `null` uses the standard macOS location |
| `pdf.max_pages` | `50` | 1 to 1000; a longer message is refused, never truncated |
| `pdf.render_timeout_seconds` | `45` | 10 to 45, for the whole render |
| `pdf.memory_budget_mb` | `null` | 64 to 65536; must be set, from measurements, before PDF can turn on |
| `pdf.scratch_budget_mb` | `null` | 16 to 65536; same |
| `pdf.monitor_interval_ms` | `null` | 10 to 5000; same |
| `label_admin` | `true` | Registers the five label-administration tools |
| `label_delete_max_messages` | `10000` | 1 to 100000; `delete_label` refuses larger labels |
An invalid value never crashes the server. It turns off only the feature it governs and
prints a fixed warning to stderr. For example, an invalid `label_admin` registers no
admin tools, and an invalid `label_delete_max_messages` refuses every deletion.
## Enabling PDF (optional)
PDF output stays off (`pdf-unavailable`) until **both** of these are true.
1. **The renderer release gates pass on this machine's exact Chrome build.** The suite
starts real Chrome with synthetic fixtures. Its only network target is a local
listener on `127.0.0.1` / `::1`. Each protective layer is tested against a positive
control: scripts, network, local files, page limit, wide content, supervisor, process
containment and resources. PDFs are checked with macOS PDFKit. It takes a few minutes:
```bash
.venv/bin/python tools/render_gates.py --out .test-tmp/gates-$(date +%Y%m%d) --write-record
```
`--write-record` writes the gate record into `~/.gmail-mcp-private/render-gates/`, and
only if every gate passed.
2. **Memory budgets are measured and set.** The same run writes `gates.json`, and its
`results.resources.measurements` section shows each fixture's peak memory footprint and
scratch use on your machine. Then:
- set `pdf.memory_budget_mb` above the largest peak you want to allow, with headroom;
- set `pdf.scratch_budget_mb` well above the observed scratch use;
- set `pdf.monitor_interval_ms` to a short sampling interval, such as 50.
These are monitored limits, not hard caps: memory can overshoot between two samples
before the group is killed.
Then check the state and start a new Claude session:
```bash
.venv/bin/python tools/pdf_readiness.py
```
**PDF turns itself off after every Chrome update.** The gate record is bound to the
Chrome binary and version, the render recipe, and the renderer's own code. After any
change, readiness reports `gates-not-passed` until you re-run step 1. The other formats
keep working meanwhile.
## Using it
Some examples to ask Claude (`home` and `team` are aliases from your config):
- "Search **team** for unread invoices from the last week, and show me the next page too."
- "Read that thread and draft a reply saying we'll confirm by Friday."
- "Archive everything from that newsletter in **home**."
- "Save the PDF attachment from that message, and export the message itself as `.eml`."
- "Rename the label `Receipts/2025` to `Receipts/Archive`, then undo it."
Drafts land in the account's Drafts folder; you review and send them from Gmail yourself.
## Tests
```bash
.venv/bin/python -m unittest discover -s tests -t .
```
The suite runs offline against a fake Gmail and a fake Chrome. It uses a throwaway
runtime folder under `.test-tmp/` (gitignored) with a Keychain tripwire, so it never
touches your real folder, tokens or Keychain. Run it with Python 3.14.6, the version the
export view is fingerprinted against.
## Known limits
- **A long export or label operation blocks that session's server.** That can be up to a
45 s render, plus lock waits of up to 60 s (render, label admin) or 120 s (one message).
- **Parsing a crafted message is bounded, not cheap.** On the export path it can cost up
to about 20 s and over 2 GB of memory before the view is refused. Message parsing does
not yet run in a supervised child process.
- **PDF budgets are monitored, not hard caps** (see above).
- **A saved `.eml` keeps the message's original active HTML.** Opening it in Mail or
previewing it with Quick Look is outside the renderer's controls. Spotlight and
Quick Look may also parse files in `~/Downloads` without a click; an export root whose
path ends in `.noindex` avoids indexing.
- **Export roots are shared by all accounts.** A synced or shared root gives a
prompt-injected session a way to move mail content out of the machine.
- **Chrome's browser process runs unsandboxed as you.** Keep Chrome updated and re-run the
gates.
- **The export view's inline-image prediction is conservative.** For very large or unusual
messages, some embedded images are saved as attachments even though the view might
show them.
- **Undo can't verify memberships individually.** Batch and thread label changes aren't
journaled, and `undo_label_change` counts accepted, missing, unresolved and conflicting
ids rather than reading each membership back.
- **Pagination tokens are valid only in the server process that issued them.** A new
session restarts its search.
## More machines
Each install is independent, with its own encrypted tokens: set it up on another machine
and the two never conflict. Re-run the PDF gates and measurements on each machine.
## Kill switch
- **Disable:** `claude mcp remove multi-gmail-mcp`
- **Revoke one account:** delete `~/.gmail-mcp-private/tokens/<alias>.enc` and/or revoke the
app at <https://myaccount.google.com/permissions>
- **Remove everything:** delete `~/.gmail-mcp-private/` (exported files stay in the folders
you configured)
## Files to audit
| File | What it is |
|---|---|
| `server.py` | The MCP server and the exact tools Claude can call |
| `gmail.py` | Gmail API wrapper, MIME parsing, label resolution, header-injection guards |
| `auth.py`, `authenticate.py` | OAuth loopback flow, encrypted atomic token storage, account validation; the sign-in CLI a human runs |
| `auditlog.py` | Metadata-only audit logging (`0600`, rotating) |
| `runtime.py`, `config.py`, `output.py` | Runtime folder, settings validation, the trusted-line and fence contract |
| `mime.py`, `names.py` | Attachment enumeration and inline prediction; file-name rules |
| `exportfs.py`, `exports.py`, `exporttools.py` | Write controls (pinned roots, no-clobber, quarantine), the export identity and recovery protocol, the export tools |
| `htmlview.py` | The sanitized view |
| `renderer.py`, `procinfo.py` | Chrome over a pipe, with the supervisor; process inspection |
| `journal.py`, `labels.py`, `locks.py` | The label recovery journal, label administration, host-wide locks |
| `tools/render_gates.py`, `tools/pdf_readiness.py` | The renderer release gates; the readiness report |
| `tests/` | The offline suite |
| `setup.sh`, `CLAUDE.md` | One-command install; the brief that lets Claude Code run your setup |
## License and author
MIT, see [LICENSE](LICENSE). Built by [Dima Kaleganov](https://github.com/dkaleganov)
with Claude Code.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues