Skip to main content
Glama

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.

Related MCP server: mailpouch

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.

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.

This repo ships a CLAUDE.md that briefs Claude Code on the whole setup:

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:

    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:

    ./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):

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:

    .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:

.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

.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. Built by Dima Kaleganov with Claude Code.

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    C
    maintenance
    Local-first MCP server for agents that need to work across multiple Gmail and Microsoft 365 accounts without cloud token storage.
    6
    -
  • A
    license
    A
    quality
    A
    maintenance
    An MCP server that gives AI agents permission-gated, audit-logged access to private email providers (Proton Mail via Bridge and plain IMAP), running locally with OAuth-based authentication and human-controlled escalation for destructive operations.
    66
    46 npm
    11
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    A local-first MCP server for managing multiple Gmail accounts with separate OAuth grants, supporting search, retrieval, labels, drafts, and attachments while deferring direct send and destructive actions for safety.
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    MCP server that gives an AI assistant simultaneous access to multiple Gmail accounts with per-account read-only, draft, or send tiers, two-phase write confirmation, and safeguards for handling untrusted email content.
    10
    15 npm
    MIT