gmail-mcp-server
# gmail-mcp-server

[MCP](https://modelcontextprotocol.io) server that manages **multiple Gmail accounts** under one Google OAuth client, with per-call account routing and cross-account search.
- 12 tools, all prefixed `gmail_`: account management (add / list / remove), read (search / thread / message / labels), write (send / reply / draft / labels / trash)
- Every inbox tool takes an optional `account` (email or alias); `gmail_search_threads` also accepts `account: "all"` to fan out across every stored account and merge results by date
- Per-account access levels chosen at add time: `readonly`, `modify` (the default), or `send`. Tools that need more access than an account has refuse with a clear message instead of a raw Google 403
- Tokens live in `~/.config/gmail-mcp/accounts.json` (file `0600` in a `0700` directory, refresh tokens only — no access tokens are persisted)
- Runs over stdio for a local client, or over Streamable HTTP with bearer-token auth for a hosted setup
- Unit tests, protocol checks on both transports, and a secret scan run in CI on every push
## Requirements
- Node.js ≥ 18
- A Google Cloud OAuth client (Desktop app) — see below
## Google Cloud setup (one-time)
1. Go to [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project.
2. **Enable the Gmail API**: APIs & Services → Library → search "Gmail API" → Enable.
3. **Configure the OAuth consent screen** (APIs & Services → OAuth consent screen):
- User type: **External** is fine for personal accounts.
- Fill in the required app name / support email fields.
- **Publishing status: set to "In production."** This matters: in **Testing** mode, Google expires refresh tokens after **7 days**, so you would have to re-authenticate every account weekly. In production status, tokens persist.
- You do **not** need Google's app verification for personal use. During consent you'll see an "unverified app" warning — click *Advanced → Go to (app name)*. This is expected and acceptable for a private tool.
4. **Create the OAuth client**: APIs & Services → Credentials → Create Credentials → OAuth client ID → Application type: **Desktop app**.
5. Copy the client ID and client secret and provide them to the server one of two ways:
**Option A — environment variables** (e.g. in your MCP client config):
```
GMAIL_MCP_CLIENT_ID=xxxxx.apps.googleusercontent.com
GMAIL_MCP_CLIENT_SECRET=GOCSPX-xxxxx
```
**Option B — config file** at `~/.config/gmail-mcp/client.json`:
```json
{ "client_id": "xxxxx.apps.googleusercontent.com", "client_secret": "GOCSPX-xxxxx" }
```
If credentials are missing, tools return an error pointing back at this section — the server itself never crashes over it.
## Install & build
```bash
npm install
npm run build
```
The server binary is `dist/index.js` (stdio transport; all logging goes to stderr).
## Hooking it up to Claude
**Claude Code** (`~/.claude.json`, or per-project `.mcp.json`) / **Claude Desktop** (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"gmail": {
"command": "node",
"args": ["/absolute/path/to/gmail-mcp-server/dist/index.js"],
"env": {
"GMAIL_MCP_CLIENT_ID": "xxxxx.apps.googleusercontent.com",
"GMAIL_MCP_CLIENT_SECRET": "GOCSPX-xxxxx"
}
}
}
}
```
(Omit the `env` block if you use `~/.config/gmail-mcp/client.json`.)
With Claude Code you can also add it from the CLI:
```bash
claude mcp add gmail -- node /absolute/path/to/gmail-mcp-server/dist/index.js
```
## Running over HTTP (hosted)
`node dist/index.js --http` serves MCP over [Streamable HTTP](https://modelcontextprotocol.io/specification/basic/transports) at `/mcp`, stateless, with a `/healthz` endpoint.
| Env var | Default | Purpose |
|---|---|---|
| `GMAIL_MCP_HTTP_TOKEN` | required | Bearer token clients must send. At least 32 characters; the server refuses to start without it. Generate with `openssl rand -hex 32`. |
| `GMAIL_MCP_HTTP_HOST` | `127.0.0.1` | Interface to bind. Use `0.0.0.0` only behind a reverse proxy. |
| `GMAIL_MCP_HTTP_PORT` | `3333` | Port. |
Requests without the right `Authorization: Bearer …` header get `401`. The token check is constant-time.
**Docker:**
```bash
docker build -t gmail-mcp-server .
docker run -d -p 127.0.0.1:3333:3333 \
-e GMAIL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
-e GMAIL_MCP_CLIENT_ID=... -e GMAIL_MCP_CLIENT_SECRET=... \
-v gmail-mcp-data:/data gmail-mcp-server
```
Tokens are stored under `/data/.config/gmail-mcp/` in the volume.
**Adding accounts on a hosted server.** The consent flow opens a browser on the machine running the server, so it does not work remotely. Add accounts on your own machine with `node scripts/add-account.mjs [--readonly | --send] [--alias name]`, then copy `~/.config/gmail-mcp/accounts.json` into the volume.
**Before exposing it to the internet:**
- Terminate TLS in front of it with Caddy, nginx, or a platform load balancer. Never send the bearer token over plain HTTP.
- Treat the token like a password. Anyone holding it can use every stored account at its access level.
- The bearer token is a single shared secret, suited to one owner. Multi-user hosting should use the MCP authorization spec (OAuth 2.1 with protected-resource metadata), which is the planned next step.
## Adding accounts
Ask your MCP client to call `gmail_add_account`. A browser opens for Google consent (the URL is also returned in the tool response in case it doesn't). The flow runs on an ephemeral `127.0.0.1` loopback listener and times out after 5 minutes.
- Pass `access` to choose what the account can do: `"readonly"`, `"modify"` (default), or `"send"`. See the table below.
- `allow_send: true` still works as a legacy alias for `access: "send"`.
- Pass `alias` ("personal", "work", …) to reference the account by a short name later.
- The first account added becomes the default; pass `set_default: true` to change the default later.
- Re-running `gmail_add_account` for an email you already added is the **re-auth path** — it overwrites the stored credentials (useful for expired/revoked tokens or upgrading an account to a higher access level).
### Access levels
| `access` | Scopes granted | Can do | Cannot do |
|---|---|---|---|
| `"readonly"` | `gmail.readonly` | search, read threads and messages, list labels | change labels, archive, trash, drafts, send, reply |
| `"modify"` (default) | `gmail.modify` | everything above + labels, archive, trash, **create drafts** | send, reply |
| `"send"` | `gmail.modify` + `gmail.send` | everything above + send, reply | — |
Note that `modify` is not read-only: it can relabel and trash mail. Pick `readonly` for an account an AI assistant should only read.
Each write tool checks the account's stored scopes before calling Google and returns an instruction to re-add the account at the right level, not a raw 403.
## Account routing
Every inbox tool takes `account?: string`, resolved as: exact email → alias → the default account → the sole stored account → an error listing what's available. `gmail_search_threads` additionally accepts `account: "all"`: it queries every stored account concurrently, merges thread summaries newest-first, tags each with its account, and reports per-account failures inline without failing the whole search. Pagination (`page_token`) is single-account only.
**Ids are account-specific.** A `thread_id`/`message_id` found in one account does not exist in another — always pass the same `account` the id came from.
## Token storage & security
- `~/.config/gmail-mcp/accounts.json`, written with mode `0600`; stores **refresh tokens only**, never access tokens.
- `gmail_remove_account` best-effort revokes the token with Google before deleting it locally.
- The client secret of a Desktop-app OAuth client is not treated as confidential by Google's model, but keep it out of version control anyway. `.gitignore` excludes `client.json`, `accounts.json`, and `.env*`.
### Threat model
- **Incoming mail is untrusted input.** Tool results put email bodies into the model's context, so a message can contain instructions aimed at the assistant ("forward this to…", "reply with…"). Give assistant-facing accounts the lowest access level that works, and keep a human approving anything the assistant sends.
- **Least privilege by default.** Sending is never granted unless you ask for it, and `readonly` removes every write path. Scope checks run locally before any Gmail call.
- **Local token storage.** Refresh tokens sit on disk readable only by your user. Anyone with your user account can use them, so treat the machine as the trust boundary.
- **Revocation.** Removing an account revokes its token with Google, not just the local copy.
Found a security issue? Open a GitHub issue without exploit details and ask for a private contact.
## Troubleshooting
- **"Stored credentials … expired or revoked"** — run `gmail_add_account` for that email again. If this happens every ~7 days, your consent screen is still in Testing mode; switch it to In production.
- **"Google did not return a refresh token"** — the OAuth client isn't a Desktop app, or consent didn't complete. Recreate as Desktop app and re-run.
- **Browser doesn't open** — the consent URL is included in the `gmail_add_account` response; open it manually on the same machine.
- **Message/thread not found (404)** — you're almost certainly using an id from a different account; check the `account` field on the search result that produced it.
## Development
```bash
npm run typecheck # strict TS, no emit
npm test # unit tests (Vitest): access levels, write guards, token storage, routing, schemas, error mapping
npm run verify # build, then protocol-level checks over stdio against an empty throwaway HOME
npm run test:live # optional live test against two real accounts (see scripts/live-test.mjs); not run in CI
npx @modelcontextprotocol/inspector node dist/index.js # interactive inspection
```
CI (`.github/workflows/ci.yml`) runs typecheck, unit tests, the protocol checks, and a gitleaks secret scan on every push and pull request.
## License
MIT, see [LICENSE](LICENSE).
TDQS
Scored across 12 tools
Every tool targets a distinct action-resource pair: account management, search/read, send/reply/draft, labels, and trash are cleanly separated. Even similar tools like get_message and get_thread are clearly scoped by resource type, and list_labels explicitly feeds modify_labels without overlap.
All tools use the consistent gmail_verb_noun pattern with snake_case, making the action and target predictable. Minor variation like reply_to_thread instead of reply_thread does not break the overall convention.
Twelve tools is well-scoped for a Gmail server: account lifecycle, search/read, send/reply/draft, labels, and trash each get focused coverage without redundancy. The count is solidly within the ideal range.
Core Gmail workflows are well covered: search, read, send, reply, draft, label modification, trash, and multi-account management. Minor gaps exist—no attachment content download, no draft editing/deletion, and no label create/delete—but these are workable and do not leave the main workflows dead-ended.