mailwarden — Gmail MCP Server for AI Assistants

mailwarden is a reliable, native Gmail MCP server — full mailbox triage for AI assistants such as Claude, with the feature no other Gmail MCP server ships: mailbox-side snooze. It talks straight to the live Gmail API (no cached mailbox snapshot) and re-verifies the sometimes-stale read state Gmail’s thread search reports, so what your assistant sees is the mailbox as it stands, not a snapshot — with one limit worth knowing: Gmail keeps spam and trash out of any search that does not name them, so ask explicitly with in:spam when a mail seems missing.

Open source under the MIT license, written in TypeScript, published on npm. Install is a single command:

claude mcp add mailwarden -- npx -y mailwarden

Authorization is a one-off mailwarden --auth against your own Google Cloud OAuth client. If anything about that setup is off, mailwarden --check (alias --doctor) says so instead of leaving you to guess: it inspects the credentials file, whether a token exists and is encrypted, and whether the granted scopes actually cover the tool tiers you enabled — then makes one live Gmail call to prove the token still works. Every finding comes with the concrete command that fixes it, and a failed check exits non-zero, so it doubles as a CI or health check.

Both modes a person actually watches — --auth and --check — close by naming where the documentation lives, and, if something went wrong, where to report it. That closing line is the only address the tool ever gives you: npx puts mailwarden to work without anyone having to see its repository. In server mode it stays away: there the error output is the host’s log, and a link in a machine log is noise.

Highlights

  • Mailbox-side snooze. Archive a thread now, have it resurface in the inbox on a date, a date + time, or a preset (tomorrow 9am, weekend, next week, in 3 hours). Built on dated labels plus a sweep, so the snoozed state lives in the mailbox: visible in Gmail itself, in effect from any client, and it survives a restart — not a reminder list held on the side by the server. The Gmail API itself has no snooze.
  • Search you can trust. threads.list — the call any thread search goes through — can hand back threads you finished reading long ago as still unread; every hit is re-checked against its live labels and false positives are discarded. If the quota runs out mid-search, the search fails with rate_limited rather than returning a list shortened by the quota, which would read exactly like “nothing else matches”. The re-check has a price: every candidate is fetched in full, so a search spends a noticeable share of the quota Gmail grants each user per minute — a budget that every client on the mailbox draws from.
  • Bulk operations that scale. Batch label changes for everything matching a query, 1000 messages per API request, with per-chunk partial-success reporting. Every bulk tool takes dryRun: true and then walks the same path as the real call, stopping at the first write or outbound request: you get the threads it matched and the labels it would create, and the result says dryRun rather than claiming an outcome.
  • Server-side auto-triage. Create, list and delete Gmail filters so the mailbox keeps triaging itself with no assistant in the loop — label-only actions, never forwarding, so there is still no exfiltration path.
  • Unsubscribe, end to end. See which senders fill the mailbox — grouped by sender, with thread and unread counts, the period they have been showing up over, and the opt-out each one offers — then act on it with an RFC 8058 one-click opt-out. No send scope is involved. At most one request per sender — and that ceiling belongs to the server rather than to a single call: it remembers which senders it has contacted for as long as it runs, and the single-thread tool shares that memory with the bulk path. A call that ran into a timeout can therefore be repeated without any sender learning a second time that the address is live. The memory is held in RAM only, so a restart forgets it — a decision, not a gap: a file would be a second piece of local state beside the token. For the deliberate second attempt, after the endpoint answered with an error, there is an explicit argument. Partial success is reported per thread, and the bulk variant stays capped rather than turned loose on a query: an opt-out cannot be taken back, and it confirms to the sender that the address is live.
  • Triage digest. One read-only triage_digest tool turns a mailbox slice into a decision-ready overview — top senders, label and age buckets, unread and attachment counts — instead of a raw thread list.
  • Signals on every search hit. Each result carries four flags — newsletter, automated, calendar, replyToMismatch — so an assistant can decide without opening the mail. Every one of them rests on a documented header convention or a MIME fact, never on a guess from the wording, and they are read from the fetch search makes anyway: no extra API call, available in the read-only tier.
  • Several mailboxes, one install. mailwarden --auth --account work stores its own token.work.json, and a server entry claims it with MAILWARDEN_ACCOUNT=work. Each account is fully isolated — its own token, its own granted scopes, its own tool surface — so a read-only work mailbox can sit beside a fully writable private one. Authorizing guards that isolation rather than assuming it: if the account you sign in with is not the one the target token file belongs to, --auth stops and names both addresses and the path instead of quietly replacing the other mailbox’s token. Leave the variable unset and a single-mailbox setup keeps working unchanged, with no re-authorization. The isolation carries one condition, easy to read along with two mailboxes and easy to lose past them: at most one mailbox per client configuration gets the writing tiers, the others stay read-only. Every entry in one client puts its tool surface in front of the same model at the same time, so text injected into one mailbox can trigger a call against another — what it cannot do is make that call land anywhere other than where its own entry points. Whoever genuinely needs two writing mailboxes separates them into their own clients or sessions. Two things grow with the number of entries: each instance brings its own tool surface, a full one considerably wider than a read-only one, and no tool reads across mailboxes — search, digest and the sender overview each serve the one account of their instance, so “which newsletter writes to all of them” is one call per mailbox whose answers the caller puts together.
  • Structured outputs. Every tool declares an output schema and returns validated structured content — no parsing guesswork for clients.
  • Failures are machine-readable too. A tool call that fails answers with a sentence for a human and, beside it, a fixed code plus whether a retry can help at all. “Wait and try again” is therefore distinguishable from “the user has to re-authorize” without guessing from wording that can change with any commit. When Gmail’s per-minute quota is exhausted, the answer also says how long to wait.
  • Correct with real-world mail. Encoded headers decoded, bodies decoded in their declared charset, brief throttling retried with exponential backoff. An exhausted per-minute quota gets one more try after about twenty seconds and then an honest rate_limited instead of a call left hanging.

Security & privacy by design

  • No send tools. mailwarden cannot compose, reply or forward — a prompt-injected instruction inside an e-mail has no exfiltration path through this server. In the read tier Google itself enforces that: gmail.readonly is refused by the send endpoints, so a read deployment could not send even if this binary were replaced. In the writing tiers the guarantee is carried by the tool surface instead — no tool exists that would call send, and none can be registered while the server runs. Underneath that sits a checkpoint every authenticated Gmail request has to pass: it admits only the endpoints mailwarden actually uses, and ahead of that it checks a separate deny list naming send, every draft endpoint, messages.import/insert, permanent deletion, and every settings branch except filters. The two lists are kept apart on purpose, so a careless later addition to the allow list cannot quietly re-open a forbidden path. That changes the claim: not no tool would do this, but no code path in this server can. The line it does not cross: this protects the server, not the token — a stolen gmail.modify token still sends from somewhere else, and against that only the read tier’s scope helps.
  • No telemetry, no open ports by default. stdio by default, with no listening socket at all. The optional HTTP listener binds to the loopback address and refuses to start without a bearer token — running it unauthenticated takes a deliberate opt-out, meant for an isolated network.
  • Its own consent flow. The browser consent flow is mailwarden’s own code, not a third-party helper: the loopback server that receives Google’s answer listens on this machine only, and the request carries a one-time marker the answer must return unchanged.
  • Tool tiers & least scope. MAILWARDEN_TOOLS advertises only the tiers you name — read, manage, filters — and the OAuth scopes requested at authorization are derived from them, so a read-only deployment asks Google for nothing more than gmail.readonly. MAILWARDEN_READONLY=1 is the read-only shorthand.
  • Beside a broad workspace server, the credential is the argument. A suite server that also composes mail has to hold a credential permitting that, and the permission then exists for every mailbox it is pointed at, whatever its tool surface shows today. mailwarden never asks for a compose or send scope at all: gmail.readonly in the read tier, gmail.modify at its widest, plus the filters scope where that tier is enabled. modify is no promise by itself — Google lets it reach the send endpoint; what closes that path here is the tool surface and the checkpoint above. So the question is which credential you hand over, not how many tools each side ships. Since 08.09.2026 that question has a second server asking it: aaronsb can refuse the same four write operations — by configuration, on a surface that otherwise ships them.
  • Token encryption at rest. Set MAILWARDEN_TOKEN_PASSPHRASE and the stored OAuth token is AES-256-GCM-encrypted, so a copied or synced token file is useless without the passphrase.
  • The mailbox is not a tool parameter. No tool takes an account argument. Which mailbox an instance serves is fixed by MAILWARDEN_ACCOUNT in the server configuration and resolved before the first tool is registered — outside the model’s reach, with no call that could switch mailboxes. Because the account is settled that early, tiers and scopes resolve per instance: an instruction injected into the private mailbox cannot reach into the read-only work one, since that instance never had write tools in the first place.
  • What reaches the model is cleaned first. Every answer leaves in two halves — a fenced text copy, and a machine-readable result object for clients that read the schema — and both are built from one sanitized object. Removed are only characters that render as nothing: zero-width and direction marks, C1 controls, and the two blocks that let a complete ASCII instruction hide inside a subject line (Unicode tag characters and the variation-selector supplement). A legitimate emoji therefore stays an emoji.
  • Attachment downloads are fenced — once you set the fence. download_attachment takes its destination path from the client. With MAILWARDEN_DOWNLOAD_DIR set, relative paths resolve inside that directory and the result must stay inside it; the containment check is re-run against the real parent directory, so a symlink in the fence itself or in a subdirectory below it cannot shift the boundary, and an existing file is never overwritten. Without that variable there is nothing to resolve the path against, so over HTTP a client that has already authenticated can write anywhere the server process can reach. Since 0.16.0 an --http start says so on startup instead of refusing: a missing bearer token exposes the whole mailbox to anyone who reaches the port and does refuse, whereas this needs an authenticated client, and running deployments rely on the current behaviour. The warning stays silent where it would be noise — with the fence configured, and in the read tier, which never registers the tool at all.
  • What the receiving server concluded about the sender — passed on, not judged. get_thread reports the SPF, DKIM and DMARC results from the message’s own Authentication-Results header: which checks passed, which domains each one actually verified, the envelope sender, and which server states all this. It costs no extra request and no extra scope — the headers already arrive with every full fetch and used to be discarded. Because that text is written by machines an attacker may control, three limits apply: only the first such header is read (each hop prepends its own, so the first belongs to the receiving server and any forged ones sit below it — the count of skipped reports is reported rather than hidden), every value is validated as a token or dropped, and a message with no report at all is marked unchecked, so “nobody looked” can never read as “nothing found”. The line it does not cross: differing domains are not a finding. A genuine forwarded message routinely shows three different ones — rewritten envelope sender, the forwarder’s DKIM key, the original From. That is why the DMARC result is the one that carries the statement; comparing the domains by hand turns every forward into a false alarm.
  • Live API, no copy. No mailbox mirror or search index is stored anywhere; the only local state is your OAuth token. That holds for what_changed too, which answers “what happened since I last looked” from Gmail’s own event log: the server keeps nothing, and the starting point is one number the caller holds.
  • Exactly one outbound request — the one-click opt-out. Unsubscribing is the single code path that reaches a host other than Google, and it is fenced accordingly. The URL is never a tool parameter: it comes only from the List-Unsubscribe header of the addressed message, so an injected mail cannot smuggle mailbox content into a query string. The request is fixed (List-Unsubscribe=One-Click) and the response body is dropped unread — what returns to the model is the status code and the URL actually called, never content that could carry instructions. Only one-click endpoints are called: a bare https: link is handed back rather than fetched, and mailto: opt-outs are never executed, since that would be sending. https and default port only, no credentials in the URL, at most three redirects, and every hop must resolve exclusively to globally reachable addresses. A read deployment never makes this request at all. Since 0.18.0 a second kind of link can come back: where a message carries no List-Unsubscribe header at all, the tool reports opt-out links found in the message body. The rule above does not bend for them — they are URLs an unknown sender wrote, so they are shown and never visited, not even by the one-click path. That distinction is not academic: another Gmail MCP server fetches exactly these body links and follows their redirects, which is a request to an address of the sender’s choosing.

The reasoning behind those choices is published rather than asserted: SECURITY.md documents the trust boundary, the data flow and the mitigation per threat class — among them prompt-injection exfiltration, destructive actions, stale state, token theft, the HTTP listener, path traversal and acting on the wrong mailbox — along with the non-goals it deliberately does not defend against, the configuration cost each choice carries, and a private route for reporting a vulnerability.

Compared to other Gmail MCP servers

Several Gmail MCP servers exist. What stays mailwarden’s alone is not any single row below — it is mailbox-side snooze plus live re-verification together: an inbox-workflow layer over the mailbox’s current state, not over a snapshot. The table is not a scoreboard: several rows show parity, and some show someone else doing more.

CapabilitymailwardenGoogle officialtaylorwilsdona-bonusklodraaronsb
Mailbox-side snooze
Search-result re-verification
Unsubscribe (per-sender + one-click)header shown, no action
Sweep / bulk over a querybatch by explicit idsbatch by explicit idsbatch by explicit ids
Inbox triage overviewby sender, label, age + header signalscontent heuristics (newsletter, meeting, action)unread inbox, sender + subject + date
Server-side filters✓ (never a forwarding rule)
No send tools (by design)draft onlysendssendssendssends; blockable, opt-in
Least-privilege tool tierstier → scopespartialtiers, read-only, per-service levelsscopes → toolsgranted scopes → tools, per call
Sender authentication reported (SPF/DKIM/DMARC)
Token encryption at restopt-inn/afile mode / CMEKfile mode
Structured outputs (schema per tool)✓ every toolnot documentedone tool
No vendor cloud — you operate the server

Comparison as of August 2026 against the then-current public versions; capabilities move, so treat it as a snapshot. The sender authentication row is about evaluation, not access: any server that returns a full message hands a client the raw headers. What none of the others do is read them and say what the result rests on — that only the first Authentication-Results header is the receiving server’s, and that anything below it can be forged. All four foreign columns were re-read on 03.09.2026 — the three repositories by diff against the revision noted for each, Google’s column in full — and no cell changed. On 07.09.2026 taylorwilsdon’s column was read once more, at 54b1c56: the least-privilege cell gained his per-service permission levels, which his repository has carried since February 2026 — an omission of ours, not a change of his, so the table’s age still dates from the 03.09 round. The aaronsb column is new on 08.09.2026, read at ccdf507 — every cell in it checked against his source here, not taken from the report that pointed us at him: the manifest at src/factory/manifest/gmail.yaml for the tool surface, src/factory/safety.ts for the policies, src/accounts/credentials.ts for the token. That check moved one cell away from the report we were given: he does ship a triage tool. The table’s age still dates from the 03.09 round — a new column does not refresh the old ones. mailwarden has held the property since 0.17.0. The token encryption row asks who holds the key, which a tick cannot express: ours is opt-in — AES-256-GCM only once MAILWARDEN_TOKEN_PASSPHRASE is set, and an unencrypted file otherwise. taylorwilsdon stores the credential unencrypted at file mode 0600 locally, or hands the key to the bucket’s CMEK on GCS — both defensible, neither an app-level key derived from a passphrase. Corrected 01.09.2026 after mailwarden re-read the column against source (their `406e191`); the previous tick in his column read as app-level encryption and was ours to get wrong, not his. Columns are the servers a reader is likely to reach for — Google’s own (still Developer Preview: drafts only, no trash, filters or unsubscribe), the most active community server, a widely used Docs/Drive server that carries a full Gmail surface, klodr, whose least-privilege design runs opposite to ours — klodr hides tools by the scopes you granted, mailwarden derives the scopes from the tier you chose — and aaronsb, added 08.09.2026, the only other column here that can be made unable to send. The difference that row records is who decides: his block on send, reply, reply-all and forward is a policy an operator switches on (GWS_SAFETY_POLICY), and the server ships those four tools either way; here no send tool was ever written. His scope check is the sharper idea on that row, and it runs in the opposite order to everyone else’s: it compares each call against the scopes Google actually granted— a consent screen lets a user untick individual boxes — rather than against a table the server keeps. Ours points the same way and fires earlier, at registration. taylorwilsdon reduces scope in the same direction we do — tool tiers (core, extended, complete), a --read-only mode and per-service permission levels (--permissions gmail:organize), with the requested scopes following from what is enabled; his tiers are motivated by context-window economy and the scope effect follows from that. The three are not one dial: a per-service level supersedes --read-only rather than adding to it — in permissions mode the read-only scope map is never consulted — while the tier keeps filtering underneath it. Hosted commercial services such as mcpemails are left out for lack of any reach data; mcpemails does bulk over the mailbox and has an approval mode in its dashboard, and still no snooze. On the structured-outputs row: taylorwilsdon’s Gmail tools return text, a-bonus declares no output schema, and klodr ships one deliberately — its schema file calls download_email the “first wave” and defers the rest. Google’s MCP reference documents no output schema for its ten Gmail tools; the server is hosted, so the only way to see the real contract is a tools/list against your own project — hence “not documented” rather than a verdict.

On the last row: self-hosting is the common ground, not a mailwarden advantage — every community server here can also be run remotely (mailwarden behind --http, taylorwilsdon over streamable HTTP with OAuth 2.1, a-bonus on Cloud Run; klodr is stdio-only). Running one of them on your own host is not a cloud copy; running it on the vendor’s is.

Why re-verification matters — a concrete case

Ask an assistant to “archive the unread promotions that already scrolled past the inbox” and the obvious query is category:updates is:unread -in:inbox. Gmail applies is:unread here — the same query without it returns several times as many threads — but threads.list, the call any thread search goes through, can apply it against a thread-level read state that has not caught up, so threads you finished with weeks ago still come back as unread. It is that thread view specifically: the same query in the same minute through messages.list showed no stale hits at all, so this is not “Gmail search is unreliable”.

Measured on a real mailbox in August 2026, that query returned 128 threads of which 14 held an unread message. A server that trusts the index would archive all 128 — in a bulk action that is tedious to undo. search re-checks every hit against its real labels and drops the index’s false positives before it returns them; in that same measurement it discarded no genuinely unread mail.

A second mailbox, same day and same method, drifted not at all — and the effect is not tied to exotic operator combinations, since a plain is:unread -in:inbox showed it too. The full measurement — method, all three queries, both controls, and which readings the numbers rule out — is written up separately. The short version of what that piece adds: the behaviour is not new, it was reported to Google once before and closed as not reproducible, and it is still here. That is precisely why search checks every time: a server cannot know which kind of mailbox it has been pointed at. Where nothing drifts, the check costs nothing, because the hits are fetched live either way.

Where it is not free, it does not happen — and the tool says so itself. search can re-verify because it fetches every hit regardless; a bulk label change is sized in thousands of messages, where one fetch per hit is a different order of cost. So bulk_modify and the applyToExisting sweep act on what Gmail hands them, and the result names the gap: unverifiedPredicates lists the conditions from your query that were taken on Gmail’s word, and empty means there was nothing to distrust. A dryRun reports that same list rather than letting a rehearsal pass for verification — it re-reads the same data, so it tells you how large the set is, never whether it is right. If a bulk action has to be read-state-precise, resolve the set with search first and act on those thread ids.

Since 0.18.0 the bulk path can put a cheap net under itself: each predicate derived from the query is asked a second time — through Gmail’s label filter instead of the query parser — and messages the two ways disagree about are left alone. It costs one list call per predicate, not per message, which is why the bulk path can afford it where per-hit re-verification never could. What it is not is a second re-verification: a disagreement is a finding, agreement is none — both ways read the same index, and an index can be consistently wrong. So unverifiedPredicates still says exactly what it said before, and whether the two ways ever diverge in a real mailbox is, as of this writing, unmeasured.

The same honesty applies to what a bulk action reports about itself. Gmail’s batch endpoint answers with no content and passes over ids it does not recognise, so a count taken from the request is the number of ids sent, not the number of messages changed — which is why those fields are named submitted… here. For a mailbox agent a wrong done is worse than an error, because it suppresses the retry that would have repaired it. Setting verify reads the labels back afterwards and splits the outcome into applied, not applied, and unverifiable — an end state that could not be read is deliberately not filed as a failure, because it warrants a second look rather than a blind repeat. It stays opt-in: the read-back costs one extra request per affected thread, and a routine sweep should not pay that silently.

Two limits on how far that carries. The stale read state above was measured on threads.list, while the bulk path queries messages.list, where the same measurement found none — so this is an unverified condition, not a known-wrong one. And it concerns the path query → mass action only: the snooze sweep never takes it, resolving due threads through the exact label instead of a query.

Tools
ToolWhat it does
searchGmail query syntax → thread summaries, read-state predicates re-verified, paginated; every hit carries signals(newsletter, automated, calendar, reply-to mismatch) read from the thread’s first message
get_threadFull thread: headers, plaintext + HTML bodies, attachment metadata, plus what the receiving server concluded about the sender (SPF, DKIM, DMARC). The metadata-only fetch omits the body and attachment fields rather than returning them empty — a missing field cannot be mistaken for “nothing attached”
list_labelsAll labels, system + user
get_profileConnected address + message/thread totals — confirm which mailbox is wired up; also returns the starting point for what_changed
what_changedWhat happened since a point you already hold: arrivals, removals, label changes — in one call instead of a fresh search. Reports events, not state (read then unread appears in both lists), and a starting point older than the week Google keeps comes back as an error, never as an empty result
triage_digestDecision-ready overview of a mailbox slice: top senders, label + age buckets, unread and attachment counts, plus how many threads carry each signal
create_labelCreate a user label (idempotent, nested via Parent/Child) — and set its colour, including on a label that already exists; Gmail accepts only its own palette, so its refusal is passed on rather than second-guessed
modify_labels / bulk_modifyLabel changes for one thread or for every message matching a query — 1000 per API request, partial success reported per chunk; bulk_modify rehearses with dryRun
archive, mark_read, mark_unread, trash, untrashEveryday mailbox actions
download_attachmentSave an attachment locally — confined to MAILWARDEN_DOWNLOAD_DIR when that is set, never overwrites
snooze, unsnooze, list_snoozed, sweep_snoozedArchive now, resurface on a date, date + time or preset; sweep on demand, via cron or hourly daemon — sweep_snoozed answers “what is due?” with dryRun without waking anything
list_unsubscribe, list_subscriptionsWhich opt-out options a thread offers, and a per-sender view of the mailbox — thread and unread counts, the period seen, the options each sender offers. Contacts nobody; one header fetch per sender, not per thread
unsubscribe, bulk_unsubscribeRFC 8058 one-click opt-out via the endpoint from the message’s own header; the bulk form runs sequentially with at most one request per sender — a ceiling the server keeps for its whole run, shared with the single-thread tool — capped at 25 threads and 60 seconds per call, and rehearses with dryRun
list_filters, create_filter, delete_filterServer-side auto-triage rules (criteria → label actions only, never forwarding); optionally apply a new filter to existing mail

Links

  • Source & documentation: github.com/csitte/mailwarden (setup guide, changelog, issues)
  • Package: npmjs.com/package/mailwarden — also registered with the MCP registry as io.github.csitte/mailwarden
  • Claude Desktop extension: current releases carry a mailwarden-<version>.mcpb bundle as an asset — install it under Settings → Extensions → Install extension…. Self-contained at runtime (Desktop brings its own Node, no npx), tool tiers as a setting; the one-off npx -y mailwarden --auth stays.
  • Claude Code plugin: the repository itself is the plugin — claude --plugin-dir <clone> — including two skills: /mailwarden:setup walks through the OAuth setup, starting at mailwarden --check, and /mailwarden:triage carries the rules an assistant needs while working a mailbox, above all that search re-verifies its hits and bulk changes do not. The skills come only with the plugin: an npm or claude mcp add setup gets the tools without them.
  • Smithery: csitte/mailwarden. Mind which of its two paths you take: npx -y @smithery/cli install csitte/mailwarden --client claude writes an ordinary local stdio entry — process, token and mail stay on your machine, exactly as with npx. The toolbox path (smithery mcp add) also starts the bundle locally, but relays the tool traffic through Smithery’s gateway so a remote client can reach it — which routes mailbox content through a third party. That is a property of the gateway, not of mailwarden, and it is the one route on this page that does not run fully local.
  • Directory listings: Glama · mcpservers.org · CodeGuilds

mailwarden is developed and used in daily mailbox automation by C.Sitte Softwaretechnik. Contributions welcome.