gmail-mcp
# pigeon-mcp
Gmail connector for MCP clients. One server, many Gmail accounts via OAuth refresh tokens. Sends real MIME (file-path attachments, live signatures, post-send proof). Reads and organises mail without dumping megabytes of base64 into the model.
**Not** a wrapper around Google's hosted Gmail MCP — this builds RFC822 on the host and talks to `gmail.googleapis.com` directly.
**Scope today:** Gmail only. The Google Cloud project may also have **Calendar** and **Drive** APIs enabled for later work — this server does not call them yet. Do not request Calendar/Drive OAuth scopes until those tools ship.
## Features
- **Multi-account OAuth** — add mailboxes with `accounts_add`; tokens stored as `gmail-token-*.json` (mode 0640)
- **Many identities on one mailbox** — `identities_list` reads Gmail's verified send-as list; `from_identity` on send/reply/forward/draft sets `From`, `Reply-To` and the alias's own signature, validated in the handler. See [`docs/mailroom.md`](docs/mailroom.md)
- **Routing headers** — every read exposes `originalTo` (`X-Gm-Original-To`), `deliveredTo`, `replyTo`, `authResults`; `messages_list` sweeps headers without bodies
- **Send / reply / forward** — server-built MIME, outbox file paths only, 25 MB cap, idempotency keys, proof on success
- **Read / organise** — search (threads + pagination), get thread/message, labels, archive/trash, drafts
- **Attachments** — send from configured outbox root (default `~/Outbox`); stage via `POST /outbox/stage` (bearer); download to configured download root (default `~/Inbox`); remote seats fetch pulls via a single-use `download_url`; the HTTP server deletes both after 24 h
- **Dual transport** — stdio for local harnesses; Streamable HTTP behind a gateway for remote clients
## Requirements
- Python 3.12+ (development uses 3.13 via pyenv)
- A Google Cloud **Web application** OAuth client with redirect `https://<your-host>/oauth/callback`
- A Linux or macOS host you control, behind TLS (nginx / Caddy / Cloudflare)
## Quick start
```bash
git clone https://github.com/iXanadu/pigeon-mcp.git
cd pigeon-mcp
# Python 3.12+ (example with pyenv)
pyenv virtualenv 3.13 pigeon-mcp-3.13
pyenv local pigeon-mcp-3.13
pip install -e '.[dev]'
# Config (see examples/)
cp examples/config.example .env
cp examples/secrets.example .keys
chmod 600 .keys
# Sanity check
pigeon-doctor
```
Fill in `.keys` with the Web client id/secret and a long random `PIGEON_MCP_HTTP_BEARER_TOKEN` (this becomes tenant `grokbot` — the seat that may connect mailboxes). Set `PIGEON_MCP_OAUTH_PUBLIC_REDIRECT_URI` in `.env` to your public callback.
### Google Cloud Console (one-time)
| Step | Where | What |
| --- | --- | --- |
| 1 | APIs & Services → Library | Enable **Gmail API** |
| 2 | OAuth consent screen | **External** → **Publish app** (*In production*). Do **not** stay in Testing (7-day refresh expiry). See the doc for Workspace **Internal**. |
| 3 | Credentials → Create | **OAuth client ID → Web application** |
| 4 | Web client | Redirect **`https://<your-host>/oauth/callback`** → `PIGEON_MCP_OAUTH_PUBLIC_REDIRECT_URI` |
| 5 | `.keys` | Web client id/secret → `PIGEON_MCP_GOOGLE_WEB_CLIENT_ID` / `_SECRET` |
**Full guide** (scopes, the unverified-app warning, Workspace vs personal Gmail, the 7-day Testing trap, token protection, legal URLs): [`docs/google-oauth-setup.md`](docs/google-oauth-setup.md). Privacy / Terms URLs for the consent screen: [`docs/legal/`](docs/legal/README.md).
Scopes are fixed in the server: `gmail.modify` + `gmail.send` + `gmail.settings.basic` — read/send/organise mail; `modify` also reads the send-as list (identities, live signature, not cached); `settings.basic` exists only so `filters_create` / `filters_delete` work. **No** username/password, app password, or pasted refresh token in chat.
### First run — tenants, not one shared key
After HTTP is up (see **Deployment layout**):
1. **GrokBot (or one operator agent)** gets the env bearer and the MCP URL. It calls `accounts_auth_start`; **you** open the Google link on your computer and Allow. The Gmail refresh token stays on the server (`gmail-token-*.json`, mode 0640). Empty `accounts_list` on a fresh host is success.
2. **You** register a passkey: proxy `/~/` like `/mcp`, run `pigeon-admin bootstrap`, open the one-time URL, then sign in at `https://<your-host>/~/`.
3. **Mint one tenant per coding harness** on that page. Copy the `pgn_…` once. Grant only the mailboxes that seat may see. Leave “can connect mailboxes” off unless the seat should add Gmail accounts.
4. **Wire Cursor / Claude / Codex / Grok** like Share: secret in `~/.config/pigeon-mcp/identities/<name>` (0600), harness config only has `PIGEON_IDENTITY`. Install [`scripts/pigeon-mcp-proxy`](scripts/pigeon-mcp-proxy) as `~/.local/bin/pigeon-mcp`.
Do not paste `pgn_…` into `mcp.json`. Do not reuse GrokBot’s vault token for a coding session.
Full walk-through (who may connect a mailbox, audit, files on disk): [`docs/tenants.md`](docs/tenants.md).
<details>
<summary>Optional: local-only stdio with a Desktop client</summary>
If you run pigeon purely on your own machine over stdio and never expose HTTP, you can add a Google **Desktop** OAuth client (redirect `http://127.0.0.1:8767/oauth/callback`) to `.keys` as `PIGEON_MCP_GOOGLE_CLIENT_ID` / `_SECRET` and use `accounts_add`, which opens a local browser. `accounts_add` / `accounts_remove` exist only on the stdio transport. Most deployments do not need this.
</details>
## Deployment layout
```
┌──────────────────────┐ ┌──────────────────────────────┐
│ Any browser │ │ Your server (Linux/macOS) │
│ (human clicks Allow)│──────► │ TLS proxy ─► pigeon-mcp-http │
│ │ /oauth │ 127.0.0.1:8879 + .env/.keys │
└──────────────────────┘callback└──────────────┬───────────────┘
│ /mcp /outbox/stage
agent seat (bearer)
```
- `pigeon-mcp-http` binds **loopback** (`127.0.0.1:8879`); the proxy terminates TLS and forwards `/mcp`, `/outbox/stage`, `/inbox/fetch/`, `/oauth/callback`, `/healthz`, `/~/`.
- Each HTTP bearer is a **tenant**. The env token `PIGEON_MCP_HTTP_BEARER_TOKEN` is seeded as tenant `grokbot` and may start Google consent. Other tenants are minted on the owner dashboard (`/~/`, passkey login) and only see mailboxes you grant. `/oauth/callback` is public by necessity (a browser redirect carries no bearer); it is protected by single-use `state` + PKCE and only a tenant that may connect mailboxes can start a flow.
- If you put an access gate (e.g. Cloudflare Access) in front of the host, **exempt `/oauth/callback`** or consent dies after the user clicks Allow.
- In-repo deploy kit for the reference host: [`deploy/DEPLOYING.md`](deploy/DEPLOYING.md).
After deploy: `pigeon-doctor`, start the service, `pigeon-admin bootstrap`, then connect mailboxes from the `grokbot` seat. See [`docs/tenants.md`](docs/tenants.md).
## Configuration
Non-sensitive settings live in `.env`; secrets in `.keys` (never commit either when populated). Templates: `examples/config.example` and `examples/secrets.example`.
| Variable | File | Purpose |
| --- | --- | --- |
| `PIGEON_MCP_ENVIRONMENT` | `.env` | Label for logs/status |
| `PIGEON_MCP_LOG_LEVEL` | `.env` | Server log level |
| `PIGEON_MCP_HTTP_HOST` | `.env` | HTTP bind address (default `127.0.0.1`) |
| `PIGEON_MCP_HTTP_PORT` | `.env` | HTTP port (default `8879`) |
| `PIGEON_MCP_OUTBOX_ROOT` | `.env` | Send/stage attachment paths (pick per machine; `/tmp/...` fine on personal hosts) |
| `PIGEON_MCP_DOWNLOAD_ROOT` | `.env` | `get_attachment` writes |
| `PIGEON_MCP_FILE_TTL_HOURS` | `.env` | HTTP server deletes outbox + download files older than this, hourly (default `24`, matches the privacy policy; `0` = off) |
| `PIGEON_MCP_TOKENS_DIR` | `.env` | OAuth token storage directory |
| `PIGEON_MCP_ADMIN_DB` | `.env` | Optional SQLite path (default: next to tokens dir, `admin.sqlite`) |
| `PIGEON_MCP_OAUTH_PUBLIC_REDIRECT_URI` | `.env` | Public HTTPS callback — must match the Web client exactly |
| `PIGEON_MCP_GOOGLE_WEB_CLIENT_ID` | `.keys` | Google Web OAuth client id |
| `PIGEON_MCP_GOOGLE_WEB_CLIENT_SECRET` | `.keys` | Google Web OAuth client secret |
| `PIGEON_MCP_HTTP_BEARER_TOKEN` | `.keys` | GrokBot / legacy tenant bearer (hashed into SQLite as `grokbot`) |
| `PIGEON_MCP_OAUTH_REDIRECT_URI` | `.env` | *Optional, stdio only:* loopback callback for a Desktop client |
| `PIGEON_MCP_GOOGLE_CLIENT_ID` / `_SECRET` | `.keys` | *Optional, stdio only:* Desktop client for local `accounts_add` |
Run `pigeon-doctor` after changing config.
## Transports
### stdio (local process on the pigeon host)
```bash
pigeon-mcp
```
Same tools as HTTP plus `accounts_add` / `accounts_remove` (local Desktop-client consent). This is the **server** binary in the venv — use it when you are sitting on the pigeon machine. Remote Cursor / Claude / Codex / Grok should use [`scripts/pigeon-mcp-proxy`](scripts/pigeon-mcp-proxy) and a tenant identity file, not this process.
### Streamable HTTP (gateway)
```bash
pigeon-mcp-http
```
Binds `127.0.0.1:8879` by default. Requires a tenant bearer (`Authorization: Bearer …`); requests without a valid token get **401**. There is no OAuth authorization server for MCP clients, and `/.well-known/oauth-*` 404s are intentional.
**Owner dashboard:** `pigeon-admin bootstrap` → one-time setup URL → passkey → `https://<host>/~/` to mint and grant. Nginx must proxy `/~/`. Step-by-step: [`docs/tenants.md`](docs/tenants.md).
**HTTP allow-list:** read/organise tools plus `send`, `reply`, `forward`, `draft_create`, `draft_send`, `identities_list`, `messages_list`, `accounts_list`, `accounts_auth_start`, and `gmail_status`. `accounts_add` / `accounts_remove` stay on stdio.
**Stage attachments** (no scp required):
```bash
curl -sS -X POST "https://pigeon.c52.com/outbox/stage?filename=deed.pdf" \
-H "Authorization: Bearer $PIGEON_MCP_HTTP_BEARER_TOKEN" \
--data-binary @deed.pdf
# → {"path":".../deed.pdf","filename":"deed.pdf","size":N,...}
```
Then call `send` / `reply` / `forward` with `attachments_json` using that `path`. Proxy must expose `/outbox/stage` (same bearer as `/mcp`). Cap: 25 MB.
**Fetch inbound attachments:** over HTTP, `get_attachment` returns a `download_url` (`/inbox/fetch/<ticket>`). `GET` it with the same bearer; it is single-use, expires in 15 minutes, and only the seat that pulled the file can redeem it. Proxy must expose `/inbox/fetch/`.
#### macOS service (user LaunchAgent)
```bash
./scripts/start.sh # install plist → ~/Library/LaunchAgents, load
./scripts/stop.sh
./scripts/restart.sh
```
Edit `launchd/com.pigeon-mcp.plist` paths if your checkout or pyenv name differs. Logs go to `logs/`.
On Linux, run `pigeon-mcp-http` under systemd with the same loopback bind — see **Deployment layout** above.
## Tools
| Tool | Notes |
| --- | --- |
| `gmail_status` | Version and config summary |
| `accounts_list` | Connected addresses and token health |
| `accounts_auth_start` | Start Google consent over HTTP; returns `auth_url` for a human |
| `accounts_add` | Local Desktop-client consent (**stdio only**, optional) |
| `accounts_remove` | Revoke and drop token (**stdio only**) |
| `identities_list` | Verified send-as identities for an account — the only values `from_identity` accepts |
| `search` | Gmail query; returns threads |
| `messages_list` | Gmail query; returns messages with headers + snippet, no bodies (routing sweeps) |
| `get_thread` / `get_message` | `format=metadata` (headers only), `plain` or `full`; every message carries `originalTo`, `deliveredTo`, `replyTo`, `authResults` |
| `get_attachment` | Writes under download root; over HTTP, into the seat's own folder plus a single-use `download_url` (bearer, 15 min) |
| `send` / `reply` / `forward` | Paths only; rejects `content` / base64 in JSON; optional `from_identity` |
| `draft_create` / `draft_send` | Same attach/proof rules as send; `draft_create` takes `from_identity` |
| `labels_list` / `labels_create` | User + system labels |
| `filters_list` / `filters_create` / `filters_delete` | Gmail filters (server-side routing: `to:` an alias → label + skip inbox) |
| `label` / `unlabel` | Comma-separated names or ids |
| `archive` / `trash` / `untrash` | Thread-level |
Every tool except `accounts_list`, `accounts_add`, and `gmail_status` requires an `account` argument (the Gmail address).
## Send rules (summary)
- `send` / `reply` / `forward` / `draft_send` require a unique `idempotency_key`; a replay with the same key returns the first result and sends nothing
- Stage remote files first: `POST /outbox/stage` (bearer) → use returned `path`
- Attachments: `{ "path": "/absolute/or/under/outbox/file.pdf" }` — no inline base64
- `from_identity` (optional): a verified send-as address on the account — sets `From` with display name and `Reply-To`; rejected in the handler if not in `identities_list`
- Live Gmail signature of the sending identity appended at send time (not cached)
- Optional `footer` after signature
- Returns proof: sizes, hrefs, `ok` false → tool error (e.g. chopped attachment or `google.com/url` rewrite)
## Tests
```bash
pytest tests/ -v
```
Uses mocked Gmail HTTP; no live mailbox required.
## Mailroom: one mailbox, many agents
Give each agent its own address on one mailbox (Workspace catch-all + send-as, or consumer plus-addressing), route inbound on `originalTo` → `deliveredTo` → `to`, trust only recipients in `identities_list`, send with `from_identity`. Full pattern, setup steps, DKIM/alias tiers, label scheme and the do-not-attempt list: [`docs/mailroom.md`](docs/mailroom.md).
## Docs
| Doc | For whom | What |
| --- | --- | --- |
| [`docs/tenants.md`](docs/tenants.md) | New operator | Passkey admin, one bearer per harness, identity files, who may connect a mailbox |
| [`docs/for-agents.md`](docs/for-agents.md) | The agent seat (GrokBot, OpenClaw, Hermes, …) | **Connect card**, OAuth gotchas, send-with-file, rules of the road, do-not-attempt, escalation — paste into the seat's context |
| [`docs/mailroom.md`](docs/mailroom.md) | Operator + agent | One mailbox, many identities: setup, dispatch, trust tiers, labels, DKIM |
| [`docs/google-oauth-setup.md`](docs/google-oauth-setup.md) | Operator | Consent screen, scopes, Testing trap, unverified-app warning |
| [`docs/your-server.md`](docs/your-server.md) | Operator with no server yet | The $5 VPS on-ramp: ten minutes by hand, four prompts for the rest |
| [`deploy/DEPLOYING.md`](deploy/DEPLOYING.md) | Operator | How the reference host ships: pull, install, restart, verify, auto-rollback |
| [`docs/legal/`](docs/legal/README.md) | Operator | Privacy / Terms for the consent screen |
| [`docs/specs/gmail-mcp-spec.md`](docs/specs/gmail-mcp-spec.md) | Contributors | Acceptance criteria |
Same content lives on the reference site (pigeon.c52.com) as pages; the repo is the source of truth.
## Spec
Product requirements: `docs/specs/gmail-mcp-spec.md`
## License
Apache-2.0
TDQS
Scored across 23 tools
Most tools target clearly distinct actions or resources, such as send vs reply vs forward or get_thread vs get_message. A few boundaries are close, notably accounts_add vs accounts_auth_start and get_message vs get_thread, but the descriptions are sufficient to avoid serious misselection.
The naming style is mixed: some tools use resource_action (accounts_list, labels_create), some use action_resource (get_attachment, get_thread), and some are bare verbs (send, reply, archive, label). Within subfamilies the naming is consistent, but across the whole set there is no single predictable pattern.
23 tools is on the heavy side and falls into the borderline range for a single server. However, the count is justified by the breadth of Gmail functionality covered: accounts, labels, threads, messages, drafts, sending, and attachments.
The tool surface covers core Gmail workflows well: search, read, send, reply, forward, draft, label, archive, trash, and attachment retrieval. Minor gaps exist, such as no explicit mark-read/unread or message mutation beyond labels and trash, but agents can work around these.