Gmail MCP Gateway
Provides AI agents with read and organizational access to multiple Gmail accounts, including searching, reading messages and threads, downloading attachments, creating and editing drafts, marking read/unread, managing labels, and archiving, while explicitly blocking sending, trashing, deleting, marking spam, or making arbitrary API calls.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Gmail MCP Gatewayfind emails from Alice about the budget meeting last week"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Gmail MCP Gateway
An MCP server that gives AI agents full read and organizational access to multiple Gmail accounts — and no ability to send, trash, or delete mail.
Gmail MCP Gateway
ALLOWED FORBIDDEN
─────── ─────────
Search Send
Read messages Send draft
Read threads Trash
Read attachments Delete
Create drafts Mark spam
Edit drafts Gmail settings
Archive Forwarding rules
Read / unread Arbitrary API calls
LabelsThe guarantee is enforced in application code, not by instructions to the client. A buggy, compromised, or prompt-injected MCP client cannot send email through this gateway, because no code path exists that would let it.
Contents
Quickstart — from clone to working, in five steps
Environment variables — what to set, and how to generate it
Running it — stdio, HTTP, systemd, Docker
Related MCP server: imap-mcp
Quickstart
Requires Python 3.11+. Five steps, roughly ten minutes, most of it in the Google console.
1. Install
git clone <this-repo> gmail-mcp-gateway
cd gmail-mcp-gateway
uv sync # or: python -m venv .venv && .venv/bin/pip install -e .
.venv/bin/gmail-mcp-gateway --versionOptionally put it on your PATH so the examples below read more naturally:
export PATH="$PWD/.venv/bin:$PATH"2. Create a Google OAuth client
One-time, free, and shared by every account you later add.
Create a project at https://console.cloud.google.com/.
APIs & Services → Library → enable Gmail API.
APIs & Services → OAuth consent screen → External, fill in the required fields, add your own Google account under Test users.
Publish app (still no verification review needed while you are the only user). Skipping this leaves the app in "Testing", where Google expires refresh tokens after 7 days and you will be re-authorizing every week.
Credentials → Create credentials → OAuth client ID → Desktop app → Download JSON.
You do not select scopes here. The gateway requests exactly what it needs at authorization time and refuses to request anything outside Gmail.
3. Install the OAuth client
install -Dm600 ~/Downloads/client_secret_*.json \
~/.local/share/gmail-mcp-gateway/secrets/oauth_client.jsonThat is the only file you have to place by hand. The encryption key is generated for you on first run.
4. Authorize an account
gmail-mcp-gateway accounts add personalA browser opens; approve the requested permissions, leaving every box ticked (the gateway fails loudly rather than half-working if a permission is withheld). A refresh token is stored encrypted, and from here the gateway runs unattended.
Add as many as you like — each gets its own consent, refresh token, encryption key, rate-limit bucket, and audit trail:
gmail-mcp-gateway accounts add work
gmail-mcp-gateway accounts add newsletters --read-only # Google itself refuses writes5. Verify
gmail-mcp-gateway health # exit 0 = ready, 2 = something is wrong[ok ] directories config=/home/you/.config/gmail-mcp-gateway ...
[ok ] database /home/you/.local/share/gmail-mcp-gateway/gateway.db
[ok ] master_key loaded
[ok ] oauth_client configured
[ok ] accounts 1/1 authorized
gmail-mcp-gateway 1.0.0: healthyThen point your MCP client at it — see Connecting an MCP client — or take it for a spin first:
uv run python scripts/try-it.py --account personalEnvironment variables
For a normal local install you need none of them. The quickstart above sets
zero environment variables. Defaults put config in ~/.config, data and
secrets in ~/.local/share, and the encryption key generates itself.
These exist for containers, systemd units, and secret managers — places where a file on disk is the wrong mechanism.
Variable | Required? | Default | Purpose |
| no¹ | — | Google OAuth client id |
| no¹ | — | Google OAuth client secret |
| no² | auto-generated | base64 32-byte credential encryption key |
| no |
|
|
| no |
|
|
| no |
| keys, OAuth client, credentials |
| no |
| HTTP bind address |
| no |
| HTTP bind port |
| no |
| Enable HTTP transport via config |
| no |
| Permit a non-loopback bind |
| no |
|
|
¹ Alternative to secrets/oauth_client.json. Provide the file or the pair.
² Unset means the gateway creates secrets/master.key (mode 0600) on first run.
Environment variables override config.toml, which overrides the defaults.
Generating each value
OAuth client id and secret — from the JSON you downloaded in quickstart step 2. To use environment variables instead of the file:
jq -r '.installed.client_id' ~/Downloads/client_secret_*.json
jq -r '.installed.client_secret' ~/Downloads/client_secret_*.jsonMaster key — 32 random bytes, base64:
openssl rand -base64 32
# or, without openssl:
python3 -c "import base64,secrets; print(base64.b64encode(secrets.token_bytes(32)).decode())"This key decrypts your stored refresh tokens. Changing it after you have added accounts makes their credentials unreadable and every account needs
accounts reauth. Back it up wherever you back up the data directory.
Gateway bearer token — not an environment variable. It is what an MCP client sends over the HTTP transport, and it is unrelated to any Google credential. The CLI mints it and stores only a SHA-256 hash:
gmail-mcp-gateway token create my-agentThe plaintext is printed once and goes in the client's config.
Using an env file
The gateway does not read .env automatically — a security tool should not
silently absorb secrets from whatever directory it was started in. Copy
.env.example, which documents every variable, and load it
explicitly:
cp .env.example .env # already covered by .gitignore
$EDITOR .env
set -a && source .env && set +a
gmail-mcp-gateway healthsystemd uses EnvironmentFile=; Docker Compose uses env_file:.
Running it
stdio — the usual choice
The client launches the gateway as a child process and talks over pipes. No port, no token, no network exposure. Google credentials stay inside the gateway process; the client sees only tool calls.
gmail-mcp-gateway serve --transport stdioRun by hand it will appear to hang — that is correct, it is waiting for JSON-RPC on stdin. Normally your MCP client starts it for you.
Streamable HTTP — standalone service
For a long-lived service, or a client that cannot spawn processes.
gmail-mcp-gateway token create my-agent # once; save the printed token
gmail-mcp-gateway serve --transport http --host 127.0.0.1 --port 8765The endpoint binds loopback, requires a bearer token, and has DNS-rebinding
protection on. GET /healthz is unauthenticated and reports liveness only.
Binding a non-loopback address requires GMAIL_MCP_ALLOW_REMOTE_BIND=true, and
an internet-routable address is refused even then. For a remote client, tunnel:
ssh -L 8765:127.0.0.1:8765 gateway-hostsystemd
deploy/gmail-mcp-gateway.service runs as a
dedicated system user in a hardened sandbox — ProtectSystem=strict, empty
CapabilityBoundingSet, seccomp filter, NoExecPaths over the data directory.
Install steps are in the unit's header. Authorize accounts once, interactively,
as the service user before starting it.
Docker
deploy/Dockerfile and
deploy/docker-compose.yml run non-root and
read-only, all capabilities dropped, port published to loopback only. The image
contains no credentials; they live in the /secrets volume.
docker compose -f deploy/docker-compose.yml up -dThe one-time authorization sequence — placing the OAuth client, running the consent flow with the redirect port published, minting a token — is in the compose file's header comments.
Connecting an MCP client
stdio
{
"mcpServers": {
"gmail": {
"command": "/absolute/path/to/gmail-mcp-gateway/.venv/bin/gmail-mcp-gateway",
"args": ["serve", "--transport", "stdio"]
}
}
}Claude Code:
claude mcp add gmail -- /absolute/path/to/.venv/bin/gmail-mcp-gateway serve --transport stdioHTTP
{
"mcpServers": {
"gmail": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp",
"headers": { "Authorization": "Bearer <token from `token create`>" }
}
}
}Clients never mount or otherwise access Google credential files. Under stdio the client speaks to a pipe; under HTTP it holds a gateway token unrelated to any Google credential.
Tool reference
Every tool takes an account alias — there is no default account. Mutating
tools accept an optional client_request_id for idempotency: repeating a call
with the same id and arguments returns the first result instead of acting twice.
Tool | What it does |
| Aliases, addresses, status, granted capabilities. No credentials. |
| Live authorization check per account, plus mailbox totals. |
| Gmail search syntax; |
| One message: sender, to, cc, bcc, subject, timestamp, labels, read state, body, attachment inventory. |
| A whole conversation in order, with participants. |
| Attachment inventory. Downloads nothing. |
| Fetch bytes: inline base64 when small, otherwise written to the gateway's own directory. |
| All labels with counts, and whether the gateway will modify each. |
| Apply labels by id or name. Refuses |
| Remove labels by id or name. Refuses |
| Remove |
| Remove |
| Add |
| Saved drafts with recipients, subject, snippet. |
| One draft in full. |
| New plain-text draft. Saved, never sent. |
| Reply draft in an existing thread, with correct |
| Edit a draft; omitted fields keep their values, threading is preserved. |
Mutations work on individual messages or threads and on batches (default cap 100
ids). Labels may be given as ids (Label_7) or display names (Receipts).
Reading and writing are treated asymmetrically where it matters: gmail_search
will happily filter on TRASH or set include_spam_trash, because inspecting
what is already there is a read. Applying those labels is refused, because that
would move mail into Trash or report it as spam.
Errors
Failures come back as MCP tool errors with isError: true and a structured
payload in both the text block and structured content:
{"error": {
"code": "forbidden_label",
"message": "refusing to add label 'TRASH': moving messages to Trash is a forbidden capability of this gateway",
"retryable": false
}}Codes include invalid_input, unknown_account, not_found, too_large,
batch_too_large, rate_limited, forbidden_operation, forbidden_label,
account_read_only, needs_reauth, upstream_rate_limited,
upstream_unavailable, network_error, timeout, and internal_error.
Internal exceptions are logged server-side and reported as a bare
internal_error — clients never receive a traceback or an internal path.
Administration
gmail-mcp-gateway accounts list
gmail-mcp-gateway accounts status # live Gmail check per account
gmail-mcp-gateway accounts auth <alias>
gmail-mcp-gateway accounts reauth <alias> # after a revoked or expired grant
gmail-mcp-gateway accounts remove <alias> --yes # revokes at Google, deletes locally
gmail-mcp-gateway token create <name>
gmail-mcp-gateway token list
gmail-mcp-gateway token revoke <name>
gmail-mcp-gateway audit --limit 50 # recent state-changing operations
gmail-mcp-gateway audit --account work --since-hours 24
gmail-mcp-gateway audit --outcome denied --json
gmail-mcp-gateway prune # expired audit rows, dedup keys, attachments
gmail-mcp-gateway health --jsonOn a headless host, authorize with the redirect port forwarded:
# on the server
gmail-mcp-gateway accounts add work --no-browser --port 8899
# on your laptop
ssh -L 8899:127.0.0.1:8899 server
# then open the printed URL locallyThe audit log records account, timestamp, operation, affected ids, outcome, error code, duration, and calling principal — for successes, failures, and refusals alike. It never records tokens, message bodies, subjects, or attachment contents. Administration is CLI-only: a compromised MCP client cannot add an account, trigger a consent flow, mint a token, or read the audit log.
Directory layout
Configuration, data, and secrets are separate and separately overridable, so each can have a different backing store:
Role | Variable | Default | Contents |
Config |
|
|
|
Data |
|
|
|
Secrets |
|
|
|
config.toml is optional; see
deploy/config.example.toml for every key —
batch caps, page sizes, body and attachment budgets, rate limits, retry policy,
and idempotency window — with its default.
How the boundary is enforced
Four independent layers. Each alone would block a send; all four have to fail for a message to leave.
1. The tool surface. Eighteen tools exist. There is no gmail_send, no
gmail_trash, no gmail_raw_request, and no tool that accepts a URL, path,
HTTP method, or endpoint name. A generic Gmail proxy is not something the client
can reach because it is not something that was written.
mcpsrv/server.py
2. The endpoint allowlist. Every HTTP request to Gmail must name one of
fourteen Endpoint constants. users.messages.send, users.drafts.send,
users.messages.trash, users.messages.delete, and everything under
users.settings are simply absent. Path parameters are validated against a
strict id pattern and percent-encoded with an empty safe set, so no value can
introduce a / and reach a different endpoint. A denylist re-checks the
resolved method and path immediately before the request goes out, independently
of how it was built. DELETE and PATCH cannot be issued at all.
gmail/allowlist.py
3. The label policy. This closes the back door the allowlist leaves open.
users.messages.modify is permitted — it is how archiving and read state work —
but Gmail treats TRASH and SPAM as ordinary labels, so applying one trashes
a message or reports it as spam. Every label id in a mutation is checked, in
both directions, case-insensitively, and the assembled request body is checked
again before transmission.
gmail/labels.py
4. The OAuth scope. Accounts are authorized with gmail.modify and nothing
else. That scope cannot permanently delete a message (messages.delete requires
https://mail.google.com/) and cannot touch any Gmail setting, so forwarding
rules, filters, POP/IMAP configuration, and permanent deletion are impossible at
Google's authorization layer rather than merely blocked here. Google publishes no
scope granting draft creation without send, so send is blocked by layers 1–2
instead. Accounts added with --read-only get gmail.readonly, and Google
itself then rejects every write.
Security model
Email content is untrusted. Bodies, subjects, sender names, and attachment
filenames are written by third parties and may contain instructions aimed at the
model reading them. The gateway marks every read result
content_is_untrusted: true, and the server instructions tell the client to
treat email as data rather than direction. More usefully, the capabilities that
injected instructions would ask for do not exist.
HTML is never executed and never returned as markup. <script>, <style>,
<iframe>, and similar elements are discarded with their contents; all other
tags are stripped. The result is plain text.
Invisible Unicode is stripped. Zero-width characters, bidirectional
overrides, and Unicode tag characters let an attacker show a human one thing
while an LLM reads another. They are removed and the count is reported as
removed_hidden_characters.
Attachments are stored, never opened. The gateway does not parse, render, or
execute attachment content. A client can suggest a filename but never a path:
the destination is always
<attachments_dir>/<account>/<message_id>/<sanitized-name>, resolved and
re-checked for containment, written O_NOFOLLOW at mode 0600.
Credentials never reach the client. Refresh tokens, access tokens, and the
OAuth client secret exist only inside the gateway process. Each account's
credential is sealed with AES-256-GCM under a key derived per account
(HKDF-SHA256(master, "…account:<id>")), with the account id as associated data
— so one account's key does not open another's, and a credential file moved
between accounts fails to decrypt. Files are 0600 in a 0700 directory; the
gateway refuses to read a group- or world-readable key.
Honest scope: encryption at rest protects against backups, stray copies, and disk images. It does not defend against an attacker already executing code as the gateway's user — that attacker can read the master key. Filesystem permissions remain the primary boundary.
Logs cannot leak secrets. Every log record passes a redaction filter that rewrites anything shaped like a Google access or refresh token, client secret, bearer header, JWT, or credential-named field — in the message, the arguments, and the exception text. Under stdio, logs go to stderr because stdout is the MCP wire.
Inputs are validated. Draft recipients must be bare addresses matching a
strict pattern; any CR, LF, or NUL in a header value is rejected as attempted
header injection. Drafts are assembled from typed fields — the gateway never
accepts raw RFC 5322 from a client. Batches, page sizes, body lengths,
attachment sizes, and recipient counts are all capped, and a per-account token
bucket fails fast with a retry_after_seconds hint rather than queueing.
What this does not protect against
An operator who can execute code as the gateway user.
A client legitimately using allowed capabilities badly — mass-archiving, say, or writing a misleading draft. Archiving and labelling are reversible and audited; drafts still require a human to send.
Google-side compromise or a malicious OAuth client configuration.
Traffic interception if you expose the HTTP transport without TLS. Keep it on loopback or put a TLS-terminating proxy in front.
Reliability
Token refresh: automatic, with a per-account lock so concurrent calls refresh once. A
401triggers exactly one refresh-and-retry.Refresh failure:
invalid_grantmarks the accountneeds_reauthand returns a structured error naming the CLI command to fix it.Rate limits and 5xx: exponential backoff with full jitter, honouring
Retry-After, up tomax_attempts.Network failures and timeouts: retried, then reported as
network_errorortimeoutwith no internal detail.Pagination:
next_page_tokenis handed back to the client, so no cursor state lives in the server.Duplicate requests:
client_request_idsuppresses repeats for 24 hours. Concurrent repeats are serialized inside the process; reusing an id with different arguments is an error, not a silent wrong answer.Backpressure: Gmail calls share a configurable concurrency semaphore, and each account has an independent token bucket. A large search or busy account therefore cannot create unbounded upstream concurrency.
Scaling and deployment topology
Run one gateway process for a given data and secrets directory. SQLite state, credential files, token-refresh locks, and in-flight idempotency coordination are intentionally local; pointing multiple replicas at the same volume does not provide safe active-active operation.
For a larger installation, shard accounts across independent gateway instances,
each with its own config, data, secrets, bearer tokens, and loopback port. This
keeps failures, rate limits, audit trails, and credentials isolated while still
allowing each instance to serve concurrent clients. Increase
limits.max_concurrency only after observing Gmail quota use and host capacity;
the default of 8 is conservative. Put a TLS-authenticated routing layer in front
if clients need one shared network address, and route each account alias to its
own instance.
Active-active replicas for the same account would require replacing SQLite and local credential/idempotency state with coordinated external stores. That is outside this gateway's current security model; do not scale it by merely adding workers or sharing its volume.
Testing
uv sync --all-extras
uv run pytest -q # 334 tests, no Google account needed
uv run pytest tests/test_security_boundary.py -v # just the guaranteetest_security_boundary.py drives every supported operation through a mock
Gmail that fails if a forbidden URL is ever requested, then tries to trash,
spam, and send by every route available.
Once an account is authorized, exercise it against a real mailbox. The script connects over stdio exactly as an MCP client would, runs a read-only tour, then confirms the forbidden operations are refused:
uv run python scripts/try-it.py --account personal
uv run python scripts/try-it.py --account personal --draft # also drafts a reply
uv run python scripts/try-it.py --account personal --archive # archive round tripRead-only unless you pass a mutation flag, and every mutation it makes is reversible. The draft it creates must be deleted by you — the gateway cannot.
To click around interactively:
npx @modelcontextprotocol/inspector .venv/bin/gmail-mcp-gateway serve --transport stdioLayout
src/gmail_mcp_gateway/
├── mcpsrv/server.py the tool surface — the complete client-facing API
├── mcpsrv/http.py Streamable HTTP transport, bearer auth, bind safety
├── service.py the supported operations, and nothing else
├── gmail/allowlist.py the endpoint allowlist ← security boundary
├── gmail/labels.py label policy (blocks TRASH/SPAM) ← security boundary
├── gmail/client.py the only code that talks to Gmail
├── gmail/parse.py MIME → structured data, sanitization
├── gmail/compose.py draft assembly from typed fields
├── security/ validation, rate limiting, path confinement
├── auth/oauth.py OAuth 2.0 + PKCE, refresh, revoke
├── accounts.py account registry
├── crypto.py envelope encryption for credentials
├── audit.py audit log
└── cli.py administrationAdding a supported operation means: an Endpoint in allowlist.py, a method in
service.py, a tool in mcpsrv/server.py, an entry in EXPOSED_TOOLS, and
tests. EXPOSED_TOOLS and FORBIDDEN_TOOLS are asserted against the running
server, so adding a tool without declaring it — or adding a forbidden one —
fails the suite. Keep the gateway focused on Gmail; a different Google product
belongs in a separate MCP service, not in wider scopes here.
Troubleshooting
no OAuth client configured — quickstart step 3. Either place
secrets/oauth_client.json (mode 0600) or set GMAIL_MCP_OAUTH_CLIENT_ID and
GMAIL_MCP_OAUTH_CLIENT_SECRET.
Google did not return a refresh token — you have authorized this app
before. Remove its access at https://myaccount.google.com/permissions and run
accounts auth <alias> again.
consent screen did not grant every required permission — a permission box
was unticked. Re-run authorization and leave them all ticked. The gateway fails
here on purpose rather than leaving an account that half-works.
Account goes needs_reauth every week — the OAuth app is still in
"Testing", where Google expires refresh tokens after 7 days. Publish it
(quickstart step 2.4).
stored credential failed authentication — GMAIL_MCP_MASTER_KEY changed,
or the key file was replaced. Restore the original key, or
accounts reauth <alias> for each account.
<file> is accessible to other users — the gateway refuses to read a
group- or world-readable secret. chmod 600 the file it names.
refusing to bind …: it is not a loopback address — intended. Bind
127.0.0.1 and use an SSH tunnel, or set GMAIL_MCP_ALLOW_REMOTE_BIND=true if it
really is a trusted private interface. Internet-routable addresses are refused
regardless.
serve --transport stdio looks hung — correct; it is waiting for JSON-RPC
on stdin. Let your MCP client launch it, or use scripts/try-it.py.
Something changed the mailbox and I want to know what —
gmail-mcp-gateway audit --limit 50. Every state change is there, refusals
included.
License
MIT
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Alicense-qualityAmaintenanceAn open-source MCP server that provides AI agents with secure access to read, search, and manage emails via Microsoft 365 and Gmail. It features security-first defaults like recipient allowlists and markdown content conversion to facilitate safe agent interaction with mailboxes.4Apache 2.0
- Alicense-qualityBmaintenanceRead-only MCP server for IMAP email access, enabling AI agents to read, search, and monitor email without sending or deleting messages.47MIT
- AlicenseCqualityCmaintenanceMulti-account Gmail MCP server that lets assistants scan inbox, read threads, draft and send emails only after human approval, and manage follow-up reminders.4268MIT
- Flicense-qualityDmaintenanceEnables AI agents to interact with Gmail through a standardized MCP server interface, allowing for natural language email management and automation.
Related MCP Connectors
Hosted email MCP for AI agents with inboxes, send/receive, memory, recovery, and credits.
Shipmail MCP server for AI agent custom-domain email inboxes with REST API and webhooks.
Read, search, send, organize, draft and schedule email across your inboxes from any MCP client.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/systheno/gmail-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server