gmail-mcp
by DavidVinu
README.md
# gmail-mcp
An MCP server that gives an AI assistant several Gmail accounts at once — read
and draft only. It **cannot send**, it **cannot delete**, and it **cannot reach
anything but the Gmail API**.
Every tool takes a required `account` parameter; `list_accounts` shows what is
configured. Adding another account is an OAuth flow at runtime, not a redeploy.
## What it cannot do, and where the guarantee actually lives
| | How it is prevented |
| - | - |
| Send | No send tool. No code path reaches `messages/send` or `drafts/*/send`, and `gmailUrl` refuses those paths outright. |
| Delete | Not offered at all. Gmail's `drafts.delete` is permanent, so there is no delete tool of any kind, and the outbound door refuses the `DELETE` verb whatever the path. |
| Trash | No tool offers it; `trash`, `untrash` and `batchDelete` are refused paths; and the flag map below is a closed list, so no caller string can become a label id. |
| Change filters or forwarding | `gmail.settings.basic` is not requested, and `settings/` is a refused path. |
| Touch Drive, Docs, Sheets, Calendar | Those scopes are not requested. |
| Contact anything but Google | Three constant hosts, one outbound function, `redirect: 'error'`. A contract test scans the source for URL literals. |
**One thing you must know, because it is a real limit and not a detail.**
Google has **no draft-only scope** and no modify-without-send scope for this
kind of client. `gmail.modify` is what marking mail as read requires, and at
the API level it also permits sending. So the grant this server holds *could*
send; the reason it cannot is that this server offers no send tool and no code
path that reaches the send endpoint. That is the same shape of guarantee the
Proton server gives, and it is worth stating plainly rather than implying that
Google enforces it. (`gmail.modify.restricted` exists and does exclude send,
but it is for Workspace administrators using a service account with
domain-wide delegation — not for a desktop client on consumer accounts.)
**Why trash is the interesting case.** Gmail moves a message to the trash
through `users.messages.modify` with `addLabelIds: ['TRASH']` — the same
endpoint that marks mail as read. A path guard never sees it. So the boundary
that actually holds is not the refused path but the **closed flag map**: the
tool takes one word from a fixed enum (`seen`, `flagged`), and only the label
id that map yields is ever sent. A contract test reads that enum from the
*published* schema rather than a hand-written list, so a flag added later is
exercised the moment it exists.
`seen` is inverted, because Gmail labels unread mail rather than read mail:
adding `seen` removes `UNREAD`, removing `seen` adds it. Both directions are
pinned by a test.
## Requirements
- Node.js 20 or newer
- Your own Google OAuth client (below)
## What you have to do in the Google Cloud Console
This part cannot be automated — it is your account and your consent screen.
1. **Create or pick a project.** console.cloud.google.com → project selector →
*New project*. Any name.
2. **Enable the Gmail API.** *APIs & Services → Library* → search "Gmail API" →
*Enable*. Enable nothing else; this server uses nothing else.
3. **Configure the consent screen.** *APIs & Services → OAuth consent screen*.
- User type: **External** (unless every mailbox is in a Workspace you own,
then Internal).
- Fill in app name and your own address; nothing else is required.
- **Scopes:** add exactly this one, and no others:
```
https://www.googleapis.com/auth/gmail.modify
```
One scope, not three: `gmail.modify` is a superset of `gmail.readonly`
and `gmail.compose` (`users.drafts.create`, `users.messages.modify` and
`users.messages.get` all accept it), so requesting the other two as well
would advertise a narrowness that does not exist.
- **Test users:** add every Gmail address you intend to connect. While the
app is in *Testing*, only listed addresses can authorise it, and refresh
tokens expire after 7 days. For a permanent setup either publish the app
(Google will ask for verification because Gmail scopes are sensitive) or
keep it in Testing and re-authorise weekly. **Decide this before you rely
on it** — a token that quietly dies after a week is the kind of failure
that goes unnoticed.
4. **Create the client.** *APIs & Services → Credentials → Create credentials →
OAuth client ID → Desktop app*. Download the JSON.
5. **Install it:**
```sh
mkdir -p ~/.config/gmail-mcp
install -m 0600 ~/Downloads/client_secret_*.json ~/.config/gmail-mcp/oauth-client.json
chmod 700 ~/.config/gmail-mcp
```
The server refuses to start from a file that group or others can read.
If you use a **Web application** client instead of Desktop, add your redirect
URI there and set `GMAIL_MCP_REDIRECT_URI` to the same value.
## Adding an account
Ask the assistant, or call the tools directly:
1. `begin_account_auth` with `account: "privat"` — a short id you choose.
It returns a Google consent URL.
2. Open the URL **as the owner of that mailbox** and approve.
3. **Your browser will then show an error** — "This site can't be reached",
"connection refused", or a blank page. **That is expected and is not a
failure.** Nothing listens on the redirect address; the part that matters is
in the address bar:
```
http://localhost/?state=privat&code=4/0AX4X...&scope=...
^^^^^^^^^^^^^^ this
```
Copy the value of `code=`, up to the next `&`.
4. `finish_account_auth` with the same `account` and that `code`. The code is
single-use and expires within minutes, so do this straight away; if it
fails, start again at step 1.
The redirect address is taken from your client file, because it has to match
what Google registered for that client to the character — a mismatch shows up
as `redirect_uri_mismatch` on the consent screen. `GMAIL_MCP_REDIRECT_URI` only
applies if the file names none.
The refresh token is written to `~/.config/gmail-mcp/accounts/<id>/token.json`
with mode `0600` in a `0700` directory, atomically. The account's address is
read back from Gmail and stored in `meta.json`; `list_accounts` shows it.
Repeat for each mailbox. Ids must match `[a-z0-9][a-z0-9._-]{0,63}`.
To revoke: delete the account directory, and remove the app at
myaccount.google.com/permissions for that address. Deleting the file alone
leaves the grant standing on Google's side.
## The recipient rule
A reply draft is only created if the message was addressed to that account
(`To` or `Cc`). If it went somewhere else, the call is refused and both
addresses are named. It fails closed: if the original cannot be read, no draft
is created.
Only `To` and `Cc` are examined. `Delivered-To` and `X-Original-To` carry the
delivering mailbox rather than the address the mail was sent to, and checking
those would let through exactly the cases this rule is about.
## Running it
### Locally (stdio)
```sh
claude mcp add gmail --scope user -- /path/to/gmail-mcp/src/stdio.mjs
```
Claude Code reads `~/.claude.json`, **not** `claude_desktop_config.json`.
### Over HTTP
```sh
mkdir -p ~/.config/gmail-mcp
install -m 0600 /dev/null ~/.config/gmail-mcp/http-token
openssl rand -hex 32 > ~/.config/gmail-mcp/http-token
```
Then run `src/http.mjs`, or install `systemd/gmail-mcp.service`. It binds to
`127.0.0.1` only; exposing it is your job and your risk — put it behind a
tunnel or an authenticated proxy you control.
The endpoint takes a static bearer token and has **no OAuth**: it answers `404`
on `/.well-known/*` and sends no `WWW-Authenticate`, because a client that sees
either will try dynamic client registration and fail confusingly. An unknown
session id answers `404` rather than `400`, so a client re-initializes instead
of retrying a session that a restart threw away.
## Configuration
| Variable | Default |
| - | - |
| `GMAIL_MCP_CONFIG_DIR` | `~/.config/gmail-mcp` |
| `GMAIL_MCP_ATTACHMENT_DIR` | `~/.local/share/gmail-mcp/attachments` |
| `GMAIL_MCP_REDIRECT_URI` | the client file's own value, else `http://localhost` |
| `GMAIL_MCP_TOKEN_FILE` | `~/.config/gmail-mcp/http-token` (HTTP only) |
| `GMAIL_MCP_PORT` | `18791` (HTTP only) |
| `GMAIL_MCP_ADDRESS` | `127.0.0.1` (HTTP only) |
## Tools
`list_accounts`, `begin_account_auth`, `finish_account_auth`, `labels_list`,
`message_search`, `message_read`, `thread_read`, `draft_list`, `draft_read`,
`draft_create`, `draft_reply`, `attachment_download`, `flag_add`,
`flag_remove`.
`flag_add` and `flag_remove` take one word from a closed enum — `seen` or
`flagged` — and nothing else. The vocabulary is the Proton server's rather
than Gmail's, so an assistant driving both learns one word list; `answered`
is deliberately absent because Gmail has no such label.
## One-time codes
Reading mail means confirmation codes reach the model. `src/otp-filter.mjs`
masks them first — copied verbatim from the protonmail-mcp repo, where it also
carries a differential test against the Python original it was ported from.
Gmail's opaque ids are exempt by field name *and* shape, because destroying
them makes every follow-up call impossible.
Layer 2 blanks the whole string when text merely *announces* a code, so a reply
preview quoting such a message can come back empty. The draft is still created
correctly.
## Tests
```sh
npm test
```
52 cases, no network and no Google account required: global `fetch` is replaced
via `node --import`, so the production build has no switch for redirecting its
own outbound door. Twelve mutations are checked by hand and each is caught —
recipient rule off, send path allowed, header check removed, tokens written
world-readable, a second scope added, the `DELETE` guard dropped, each of the
three trash paths dropped, the `seen` inversion flipped, `STARRED` quietly
changed to `TRASH`, and a `TRASH` entry added to the flag map.
Two of those are worth repeating, because both slipped through first:
- The flag test listed the flags by hand, so adding a *new* enum entry passed
untouched. It now reads the enum from the **published schema** instead, and
drives whatever it finds there.
- Loosening the mode on the `writeFile` that stores a refresh token changed
nothing a state-based test could see, because an explicit `chmod` follows.
That mode is what closes the window in which the file exists and is readable
before the chmod lands, so holding it takes a look at the source — which is
what the case now does.
One case is skipped unless you point it at the sibling server:
```sh
OTP_FILTER_SIBLING=/path/to/protonmail-mcp/src/otp-filter.mjs npm test
```
`src/otp-filter.mjs` is a deliberate copy of the file in
[protonmail-mcp](https://github.com/DavidVinu/protonmail-mcp), not a shared
dependency: two servers, two deployments, two blast radii, so an edit for one
cannot silently change the other. The price of that is drift, and drift in a
filter is invisible until a code reaches a model. So the copies are compared by
**behaviour** rather than by text -- part of the divergence is intended, since
Gmail identifies messages by opaque hex strings where IMAP uses decimal UIDs.
`scrub` and `STRUCTURAL_FIELDS` are excluded for exactly that reason.
## Why this is not tszaks/gmail-multi-inbox-mcp
That project was the starting point and was reviewed first. It requests
`https://mail.google.com/` (full Gmail including permanent delete),
`gmail.settings.basic` (which can create forwarding addresses), full Drive,
Sheets, Docs and Calendar; it ships `send_email`, `send_draft`, `trash_emails`,
`share_drive_file` and 55 more tools; its HTTP mode binds `0.0.0.0` with no
authentication at all; it writes tokens and the client secret with a plain
`writeFile` and contains no `chmod` anywhere; and `unsubscribe_from_email`
POSTs to a URL taken from the `List-Unsubscribe` header — an address chosen by
whoever sent the mail, `http://` included, with no allowlist and no guard
against private ranges.
None of that is a criticism of its goals; it is simply a different tool. This
one is written to a narrower brief.
## License
AGPL-3.0-or-later, Copyright (C) 2026 David Vinu.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues