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.
| 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.
- Node.js 20 or newer
- Your own Google OAuth client (below)
This part cannot be automated — it is your account and your consent screen.
- Create or pick a project. console.cloud.google.com → project selector → New project. Any name.
- Enable the Gmail API. APIs & Services → Library → search "Gmail API" → Enable. Enable nothing else; this server uses nothing else.
- 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:
One scope, not three:
https://www.googleapis.com/auth/gmail.modifygmail.modifyis a superset ofgmail.readonlyandgmail.compose(users.drafts.create,users.messages.modifyandusers.messages.getall 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.
- Create the client. APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app. Download the JSON.
- Install it:
The server refuses to start from a file that group or others can read.
mkdir -p ~/.config/gmail-mcp install -m 0600 ~/Downloads/client_secret_*.json ~/.config/gmail-mcp/oauth-client.json chmod 700 ~/.config/gmail-mcp
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.
Ask the assistant, or call the tools directly:
-
begin_account_authwithaccount: "privat"— a short id you choose. It returns a Google consent URL. -
Open the URL as the owner of that mailbox and approve.
-
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=... ^^^^^^^^^^^^^^ thisCopy the value of
code=, up to the next&. -
finish_account_authwith the sameaccountand thatcode. 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.
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.
claude mcp add gmail --scope user -- /path/to/gmail-mcp/src/stdio.mjsClaude Code reads ~/.claude.json, not claude_desktop_config.json.
mkdir -p ~/.config/gmail-mcp
install -m 0600 /dev/null ~/.config/gmail-mcp/http-token
openssl rand -hex 32 > ~/.config/gmail-mcp/http-tokenThen 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.
| 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) |
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.
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.
npm test52 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
writeFilethat stores a refresh token changed nothing a state-based test could see, because an explicitchmodfollows. 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:
OTP_FILTER_SIBLING=/path/to/protonmail-mcp/src/otp-filter.mjs npm testsrc/otp-filter.mjs is a deliberate copy of the file in
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.
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.
AGPL-3.0-or-later, Copyright (C) 2026 David Vinu.