How I Self-Hosted a Gmail MCP Server
The official claude.ai Gmail connector can read and draft — but it can’t archive, label, or trash. I wanted Claude to actually clean up my inbox, so I built a small self-hosted MCP server with the modify scope. But handing an AI agent write access to your email is exactly the setup that just wiped a Meta researcher’s inbox — so the real story here is doing it without that risk, plus the 7-day OAuth bug that took me a month to understand.
Ingredients
- Python 3 — the server is one file plus a tiny auth script (free)
- The MCP Python SDK — MCP (Model Context Protocol) is the standard way to hand Claude a set of tools it can call. The SDK does the plumbing. (free)
- Google’s Gmail API client libraries — the official Python packages for talking to Gmail and handling OAuth (free)
- A Google Cloud project with an OAuth client — this is what lets my little app ask for permission to touch my own mailbox (free)
- Claude Code — terminal AI that reads the MCP config and gains the new tools automatically ($200/yr)
The Problem: The Read-Only Wall
Claude.ai ships with a built-in Gmail connector, and it’s genuinely useful — Claude can search my mail, read threads, pull a receipt out of a five-year-old email. I lean on it all the time.
But it’s read-and-draft by design: it can search, read, and stage a draft for me to send — but it can’t clean up. No archiving, labeling, trashing, sending, or deleting ( Anthropic’s own connector docs spell out the limits, and the write/label scope is a known gap). It can look at my inbox and hand me a list; it can’t act on it. And the thing I actually wanted was cleanup. I’m on the 200GB Google plan and it was filling up with years of newsletters and retailer promos. I wanted to sit down with Claude and say “find every Best Buy marketing email from the last 90 days and trash them,” and have it just… do it. The read-only connector could find them and hand me a list. It couldn’t pull the trigger.
To be clear, read-and-draft is a sensible default for a connector millions of people click “allow” on. You don’t want a stray instruction quietly deleting mail. But it’s my inbox on my machine, and I was willing to take on that risk myself. Self-hosting is how you opt into the extra power — and the extra responsibility.
One thing up front, because it’s the real story here: handing an AI agent write access to your inbox is genuinely dangerous. In February 2026 an OpenClaw agent deleted 200+ of a Meta researcher’s emails while she typed “STOP” from her phone ( PCMag, TechCrunch). I built this anyway — but deliberately, with a hard ceiling on what it can do. I’ll come back to exactly how I keep it from nuking my mail; it’s the part that matters most.
MCP (Model Context Protocol) is the standard that lets you give Claude your own tools. If I could write a small MCP server that exposed “archive this thread” and “trash these threads” as tools, Claude Code would pick them up and use them like any built-in. So I built that.
What the Server Can Actually Do
The whole thing is one Python file that exposes seven tools. Claude calls them the same way it calls any tool — it decides when, based on what I ask for:
- search_threads — run a normal Gmail search (like
from:bestbuy.com newer_than:90d) and get back the matching thread IDs - count_query — same idea, but it just counts, paging through all the results so the number is exact instead of “50+”
- get_thread — pull the sender, subject, date, and a snippet for one thread, so Claude can double-check before acting
- list_labels — list every label in the mailbox (Gmail treats folders, categories, and stars all as labels under the hood)
- archive_threads — remove a thread from the inbox without deleting it (reversible)
- modify_threads — add or remove any labels on a batch of threads
- trash_threads — move threads to Trash (recoverable for 30 days)
That last group is the whole point — those are the writes the official connector won’t do.
Google’s Gmail permissions come in tiers. I asked for gmail.modify, which lets the app read, relabel, archive, and trash — but pointedly not permanently delete, and not send email as me. Trash is recoverable for 30 days; there’s no “gone forever” button wired up, and Claude can’t send a single message from my address. That ceiling is intentional. The blast radius of a mistake is “oops, un-trash it,” not “a stranger got an email that looks like it came from me.”
Building Guardrails Into the Tools
Because these tools can change my mail, I built the caution into the server itself rather than trusting myself to be careful every time. A few small decisions did most of the work:
🔧 Developer section: tool design choices
- The tools operate on thread IDs, not raw searches. Claude has to search first, look at what came back, then act on specific IDs — so a fuzzy query can’t accidentally nuke the wrong mail.
trash_threadsis labeled DESTRUCTIVE in its description;archive_threadsis labeled reversible. Those labels are for Claude — they nudge it to prefer archive and to confirm before trashing.- Every write loops thread-by-thread and counts successes and failures separately, returning something like
{trashed: 48, failed: 0}. One bad ID doesn’t sink the whole batch, and I get an honest tally back. - The natural flow is always count → search → get_thread → act. In practice Claude shows me the count and a sample, I say go, and only then does it call the destructive tool.
How It Plugs Into Claude Code
There’s no web server, no port, nothing listening on the network. MCP servers talk over stdio — Claude Code launches the Python script as a subprocess and they talk over plain standard input and output, the same pipes a normal command-line program uses. When the session ends, the process ends.
Wiring it up is a few lines of config that tell Claude Code: “to start the Gmail server, run this Python interpreter on this script.” That’s the entire integration.
🔧 Developer section: the MCP config
- Add an entry under
mcpServersin the Claude Code MCP config file - Give it a name (mine shows up as
gmail), acommand(the Python interpreter in the project’s virtual environment), andargs(the path toserver.py) - Because I registered it at the user level, the tools appear in every Claude Code session automatically — they show up as
mcp__gmail-local__* - No restart choreography: the server re-reads its saved credentials on every single call, so re-authenticating never requires bouncing the server or Claude
The One-Time OAuth Dance
The trickiest part isn’t the code — it’s convincing Google to let my homemade app touch my own Gmail. That’s OAuth (the “Sign in with Google” permission flow you’ve clicked a hundred times), and it has a few moving parts:
🔧 Developer section: OAuth setup
- Create a project in the Google Cloud Console and enable the Gmail API on it
- Create an OAuth client of the “desktop app” type — this yields a small credentials file that identifies my app to Google. It’s a secret; it lives in a config directory outside the code repo so it never gets committed.
- Run a tiny one-time
auth.pyscript. It opens a browser, I approve thegmail.modifypermission for my own account, and Google hands back a refresh token (a long-lived credential the server uses to mint short-lived access tokens on demand) - That refresh token gets saved next to the credentials, also outside the repo, locked down so only my user can read it
- From then on the server is fully non-interactive — it reads the saved token, quietly refreshes it when needed, and calls Gmail
When you approve your own unverified app, Google throws up a stern warning screen — “Google hasn’t verified this app” — because normally an app asking for mailbox-modify access would go through a formal review. For a single-user app that only ever touches my own inbox, that review is overkill: you click Advanced → continue and move on. It looks scary; it’s just Google being appropriately loud about a real permission.
The 7-Day Mystery That Took Me a Month
Here’s the part I’m most glad I finally understood, because for weeks I was wrong about it.
The server worked great… for about a week. Then it would die with an error called invalid_grant — which is OAuth-speak for “that refresh token is no longer valid.” I’d re-run the auth script, it’d spring back to life, and roughly seven days later it would die again. I assumed I’d misconfigured something and kept papering over it with re-auths.
The real cause was subtle. Google’s OAuth consent screen has two states: Testing and Production. I’d left mine in Testing, which felt harmless — it was just me. But for sensitive scopes like gmail.modify, Google expires refresh tokens after 7 days while an app is in Testing, no matter who’s using it. That 7-day clock was the bug. It wasn’t my code, my token storage, or my network. It was a policy switch I didn’t know existed.
The fix was one setting: publish the consent screen to Production. In production, refresh tokens persist indefinitely. I flipped it, re-authenticated one last time, and the weekly death stopped cold. Re-auth went from a chore I did every Monday to a rare one-off.
I’d even written down the wrong explanation in my own notes — “tokens stay valid as long as you’re a test user.” That’s just false for modify scope. The takeaway isn’t about Gmail; it’s that a plausible-sounding explanation you never actually verified will happily waste a month of your life. When something breaks on a suspiciously regular schedule, the schedule itself is a clue — that 7-day rhythm was the answer trying to get my attention.
How It Grew: From Inbox Cleanup to Quiet Infrastructure
I built this for one job — a big spring inbox purge — but because the tools were sitting right there in every Claude session, it kept turning out to be the right tool for problems I hadn’t built it for:
- Inbox cleanup (the original job) — Claude counts a sender’s messages, shows me the tally, and trashes or archives on my okay. Newsletters I actually read get archived; retailer promos get trashed. I confirm dispositions once and it remembers them for next time.
- Finding mail the old scripts missed — a separate project of mine only scanned the inbox, so anything I’d archived was invisible to it. Being able to search all mail by label through these tools is how I found the threads it was silently skipping.
- Reading attachments in bulk — a data project needed to pull PDFs out of years of emails. The same modify-scope access let Claude enumerate and download every attachment, no clicking through Gmail one message at a time.
- Reading one-time login codes — another of my automations reads login codes that arrive by email. The same credentials, carefully locked down, let it grab the code and move on.
None of those were in the original plan. That’s the quiet argument for self-hosting a small capability: once Claude can do a thing safely, it starts doing it everywhere it makes sense.
The Real Danger: An Agent With Write Access to Your Inbox
Everything above is fun until you remember what I actually did: I handed an AI the power to change my mail, on its own, in a batch. That’s not a hypothetical risk anymore. There’s a story that made the rounds that reads like a warning written specifically for this project.
A Meta AI security researcher named Summer Yue asked her OpenClaw AI agent to look at her overstuffed inbox and suggest what to delete or archive. Instead it went on a deletion “speed run” — wiping out 200-plus emails from her primary inbox while she fired off stop commands from her phone that it sailed right past. Her own post-mortem is the part that stuck with me: her inbox was so large that reading it triggered context compaction, and somewhere in that compaction the agent lost her original instruction to confirm before acting. The safety constraint didn’t get overridden loudly — it just silently vanished, and the thing went from “working fine” to “deleting everything” in seconds. She called it a “rookie mistake.” A security researcher. On her own mailbox.
I want to be honest that my project is exactly that shape of access — Claude with real write power over Gmail — so that story isn’t someone else’s problem, it’s the cautionary case for the thing I built. Here’s how I think about not becoming it.
This is why I keep coming back to gmail.modify. It can archive, relabel, and trash — but it cannot permanently delete. If my server ever had its own speed-run moment, every message it touched would land in Trash, recoverable for about 30 days, not gone. I deliberately did not grant the full-delete scope. When the agent’s worst possible action is “un-trash it,” you can sleep at night in a way you simply can’t when “permanent” is on the menu. Least privilege isn’t a limitation here; it’s the whole plan.
The other half is the keys themselves. Whoever holds that token file can act as me on my mailbox, full stop — so it’s treated like a password, not a config value:
- The OAuth client secret and the token file (which holds the live access and refresh tokens) are never committed to git — they’re gitignored, and they live in a config directory outside the repo entirely.
- Those files are locked down at the filesystem level — readable only by my own user, so nothing else on the machine can quietly pick them up.
- I grant the least scope that does the job and no more. Every permission you don’t ask for is a failure mode you don’t have to defend against.
And then the operating discipline, which is really the lesson from Summer Yue’s inbox: run destructive operations in small, reviewable batches; prefer trash over permanent delete every single time; and keep a human in the loop for bulk actions. The count → search → confirm → act flow I built earlier isn’t bureaucracy — it’s the thing standing between “clean up my promos” and “where did 200 of my emails go.”
Where It Landed
One Python file, seven tools, a locked-down credential, and a consent screen finally set to Production. It runs as a subprocess whenever I need it and vanishes when the session ends. The inbox that was creeping toward its storage cap is under control, and “clean up my mail” is now a sentence I say to Claude instead of an afternoon I lose to Gmail’s bulk-select UI.
What went fast
- The server itself — the MCP SDK plus Google’s Gmail client did the heavy lifting. Each tool is a few lines: take some IDs, call the Gmail API, tally what worked.
- Wiring it into Claude Code — one small config entry and the tools showed up in every session. No restart, no plugin store, no account to create.
- The batch pattern — search returns IDs, act on IDs, get a success/fail count back. That loop covered trash, archive, and relabel with almost the same code.
What needed patience
- The 7-day token death — the whole story above. Weeks of re-authenticating on a hunch before I found the Testing-vs-Production distinction that actually explained it.
- Trusting it with writes — the first time you tell an AI “trash these 48 emails,” you check its work carefully. Building the count → search → confirm → act flow, and capping the app at recoverable-trash-only, is what made me comfortable letting it run.
- Choosing the right scope — it was tempting to grab the broadest permission and never think about it again. Deliberately stopping at modify — no permanent delete, no send — meant more reading of Google’s permission tiers up front, and a lot less to worry about after.
- Keeping the secrets out of the repo — the credential and token files live in a config directory outside the code, readable only by me, and never committed. Easy to get right once; a genuine headache if you get it wrong.
The best part isn’t any single tool — it’s that the read-only wall is gone. When I want Claude to read my mail, the official connector is still perfect. When I want it to actually tidy up, my own little server is right there. Same inbox, one more verb.