{
  "markdown": "# gmail-mcp\n\n<!-- mcp-name: io.github.cunicopia-dev/multi-account-gmail-mcp -->\n\n![Python](https://img.shields.io/badge/python-3.12%2B-3776AB?logo=python&logoColor=white)\n![License: MIT](https://img.shields.io/badge/license-MIT-green)\n![tests: 74 passing](https://img.shields.io/badge/tests-74_passing-brightgreen)\n![storage: SQLite](https://img.shields.io/badge/storage-SQLite-003B57?logo=sqlite&logoColor=white)\n![MCP](https://img.shields.io/badge/MCP-ready-FF6F00)\n\n**An [MCP](https://modelcontextprotocol.io) server that reads across _all_ your\nGmail accounts from one connection.**\n\nMost Gmail integrations — including the native connectors — bind a single\naccount per OAuth grant: connect a second inbox and you disconnect the first.\n`gmail-mcp` keeps any number of accounts authorized at once. One Google Cloud\nclient authorizes them all, each lands as a row in a local SQLite file, and every\ntool takes an `account` argument that routes to the right mailbox.\n`search_all_accounts` sweeps all of them in a single query.\n\n> Python 3.12+ · MIT · stdio MCP server + auth CLI · local SQLite token store\n\nIt's built to be **owned completely**: runs in-process over stdio, stores tokens\nin one SQLite file you can inspect, copy, or delete, talks only to Google and\nyour MCP client, and hardcodes no secrets.\n\nIt reads, searches, drafts, and labels. It doesn't send — `create_draft` leaves\na draft for you to send yourself. That's a deliberate default (reasoning in\n[Security model](#security-model)), not a hard stance; if you want autonomous\nsend, it's a small addition or a different server.\n\n---\n\n## Contents\n\n- [The idea in 30 seconds](#the-idea-in-30-seconds)\n- [Design notes](#design-notes)\n- [Tools](#tools)\n- [Architecture](#architecture)\n- [Identity & auth model](#identity--auth-model)\n  - [The OAuth model](#the-oauth-model)\n  - [The multi-account model](#the-multi-account-model)\n  - [Token lifecycle](#token-lifecycle)\n  - [The headless auth path](#the-headless-auth-path)\n- [Security model](#security-model)\n- [Install](#install)\n- [Quickstart](#quickstart)\n- [Configuration](#configuration)\n- [Register with an MCP client](#register-with-an-mcp-client)\n- [Development](#development)\n- [Project layout](#project-layout)\n- [License](#license)\n\n---\n\n## The idea in 30 seconds\n\nAuthorize N accounts once via the CLI. Then every tool takes an `account`, and\n`search_all_accounts` hits all of them at once:\n\n```\nsearch_all_accounts(query=\"invoice newer_than:30d\")\n\n  ── personal@gmail.com ───────────────────────────────\n  from: billing@acme.com    subject: Invoice #4821    (id 18f...)\n\n  ── work@company.com ─────────────────────────────────\n  from: ap@vendor.io        subject: March invoice     (id 19a...)\n```\n\nOne query, every inbox, each result tagged with its account and carrying the\nmessage id — so the agent can chain `read_message(account, id)` or\n`create_draft(...)` next.\n\n---\n\n## Design notes\n\n**One OAuth client, many inboxes.** A single Google Cloud project and one\n`client_secret.json` authorize every account. Adding the tenth inbox is the same\none-command flow as the first.\n\n**Boring storage.** Tokens live in one SQLite file under `~/.gmail-mcp/`. No\ndaemon, no keyring dependency, no cloud. Back it up by copying it; revoke an\naccount by deleting a row; inspect it with any SQLite tool.\n\n**Least privilege.** Four granular scopes — `gmail.readonly`, `gmail.compose`,\n`gmail.modify`, `gmail.settings.basic` — never the full-mailbox\n`https://mail.google.com/`. It can read, draft, label, and manage filters; it\nnever sends mail, and filters it creates can't forward mail off-account.\n\n**Headless-friendly.** The auth flow assumes the server may have no browser: it\nprints a consent URL, binds a fixed port, and you SSH-forward the redirect. Works\nfine on a desktop too.\n\n---\n\n## Tools\n\nEvery tool except `list_accounts` and `search_all_accounts` takes an `account`\n(the email address). Unknown accounts return an error listing the authorized ones.\n\n| Tool | Arguments | Returns |\n| --- | --- | --- |\n| `list_accounts` | — | Authorized accounts + last-used time. Discover valid `account` values. |\n| `search_messages` | `account`, `query`, `max_results=20` | Message summaries (Gmail search syntax) with ids. |\n| `read_message` | `account`, `message_id`, `format=\"full\"`, `max_body_chars?` | Decoded headers, plaintext body (HTML stripped if needed), attachment metadata. Body capped by default; pass `max_body_chars=0` for the full body. |\n| `read_thread` | `account`, `thread_id`, `max_body_chars?` | Every message in the thread, in order. Each body capped by default; `max_body_chars=0` for full. |\n| `download_attachments` | `account`, `message_id`, `index?` | Save a message's attachments to disk and return absolute paths. Address them by the `#N` shown in `read_message`; omit `index` for all of them. Fixed download root, no destination argument. Dangerous file types and anything on a spam-labeled message are refused. |\n| `search_all_accounts` | `query`, `max_results_per_account=10` | One search across **every** account, each result tagged by account. |\n| `create_draft` | `account`, `body`, `to?`, `subject?`, `cc?`, `bcc?`, `html=false`, `reply_to_message_id?`, `reply_all=false`, `from_addr?` | A draft (not sent). Returns the draft id. With `reply_to_message_id` the draft is a reply inside that message's thread: recipient, subject, `In-Reply-To`, `References` and the thread id come from it, and `to`/`subject` become optional overrides. Without it, `to` and `subject` are required. `from_addr` sets the `From` header for a verified send-as alias; it defaults to the account address. |\n| `list_drafts` | `account`, `max_results=20` | Draft ids in the account. |\n| `list_labels` | `account` | The account's labels (name + id). |\n| `modify_labels` | `account`, selection (`message_id` \\| `message_ids` \\| `query`), `add?`, `remove?` | Add/remove labels on a **selection** (one id, a list, or everything a query matches), batched 1000/call. General mutator: archive = remove INBOX, mark-read = remove UNREAD, star = add STARRED. |\n| `trash` | `account`, selection (`message_id` \\| `message_ids` \\| `query`) | Move a selection to Trash (recoverable 30 days; not permanent delete). Refuses an empty selection. |\n| `bulk_action` | `account`, `action`, selection (`message_id` \\| `message_ids` \\| `query`) | Friendly verb layer over `modify_labels`. `action` ∈ `archive`/`unarchive`/`mark_read`/`mark_unread`/`star`/`unstar`/`spam`/`unspam`/`trash`/`untrash`. Batched 1000/call; refuses an empty selection. |\n| `read_messages` | `account`, `message_ids` \\| `query`, `max_results=25` | Batch-read full content of many messages in one call (vs. N `read_message` calls). |\n| `count_messages` | `query`, `account?`, `all_accounts=false` | Count matches **without** fetching content — blast-radius check before a bulk action. `all_accounts` gives a per-account breakdown + total. |\n| `list_filters` | `account` | The account's filters: id, criteria, actions (label ids shown as names). |\n| `create_filter` | `account`, one of `from_address`/`to_address`/`subject`/`query`/`has_attachment`, plus an action (`archive`/`mark_read`/`delete`/`star` or `add_labels`/`remove_labels`) | A server-side rule applied to **incoming** mail. Can't forward off-account. |\n| `delete_filter` | `account`, `filter_id` | Remove a filter by id (leaves already-acted-on mail alone). |\n\n---\n\n## Architecture\n\n```mermaid\nflowchart TD\n    subgraph client[Your machine]\n        Agent[MCP client / agent]\n        CLI[gmail-mcp-auth CLI]\n        Server[gmail-mcp stdio server]\n        Store[(\"SQLite<br/>~/.gmail-mcp/tokens.db\")]\n        Secret[\"client_secret.json<br/>one OAuth client\"]\n    end\n    Google[Google OAuth + Gmail API]\n\n    CLI -->|\"loopback OAuth, once per account\"| Google\n    CLI -->|\"store refresh token\"| Store\n    Secret -.-> CLI\n    Agent -->|\"tool call (account=...)\"| Server\n    Server -->|\"look up + refresh creds\"| Store\n    Secret -.-> Server\n    Server -->|\"read / draft / label\"| Google\n    Server --> Agent\n```\n\nAuthorization happens once per account through the CLI (it needs a browser).\nAfter that the stdio server reads tokens straight from SQLite, refreshing access\ntokens on demand and persisting them back. The rest of this section is the\n\"why it works the way it does\" detail.\n\n---\n\n## Identity & auth model\n\nHow `gmail-mcp` authenticates to Gmail, juggles multiple accounts under a single\nOAuth client, refreshes tokens over time, and authorizes accounts on a headless\nserver. If you just want to get running, jump to [Quickstart](#quickstart).\n\n### The OAuth model\n\n`gmail-mcp` authenticates using a Google **\"Desktop app\"** OAuth client (an\n*installed application* in OAuth 2.0 terms), driven by the `InstalledAppFlow`\nhelper from `google-auth-oauthlib`.\n\n**Why an installed-app / desktop client.** Installed apps run on a machine the\nend user controls, so OAuth treats them as **public clients**: the `client_secret`\nin the downloaded `client_secret.json` is *not* assumed to be confidential.\nThat's the right trust model for a local CLI/desktop tool — there's no\nserver-side component that could keep a secret truly secret, and security rests\non the user controlling the redirect (the loopback address) rather than on secret\nconfidentiality. It's the client type Google recommends for command-line and\ndesktop tools.\n\n**The loopback redirect flow.** After you approve consent in a browser, Google\nredirects the authorization code to `http://localhost:<port>/`, where a tiny\nthrowaway HTTP server (started by `InstalledAppFlow.run_local_server`) catches\nit. `gmail-mcp` pins this to a fixed port (default `8765`, override with\n`GMAIL_MCP_OAUTH_PORT`) and runs with `open_browser=False` so it works on\nmachines with no browser — see [The headless auth path](#the-headless-auth-path).\n\n**Scopes requested.** Four granular scopes — never the full-mailbox\n`https://mail.google.com/`:\n\n| Scope | What it grants |\n|-------|----------------|\n| `gmail.readonly` | Read mail and metadata: search messages/threads, read bodies, list labels and drafts. Read-only — cannot modify anything. |\n| `gmail.compose` | Create, update, and manage drafts. Used only by `create_draft`. |\n| `gmail.modify` | Add/remove labels on messages. Used by `modify_labels`. |\n| `gmail.settings.basic` | List, create, and delete filters. Used by `list_filters`/`create_filter`/`delete_filter`. Does **not** grant forwarding-address changes (that's `gmail.settings.sharing`, not requested). |\n\n`gmail.send` is not requested. Without it the credential simply has no Gmail API\npath to send mail — the drafts-only behavior is a property of the grant, not just\nan omitted tool. `gmail.settings.sharing` is likewise not requested, so no filter\ncan forward mail to another address. The scope list lives in one place: `SCOPES`\nin `src/gmail_mcp/config.py`.\n\n> **Adding the filter scope to an existing install:** widening `SCOPES` does not\n> retro-grant already-authorized accounts. Each account must re-run\n> `gmail-mcp-auth add` to re-consent to the new scope; until it does, the filter\n> tools return a `403 insufficient scope` error.\n\n### The multi-account model\n\n- **One OAuth client authorizes many accounts.** You create a single Google\n  Cloud project and one \"Desktop app\" OAuth client, then run the consent flow\n  once per Gmail account, signing into the account you want to add each time. A\n  single `client_secret.json` can authorize any number of accounts.\n- **Each account is a row in SQLite.** Every authorized account is stored in the\n  `accounts` table (`~/.gmail-mcp/tokens.db`, override with `GMAIL_MCP_DB`),\n  **keyed by email**. The row holds the long-lived refresh token, the most recent\n  access-token blob, the granted scopes, and timestamps.\n- **Tool calls route by the `account` param.** Every tool except `list_accounts`\n  and `search_all_accounts` takes an `account`. The server looks that email up,\n  builds a credential for it, and calls the Gmail API as that account. Unknown\n  accounts return a clear error listing what's authorized. `search_all_accounts`\n  iterates over every stored row.\n\n```mermaid\nflowchart LR\n    Client[MCP client / agent] -->|\"account=a@x.com\"| Server[gmail_mcp.server]\n    Server --> Store[(\"accounts table<br/>keyed by email\")]\n    Store -->|\"row a@x.com\"| CredsA[Credentials a]\n    Store -->|\"row b@y.com\"| CredsB[Credentials b]\n    CredsA --> InboxA[\"Gmail: a@x.com\"]\n    CredsB --> InboxB[\"Gmail: b@y.com\"]\n    Secret[\"client_secret.json<br/>one OAuth client\"] -.->|\"shared by all rows\"| CredsA\n    Secret -.-> CredsB\n```\n\n### Token lifecycle\n\n**Initial grant** (one-time, per account, via the CLI). The OAuth flow needs a\nbrowser, which an MCP tool can't drive cleanly, so authorization lives in the\n`gmail-mcp-auth` CLI rather than as a tool.\n\n```mermaid\nsequenceDiagram\n    actor User\n    participant CLI as gmail-mcp-auth add\n    participant Browser\n    participant Google as Google OAuth + Gmail API\n    participant Store as SQLite token store\n\n    User->>CLI: run `gmail-mcp-auth add`\n    CLI->>CLI: load client_secret.json,<br/>start loopback server on :8765\n    CLI-->>User: print consent URL (open_browser=False)\n    User->>Browser: open URL, sign into target account\n    Browser->>Google: consent + approve scopes\n    Google-->>Browser: redirect with authorization code\n    Browser->>CLI: GET http://localhost:8765/?code=...\n    CLI->>Google: exchange code for tokens\n    Google-->>CLI: access token + refresh token\n    CLI->>Google: users.getProfile (discover email)\n    Google-->>CLI: emailAddress\n    CLI->>Store: upsert(email, refresh_token, token, scopes)\n    CLI-->>User: \"Authorized and stored: you@gmail.com\"\n```\n\n- The CLI passes `prompt=\"consent\"` to **force a refresh token to be issued** —\n  Google only returns one on a fresh consent. The CLI errors clearly if no\n  refresh token comes back (revoke the app at\n  <https://myaccount.google.com/permissions> and re-run).\n- The account's email is **discovered**, not typed: after the token exchange the\n  CLI calls `users.getProfile` and keys the stored row by the returned address.\n\n**Per-request refresh** (every tool call). Access tokens are short-lived (≈1\nhour). On each call the server rebuilds a credential for the target account, lets\n`google-auth` refresh it on demand, and persists the refreshed blob back.\n\n```mermaid\nsequenceDiagram\n    participant Client as MCP client / agent\n    participant Server as gmail_mcp.server\n    participant Store as SQLite token store\n    participant Google as Google OAuth + Gmail API\n\n    Client->>Server: tool call (account=you@gmail.com)\n    Server->>Store: get(account) → refresh_token + last token\n    Server->>Server: build Credentials\n    alt access token still valid\n        Server->>Google: Gmail API request\n    else access token expired\n        Server->>Google: refresh using refresh_token\n        Google-->>Server: new access token\n        Server->>Store: update_token(account, new blob)\n        Server->>Google: Gmail API request\n    end\n    Google-->>Server: response\n    Server->>Store: touch(account) → last_used_at\n    Server-->>Client: result (email content wrapped as untrusted)\n```\n\nIf a refresh fails (revoked grant, expired refresh token), the server raises\n`GmailAuthError` with a \"re-run `gmail-mcp-auth add`\" message rather than crashing.\n\n**Testing vs. Published — the 7-day gotcha.** This is the usual \"it stopped\nworking after a week\" surprise:\n\n- While the OAuth consent screen is in **Testing** mode, only listed **test\n  users** can authorize, and refresh tokens issued to an **unverified** app\n  **expire after 7 days** — you'd re-run `gmail-mcp-auth add` weekly.\n- **Publishing** the app (consent screen → *Publish app*) makes refresh tokens\n  long-lived. Google will warn it's \"unverified\" — expected and fine for a\n  self-hosted personal tool you don't distribute. For long-lived use, publish.\n  [SETUP.md](docs/SETUP.md) has the exact clicks.\n\n### The headless auth path\n\nThe typical target is a headless server (no desktop, no browser), but OAuth\nconsent has to happen in a browser. The flow bridges that:\n\n- **`open_browser=False`** — the CLI prints the consent URL instead of launching\n  a browser. You open it on your own laptop, signed into the account you're\n  adding.\n- **Fixed loopback port** — after approval Google redirects to\n  `http://localhost:<port>/`. That \"localhost\" is the *server's* loopback, where\n  the CLI listens. The port is fixed (default `8765`, `GMAIL_MCP_OAUTH_PORT`) so\n  you can forward it deterministically.\n- **SSH port-forward** — bridge your laptop's browser to the server's loopback:\n\n  ```bash\n  ssh -L 8765:localhost:8765 you@your-server\n  ```\n\n  Now when the redirect hits `localhost:8765` on your laptop, SSH tunnels it to\n  the server, where the CLI catches the code and finishes the exchange.\n\n---\n\n## Security model\n\nAn inbox is full of text other people wrote, so it's a natural place for prompt\ninjection. The standard framing is the **lethal trifecta** — injection is\ndangerous when an agent has all three of:\n\n```mermaid\nflowchart LR\n    A[Private data<br/>your mailboxes] --- C{Injection<br/>risk}\n    B[Untrusted content<br/>any email you receive] --- C\n    D[Egress channel<br/>a way to send data out] --- C\n    C -.->|drafts-only removes the obvious one| D\n    style D stroke-dasharray: 5 5\n```\n\nA mail reader has the first two by nature. A couple of choices keep the third\nlow-stakes:\n\n- **Drafts instead of send.** `create_draft` is the outgoing ceiling — there's no\n  send tool and no `gmail.send` scope. A draft sits in your drafts folder until\n  *you* send it, so an instruction buried in an email can't make the agent mail\n  your data anywhere. Sensible default, easy to change if you want send.\n- **Email content is marked as untrusted.** Message text the tools return is\n  wrapped in `⟦UNTRUSTED EMAIL CONTENT⟧` delimiters by a single helper\n  (`wrap_untrusted` in `gmail.py`), with ids kept **outside** so tool-chaining\n  still works. The read tools also note in their descriptions that content is\n  data, not instructions. Multi-message responses (search results, threads,\n  cross-account sweeps) emit the fence **once** around the whole content blob —\n  not once per message — and key each body to a trusted `#N` id manifest that\n  sits outside the fence. This both cuts delimiter token overhead and keeps real\n  ids exclusively in the trusted region, so an attacker can't smuggle a forged\n  id into a place the agent treats as authoritative.\n\n- **Attachments land in one fixed place, and some never land at all.**\n  `download_attachments` writes only under `~/.gmail-mcp/attachments/<message_id>/`\n  (`GMAIL_MCP_ATTACHMENT_DIR`). There is deliberately no destination argument,\n  because one would be an arbitrary-file-write primitive that an instruction\n  buried in an email could aim at `~/.zshrc`. Filenames are attacker-chosen, so\n  they are reduced to inert ASCII basenames (path separators dropped, leading\n  dots stripped, bidi overrides removed, length capped, index-prefixed), and the\n  resolved path is re-checked against the root before the write. Files are\n  written owner-only, with `O_NOFOLLOW` so a pre-planted symlink can't redirect\n  them.\n\n  Screening happens before any bytes are fetched. It refuses every file type\n  [Gmail itself blocks in transit](https://support.google.com/mail/answer/6590)\n  (`.exe`, `.jar`, `.js`, `.vbs`, `.iso`, `.py`, ~50 more), macro-enabled Office\n  documents, executable MIME types, and every attachment on a message Gmail\n  labeled `SPAM`. All of a filename's dot-suffixes are checked, not just the\n  last, so `invoice.pdf.exe` is caught. Archives are saved but flagged, since\n  nothing here can look inside one.\n\n  **This is a type screen, not a virus scan.** Gmail scans attachments\n  server-side but does not expose the verdict through its API. There is no\n  malware field on the message or attachment resource, and `attachments.get`\n  will serve bytes the Gmail web UI refuses to let you download. A clean verdict\n  here means \"not an obvious weapon,\" never \"scanned and safe.\" The saved file's\n  *contents* remain untrusted third-party data.\n\n**Known limitation.** This only governs *this* server's surface. If the same\nagent session also has a tool that can reach the open internet (web fetch, HTTP),\nthat's a separate egress path `gmail-mcp` can't do anything about — pairing it\nwith an arbitrary-egress tool re-opens the trifecta elsewhere. Be deliberate\nabout which tools share a session.\n\nTwo more notes: no audit log is implemented (intentionally out of scope), and no\nsecrets are hardcoded — `client_id`/`client_secret` come from your downloaded\n`client_secret.json`, and tokens live only in your local SQLite store.\n\n---\n\n## Install\n\nRequires Python 3.12+. The PyPI distribution is **`multi-account-gmail-mcp`**\n(the bare `gmail-mcp` name is taken); it installs the `gmail-mcp` and\n`gmail-mcp-auth` commands.\n\n```bash\n# From PyPI\npip install multi-account-gmail-mcp\n# or, to get the commands on PATH globally:\nuv tool install multi-account-gmail-mcp     # or: pipx install multi-account-gmail-mcp\n# or run without installing:\nuvx multi-account-gmail-mcp\n```\n\nFrom source (for development):\n\n```bash\ngit clone https://github.com/cunicopia-dev/gmail-mcp.git\ncd gmail-mcp\npython -m venv .venv && source .venv/bin/activate\npip install -e .            # add \".[dev]\" for ruff + pytest\n```\n\nThis installs two console scripts: **`gmail-mcp`** (the stdio server) and\n**`gmail-mcp-auth`** (the account-authorization CLI).\n\n---\n\n## Quickstart\n\nYou need a Google \"Desktop app\" OAuth client (`client_secret.json`) and one\nauthorization per account. The full click-by-click — creating the Google Cloud\nproject, enabling the Gmail API, publishing the consent screen, and the headless\nSSH-forward step — is in **[docs/SETUP.md](docs/SETUP.md)**. The short version:\n\n```bash\n# 1. Drop your downloaded OAuth client here:\nmkdir -p ~/.gmail-mcp && mv ~/Downloads/client_secret_*.json ~/.gmail-mcp/client_secret.json\n\n# 2. Authorize an account (prints a URL to open in a browser; repeat per account).\n#    On a headless server, SSH in with -L 8765:localhost:8765 first.\ngmail-mcp-auth add\n\n# 3. Confirm what's authorized.\ngmail-mcp-auth list\n\n# 4. Point your MCP client at the `gmail-mcp` command (see below).\n```\n\nRemove an account later with `gmail-mcp-auth remove you@gmail.com`.\n\n---\n\n## Configuration\n\nAll optional — sane defaults under `~/.gmail-mcp/`.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `GMAIL_MCP_DB` | `~/.gmail-mcp/tokens.db` | SQLite token store path. |\n| `GMAIL_MCP_CLIENT_SECRET` | `~/.gmail-mcp/client_secret.json` | Downloaded Google OAuth client. |\n| `GMAIL_MCP_OAUTH_PORT` | `8765` | Fixed loopback port for the auth flow (forward this over SSH on a headless box). |\n| `GMAIL_MCP_ATTACHMENT_DIR` | `~/.gmail-mcp/attachments` | Download root for `download_attachments`. Files land in a per-message subdirectory. This is the only location the server writes to. |\n| `GMAIL_MCP_MAX_ATTACHMENT_BYTES` | `26214400` (25 MB) | Per-attachment size ceiling. Gmail's own limit is 25 MB, so this refuses nothing Gmail would deliver. `0` (or negative) means unlimited. |\n| `GMAIL_MCP_MAX_BODY_CHARS` | `500` | Default per-message body cap for `read_message`/`read_thread`. Deliberately tight so reads are cheap by default; `0` (or negative) means unlimited, and a per-call `max_body_chars` argument overrides it. |\n\n---\n\n## Register with an MCP client\n\nThe server speaks stdio. Point your client's `mcpServers` config at the\n`gmail-mcp` command:\n\n```json\n{\n  \"mcpServers\": {\n    \"gmail\": {\n      \"command\": \"/path/to/gmail-mcp/.venv/bin/gmail-mcp\"\n    }\n  }\n}\n```\n\nIf `gmail-mcp` is on `PATH`, `\"command\": \"gmail-mcp\"` is enough. Override paths\nexplicitly when needed (some clients don't expand `~`):\n\n```json\n{\n  \"mcpServers\": {\n    \"gmail\": {\n      \"command\": \"/path/to/gmail-mcp/.venv/bin/gmail-mcp\",\n      \"env\": {\n        \"GMAIL_MCP_DB\": \"/home/you/.gmail-mcp/tokens.db\",\n        \"GMAIL_MCP_CLIENT_SECRET\": \"/home/you/.gmail-mcp/client_secret.json\"\n      }\n    }\n  }\n}\n```\n\n---\n\n## Development\n\n```bash\npip install -e \".[dev]\"\nruff check .\npytest                       # 48 tests, no network — the Gmail client is mocked\n```\n\nTests cover the pure layers — MIME parsing/decoding, label name→id resolution,\nthe untrusted-content wrapper, output formatting, and token-store CRUD against a\ntemp SQLite db.\n\n---\n\n## Project layout\n\n```\nsrc/gmail_mcp/\n  server.py    MCP tool definitions + dispatch + per-account routing\n  gmail.py     Gmail service build, token refresh/persist, MIME parse/format,\n               wrap_untrusted(), label resolution, MIME message build\n  store.py     TokenStore — sqlite3 accounts table CRUD\n  auth.py      gmail-mcp-auth CLI: add / list / remove (loopback OAuth)\n  config.py    SCOPES + env-overridable paths\ndocs/\n  SETUP.md     step-by-step Google Cloud + account authorization\ntests/         store / gmail / server, Gmail client mocked\n```\n\n---\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n",
  "bytes": 25069,
  "sha": "0a79efeab3d30ba5796d8c753554fb7aa2be53ae4a21e4b1f6e2b555163555bf",
  "repo_slug": "cunicopia-dev/gmail-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_cunicopia_dev_multi_account_gm_9e8df53d/readme"
}