Gmail MCP Connector
README.md
# Gmail MCP Connector
A production-ready [Model Context Protocol](https://modelcontextprotocol.io) server that exposes Gmail
read/search/send/draft capabilities as tools, built with [FastMCP](https://github.com/jlowin/fastmcp) and the
Gmail REST API.
## Package layout
```
server.py # entry point: registers tools, runs stdio or HTTP transport
gmail_connector/
__init__.py
auth.py # OAuth2 credential lifecycle only
errors.py # shared error handling (run_safely decorator) only
tools.py # the MCP tool functions only
compose.py # template-based subject/body drafting for compose_email
notifications.py # background polling for notifications_alter
requirements.txt
Dockerfile # containerizes the connector (HTTP transport)
docker-compose.yml # connector + MCP Inspector + custom Tools-only web UI
inspector/Dockerfile # containerizes the official MCP Inspector
web-ui/ # custom Tools-only browser UI (FastAPI backend + static page)
app.py # proxies MCP tool calls from the browser to gmail-connector
static/index.html # self-contained frontend: pick a tool, fill fields, run it
Dockerfile
```
## Tools exposed
| Tool | Purpose |
|---|---|
| `list_recent_emails(max_results=10, label="INBOX", from_email=None)` | List recent emails in a label, or from one specific sender when `from_email` is given, with sender/subject/date/snippet. |
| `search_emails(query="", max_results=10, from_email=None)` | Search using Gmail query syntax (`from:`, `subject:`, `after:`, `is:unread`, ...); `from_email` is combined with `query` if both are given. |
| `reply_to_email(from_email, body)` | **Immediately sends** a threaded reply to the most recent email from `from_email` (same subject/thread, proper `In-Reply-To`/`References` headers) — no message ID lookup needed. |
| `modify_email(from_email, add_labels=None, remove_labels=None)` | Adds/removes Gmail labels on the most recent email from `from_email` — no message ID needed. Requires the `gmail.modify` scope — see the note below. |
| `trash_email(from_email)` | Moves the most recent email from `from_email` to Trash — recoverable for 30 days via `restore_email`. Requires the `gmail.modify` scope. |
| `restore_email(from_email)` | Restores the most recent **trashed** email from `from_email` back to the Inbox. Requires the `gmail.modify` scope. |
| `forward_email(from_email, to_email, body=None)` | **Immediately sends** the most recent email from `from_email`, forwarded to `to_email`, with the original sender/date/subject/body embedded and your optional `body` note prepended. |
| `send_email(to, subject, body, cc=None)` | **Immediately sends** a real, new email with a caller-supplied subject/body. |
| `create_draft(to, subject, body)` | Creates a draft without sending. |
| `compose_email(to, topic, cc=None)` | Generates a subject and body from a short `topic` using a fixed template (no external API), then **immediately sends**. |
| `notifications_alter()` | Read-only. Starts a background poller (on first call) that watches for *any* new email in Inbox or Spam — brand-new conversations and replies alike — and returns/clears whatever's queued since your last call. Never sends, replies, modifies, archives, labels, or deletes anything. |
Both listing tools accept `max_results` up to 200 and paginate internally past Gmail's 100-per-page cap, so
the caller never has to think about `nextPageToken`.
Every tool above also accepts an `account` parameter (default `"default"`) to select which authenticated
Gmail account to operate on — see [Multiple Gmail accounts](#multiple-gmail-accounts) below.
`compose_email` is for when the caller only wants to give a recipient and a one-line description of
what the email should say (e.g. "let the team know the deploy is delayed"), rather than writing an exact
subject and body. The subject is derived from the topic text itself, and the body wraps it in a plain
"Hi, ... Best regards," template — there's no AI rewriting and no API key required, so the output mirrors
`topic` fairly literally rather than reading as naturally composed prose.
> **`modify_email` needs a broader OAuth scope.** It uses `gmail.modify`, in addition to the
> `gmail.readonly`/`gmail.send`/`gmail.compose` scopes the other tools use. If you authenticated before this
> tool existed, your cached `token.json` only covers the old scopes and `modify_email` will fail with a
> permission error until you re-run the consent flow (delete the account's token file, or run the bootstrap
> snippet from [First-run authentication](#5-first-run-authentication) again) so it's re-issued with the new
> scope included.
`notifications_alter` works by **polling**, not true push notifications — a public HTTPS webhook plus
a Google Cloud Pub/Sub topic would be needed for real push, which isn't practical for a local/private
deployment. Instead, the first call for a given account starts a background thread (inside the same server
process) that checks for new inbox mail roughly every 2 minutes. Every message is tracked by ID so it's only
ever reported once, even across overlapping poll windows or repeated calls. Matches queue in memory; calling
the tool again drains and returns whatever's accumulated since your last call. Because state is in-memory
only, it resets if the container/process restarts, and polling only starts once something has actually called
this tool at least once (it doesn't run automatically at server startup).
---
## 1. Google Cloud project & OAuth consent screen setup
1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and create a new project (or select an
existing one).
2. In the left nav, go to **APIs & Services → OAuth consent screen**.
- User type: **External** (unless you have a Google Workspace org and want **Internal**).
- Fill in app name, support email, and developer contact email.
- Scopes: you can add them here or let the OAuth flow request them at runtime; either way the connector
requests exactly:
- `https://www.googleapis.com/auth/gmail.readonly`
- `https://www.googleapis.com/auth/gmail.send`
- `https://www.googleapis.com/auth/gmail.compose`
- Test users: while the app is in "Testing" publishing status, add your own Gmail address (and anyone
else's who needs access) as a **Test user**, or the consent screen will reject the login.
## 2. Enable the Gmail API
In the same project, go to **APIs & Services → Library**, search for **Gmail API**, and click **Enable**.
## 3. Create a Desktop OAuth client
1. Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
2. Application type: **Desktop app**.
3. Give it a name (e.g. "Gmail MCP Connector") and click **Create**.
4. Download the resulting JSON file. This is your OAuth **client credentials** file (contains a `client_id`
and `client_secret` — not a token, and not a service account key).
5. Save it somewhere on disk, e.g. `~/.gmail-mcp/credentials.json` (the default location — see
[Environment variables](#environment-variables) to use a different path).
## 4. Install dependencies
```bash
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
```
## 5. First-run authentication
The connector authenticates lazily, on the first tool call that needs Gmail access — there's no separate
"login" command to run. On that first call:
1. `auth.py` looks for a cached token at `GMAIL_TOKEN_FILE` (default `~/.gmail-mcp/token.json`). None exists
yet, so it falls back to the interactive flow.
2. It reads your Desktop OAuth client from `GMAIL_OAUTH_CREDENTIALS` (default
`~/.gmail-mcp/credentials.json`) and opens your default browser to Google's consent screen.
3. Sign in and grant the requested Gmail scopes.
4. The resulting credentials (including a refresh token) are written to the token file automatically.
On every subsequent run, the cached token is loaded and silently refreshed with the refresh token — no
browser popup — unless the refresh token itself has been revoked or expired, in which case the interactive
flow runs again automatically.
To test locally over stdio before wiring it into any client:
```bash
python server.py
```
This starts the MCP server on stdio. You can point an MCP Inspector or compatible local client at
`python server.py` to exercise the tools directly, which will also trigger the first-run browser consent.
## 6. Multiple Gmail accounts
Every tool accepts an optional `account` parameter (default `"default"`), so this connector can operate on
more than one Gmail account — all sharing the same OAuth client (`credentials.json`), but each with its own
token file.
To add another account, e.g. `"work"`, run the same bootstrap snippet as first-run auth, naming the account:
```bash
python -c "from gmail_connector.auth import get_credentials; get_credentials('work')"
```
This opens the browser consent flow again — sign into the *other* Google account this time. The resulting
token is saved separately at `<config dir>/tokens/work.json`; the `"default"` account keeps using
`<config dir>/token.json`, completely unaffected. From then on, pass `account: "work"` to any tool to operate
on that mailbox instead, e.g. `list_recent_emails(account="work")`.
No extra Docker or environment configuration is needed for this — `tokens/` lives inside the same config
directory (`gmail-mcp-data/` when using Docker) that's already persisted.
## 7. Running as HTTP (for deployment)
```bash
python server.py --http --port 8000
# optionally: --host 0.0.0.0 (default) or a specific bind address
```
This runs the server as a long-lived HTTP process serving the MCP **streamable HTTP** transport at
`http://<host>:<port>/mcp`. Deploy it behind your usual process manager / container orchestrator / reverse
proxy like any other Python web service. Make sure `GMAIL_MCP_CONFIG_DIR` (or the individual
`GMAIL_OAUTH_CREDENTIALS` / `GMAIL_TOKEN_FILE` paths) point at a persistent volume, since the token file must
survive restarts to avoid re-triggering interactive auth.
> The very first authentication still requires an interactive browser consent flow (`run_local_server`),
> which needs a local browser and a reachable loopback port. Run first-run auth once locally (or over an SSH
> tunnel to the deployment host) to produce `token.json`, then ship that token file to the deployed
> environment — the deployed process will only need silent refreshes after that.
## 8. Registering the endpoint in your MCP platform
Once the server is reachable (locally over stdio, or remotely over HTTP), register it with your MCP-compatible
client or platform:
- **Local/stdio clients** (e.g. Claude Desktop, an MCP Inspector): add an entry pointing at the command
`python /path/to/server.py`, run from the project's virtual environment.
- **HTTP-based platforms**: open your platform's MCP integrations / connectors panel and add a new remote MCP
server using the URL `http://<host>:<port>/mcp` (or your HTTPS reverse-proxy URL in production). Consult
your specific platform's docs for the exact field names, since this varies by product.
## 9. Docker setup with a browser UI (MCP Inspector)
`docker-compose.yml` runs two containers:
- **`gmail-connector`** — this project, in `--http` mode on port `8000`.
- **`inspector`** — the official [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector),
a web UI for calling MCP tools by hand, on port `6274`.
### Step A — bootstrap `token.json` locally first
The interactive OAuth consent flow opens a real browser and listens on a loopback port on your machine — it
cannot run headlessly inside a container. So do first-run auth **once, on your host**, before touching
Docker, and let Docker mount the resulting token:
```bash
mkdir -p gmail-mcp-data
cp /path/to/credentials.json gmail-mcp-data/credentials.json
# triggers the same get_credentials() lazy-auth logic auth.py uses, without starting a server
GMAIL_MCP_CONFIG_DIR=./gmail-mcp-data python -c "from gmail_connector.auth import get_credentials; get_credentials()"
```
A browser window opens, you sign in and grant access, and `gmail-mcp-data/token.json` is written. This
`gmail-mcp-data/` folder is exactly what `docker-compose.yml` mounts into the `gmail-connector` container at
`/data`, so the containerized server only ever needs silent token refreshes from then on.
### Step B — build and start
```bash
docker compose up --build
```
### Step C — open the Inspector UI
Visit **http://localhost:6274** in your browser. The Inspector container prints a one-time session URL
(with an auth token query parameter) to its logs on startup — if the plain `localhost:6274` page asks for a
proxy session token, run `docker compose logs inspector` and open the full URL shown there instead.
### Step D — connect the Inspector to the connector
In the Inspector's connection form:
- **Transport type**: `Streamable HTTP`
- **URL**: `http://gmail-connector:8000/mcp`
Use the service name `gmail-connector`, not `localhost` — the Inspector's proxy process runs inside the
`inspector` container, and on the Compose network the connector is only reachable at its service name.
Once connected, the Inspector's **Tools** tab lists every tool from the table above, with auto-generated
forms built from their docstrings/type hints, so you can call each one directly from the browser to verify
everything end-to-end.
To stop everything: `docker compose down` (your token/credentials survive in `./gmail-mcp-data/` on the host).
## 10. Custom Tools-only web UI (alternative to the Inspector)
The MCP Inspector always shows Resources/Prompts/Ping/Sampling/Roots tabs alongside Tools, with no way to
hide them. If you just want a page that lists the Gmail tools and lets you call them — nothing else —
`docker-compose.yml` also runs a `web-ui` service at **http://localhost:3000**.
It's a small FastAPI backend (`web-ui/app.py`) that speaks MCP protocol to `gmail-connector` over streamable
HTTP using the official `mcp` Python client, plus a single self-contained static page
(`web-ui/static/index.html`) with no framework: a dropdown to pick a tool, a form built from that tool's
input schema, and a "Run Tool" button. No session token to copy, no other tabs — just the 7 Gmail tools.
It comes up automatically with `docker compose up -d` alongside the connector and Inspector. Open
**http://localhost:3000**, pick a tool from the dropdown, fill in the fields, and click **Run Tool**.
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `GMAIL_MCP_CONFIG_DIR` | `~/.gmail-mcp` | Base directory for credentials/token files when the two below aren't set explicitly. |
| `GMAIL_OAUTH_CREDENTIALS` | `<config dir>/credentials.json` | Path to the Desktop OAuth client JSON downloaded from Google Cloud Console. |
| `GMAIL_TOKEN_FILE` | `<config dir>/token.json` | Path where the cached/refreshed user token is read from and written to. |
## Security notes
- **`send_email`, `reply_to_email`, and `compose_email` perform a real, irreversible action.** Once
called, the email leaves your account and cannot be recalled by this connector. Treat the `gmail.send` scope
as sensitive:
only grant this connector to models/agents and users you trust to decide when to send mail on your behalf,
and consider steering the calling model toward `create_draft` by default when a request is ambiguous.
`compose_email` also sends without a preview step, though its output is a deterministic template
(not AI-generated), so the wording is predictable from `topic` rather than a black box.
- `token.json` contains a live refresh token that grants ongoing read/send/compose access to the account —
protect it like a credential (file permissions, secret storage, not committed to version control).
- `credentials.json` (the OAuth client secret) should also be kept out of version control; it identifies your
OAuth client but does not by itself grant access to any mailbox.
- The Google OAuth consent screen will show these scopes as **sensitive** (`gmail.send`) and
**restricted**-adjacent (`gmail.readonly`, `gmail.compose`); moving the app to "In production" for use
beyond your own test users requires Google's OAuth verification review for sensitive scopes.
- All errors are logged server-side with full detail via `logging`, but only short, non-sensitive messages are
ever returned to the calling model — avoid logging raw email bodies or credentials at levels that ship to
external log aggregators you don't control.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues