Skip to main content
Glama
flt-sudo

gmail-mcp-server

by flt-sudo
README.md
# gmail-mcp-server

![CI](https://github.com/flt-sudo/gmail-mcp-server/actions/workflows/ci.yml/badge.svg)

[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

A4.6/5.0

Scored across 12 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues