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 mailwardenThat first run is npx fetching the package and starting it, and it is kept deliberately light: the server pulls in Gmail’s API client alone, not the client for every Google API, so there is little to download and little to parse before it answers. That is more than comfort. An MCP client gives a server a fixed window to answer the opening handshake, and a server still loading when the window closes is indistinguishable from a broken one — what the user is told is “cannot connect”, with nothing said about time.
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, how it is encrypted and whether this machine holds its key, 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.
Since 0.22.0 the scope half of that check no longer waits to be asked. A running server reports the same gap by itself: one line on stderr at startup naming the missing scope, what it covers and what the token holds instead — and, on a call Google refuses, an insufficient_scope error that names the gap rather than listing every scope that might be the one. The check is not new; who triggers it is — --check a person, these two the server on its own. The case they are built for is the token encrypted with a passphrase: the tier gating done at registration reads the token synchronously and never decrypts, so such a deployment advertises its full tier surface and would otherwise meet the gap at the first refusal. Both new paths read asynchronously and decrypt where MAILWARDEN_TOKEN_PASSPHRASE is set. A token sealed with a key from the OS keystore — the default, see below — needs neither: its granted scopes sit readable beside the ciphertext, bound into the authentication tag so that an edit fails at the next decryption, and registration hides the filters tier there just as it does for an unencrypted token.
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 withrate_limitedrather 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 that acts on a query takes
dryRun: trueand 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 saysdryRunrather than claiming an outcome. Forbulk_modifythe rehearsal also binds the write: a dry run that matches anything returns apreviewToken, and the write that hands it back acts on exactly the previewed set — mail that started matching since is left alone and counted, and a reworded query or a different label change is refused before anything is written. The token works once, for 30 minutes, and lives in memory only. The everyday actions take an explicit list too — up to 100 threads, for “exactly these, as approved” — and report per thread what changed, what failed and what was not attempted; that list form has no dry run, because there is no query to resolve. - 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 a filter cannot open an exfiltration path through the server either.
- 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_digesttool 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 flags —
newsletter,automated,calendar,replyToMismatch— so an assistant can decide without opening the mail. These rest on a documented header convention or a MIME fact, never on a guess from the wording. Beside them sit phishing signals, fixed rules over theFromline:displayNameSpoofwhen the display name shows an address or a link on a different domain than the one that sent the mail, andlookalikeDomainwhen a label of the sender’s domain mixes Latin with Cyrillic, Greek or Armenian letters. The first is a reason to look, not a verdict: some relays write the original sender into the name. All of them are read from the fetchsearchmakes anyway — no extra API call, available in the read-only tier, and counted bytriage_digesttoo. From the same fetch each hit also names the thread’s last message — when, from whom, whether it was yours — whether the thread is unread, and its attachments by name. - Several mailboxes, one install.
mailwarden --auth --account workstores its owntoken.work.json, and a server entry claims it withMAILWARDEN_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,--authstops 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, and bodies decoded as what their bytes are rather than only what their label claims: Gmail can hand over a part already converted to UTF-8 while its header still says
iso-8859-1, and decoding by the label then garbles every accented letter. So bytes that are valid UTF-8 are read as UTF-8; everything else goes by the declared charset — genuine windows-1252 with its euro sign and typographic quotes, and 7-bit charsets that plain ASCII bytes stand for. Brief throttling is retried with exponential backoff. An exhausted per-minute quota gets one more try after about twenty seconds and then an honestrate_limitedinstead 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
readtier Google itself enforces that:gmail.readonlyis refused by the send endpoints, so areaddeployment 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 stolengmail.modifytoken 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_TOOLSadvertises 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 thangmail.readonly.MAILWARDEN_READONLY=1is 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.readonlyin the read tier,gmail.modifyat its widest, plus the filters scope where that tier is enabled.modifyis 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. And since 25.10.2026 the table carries a server that answers it the way mailwarden does: gmail-tidy holds the samegmail.modifycredential and never wrote a send tool either. - Token encryption at rest, by default.
--authseals the stored OAuth token with AES-256-GCM under a random 256-bit key kept in the OS keystore — Windows Credential Manager, the macOS Keychain, the Secret Service on Linux — one key per token file, filed under a random id rather than the account name. The key does not travel with the file, so a copied or synced token file is useless anywhere else. That holds for a copy you make on purpose too: each machine and user runs its own--auth, and replacing a file whose key is not on this machine takes--auth --force, because the overwrite guard cannot read it to confirm whose token it is.MAILWARDEN_TOKEN_PASSPHRASEstill works and takes precedence over the keystore;MAILWARDEN_KEYSTORE=offkeeps new tokens out of it. Where neither is available — a Linux server without a desktop session, for instance — the token is stored unencrypted, and--authand--checksay so and why. On Linux the keystore is the Secret Service or nothing: the fallback the binding would otherwise take, the kernel keyring, lives in memory, and a key kept there would be gone after the next reboot. An existing unencrypted token keeps loading and is never rewritten unasked; the next--authencrypts it. What this protects against is a copied file, not code running as the same user, which can ask the keystore exactly as mailwarden does. Tried end to end against Windows Credential Manager; the macOS Keychain is not yet tested — in particular whether it shows an access prompt that a background server cannot answer. - The mailbox is not a tool parameter. No tool takes an account argument. Which mailbox an instance serves is fixed by
MAILWARDEN_ACCOUNTin 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. Where a body is reduced from HTML to text, a script, style block or comment left unclosed hides the rest, as a browser would — the model does not get to read script text that no human sees in the mail.
- Attachment downloads are fenced — once you set the fence.
download_attachmenttakes its destination path from the client. WithMAILWARDEN_DOWNLOAD_DIRset, 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--httpstart 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 thereadtier, which never registers the tool at all. Reading the text of an attachment needs no fence:read_attachmentworks in memory and writes nothing to disk. - What the receiving server concluded about the sender — passed on, not judged.
get_threadreports the SPF, DKIM and DMARC results from the message’s ownAuthentication-Resultsheader: 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 fetch and used to be discarded. That includes the lean one:get_threadwithbodies: "none"leaves the bodies out but keeps the verdict and the real attachment list, so checking who sent a message costs no body. The olderfull: falsekeeps the verdict too, cannot carry the attachment list, and is deprecated. Because that text is written by machines an attacker may control, three limits apply: only the topmost report that Gmail itself issued is read (the one whose issuer ismx.google.com; a report under any other name is never read, however convincing, only counted inotherReportsrather than hidden), every value is validated as a token or dropped, and a message with no report from Gmail is markedunchecked, even when it carries one under another name, so “nobody looked” can never read as “nothing found”. That holds since 0.23.0 (13.10.2026); until 0.22.1 the first report was read whoever had issued it, and a forged one in a message Gmail had not judged was taken for the result. Before the change mailwarden measured two mailboxes: every received message’s first report came frommx.google.com, so no genuine result is lost. The limit that remains, named in its README and security notes and not measured: mail that never came through Gmail’s inbound servers — fetched by POP from another account, or inserted by an app — can lack a Gmail report, and a forged one claiming to bemx.google.comis then read. Since 0.23.0 each message also says whether a passing DKIM signature belongs to theFromdomain (dkimAligned: the same domain, or one inside the other). For sibling subdomains such asnews.bank.exampleandmail.bank.examplethe field is left out on purpose: without the public suffix list they cannot be told apart froma.co.ukandb.co.uk, so a missing field is not a mismatch. 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 originalFrom. 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_changedtoo, 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-Unsubscribeheader 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 barehttps:link is handed back rather than fetched, andmailto: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. Areaddeployment never makes this request at all. Since 0.18.0 a second kind of link can come back: where a message carries noList-Unsubscribeheader 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. - A human yes, where the client can ask for one. With
MAILWARDEN_CONFIRMset, creating a filter and unsubscribing — single or bulk — first ask the person at the keyboard, through the client’s own dialog (MCP elicitation). That answer never passes through the model, so a yes that an injected mail talks the assistant into does not count; a no ends the call withdeclined_by_userand nothing done. The dialog shows the rule, or the host each opt-out request goes to. The two modes promise different things:askasks wherever the client can show a dialog and goes ahead without asking where it cannot — a convenience, not a guarantee. Onlyrequireguarantees the question, and refuses the action where no dialog is possible. Label changes never ask, since they can be undone. Off by default. - What none of this covers: the rest of the assistant. Everything above is a property of this server, and that is where the promise ends. The case worth thinking about is not the compromised client but the ordinary one: an assistant that can also fetch a URL, write into a synced folder, run code or call a second MCP server already has every route an injected mail would need — and mailwarden is what puts that mail in front of it. Stated narrowly the promise holds without an asterisk: no tool here carries mail outward, and an injected instruction finds no endpoint here. What the assistant does as a whole with what it has read is a property of that assistant — worth keeping in mind when choosing what else sits in the same session.
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.
Dependencies are audited, and an audit result is a date. The release of 27 October 2026 closed five advisories in the runtime tree. Two were rated high: zod, whose length checks could exhaust memory — low exposure here, since tool input comes from the MCP client — and body-parser, reachable only through --http. Of the three moderate ones, two also need --http and the third sits in a function mailwarden never calls. As released, an audit of the runtime tree reports nothing. The round before, on 11 October, closed four, all reached through the MCP SDK — one critical (proxy-addr, IP spoofing via a trusted proxy subnet), one high (the SDK’s OAuth client could be steered to a foreign authorization server), two moderate. Neither of the serious two had a path here: mailwarden is an MCP server and never creates the SDK’s OAuth client, and proxy-addr only matters once Express is told to trust a proxy, which --http never does.
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.
| Capability | mailwarden | Google official | taylorwilsdon | a-bonus | klodr | aaronsb | agentcomms | gmail-tidy |
| Mailbox-side snooze | ✓ | — | — | — | — | — | — | — |
| Search-result re-verification | ✓ | — | — | — | — | — | — | — |
| Unsubscribe (per-sender + one-click) | ✓ | — | header shown, no action | — | — | — | — | link shown, no action |
| Sweep / bulk over a query | ✓; a preview can bind the write to the counted set, no re-check | — | batch by explicit ids; filter apply relabels over a query, no re-check | — | batch by explicit ids | labels over a query after a preview; applies exactly the counted set, no re-check | batch by explicit ids, exact undo per message | labels over a query, up to 5,000; no preview, no re-check |
| Inbox triage overview | by sender, label, age + header signals | — | — | content heuristics (newsletter, meeting, action) | — | unread inbox, sender + subject + date | — | sender counts + mailing-list overview |
| Server-side filters | ✓ (never a forwarding rule) | — | ✓ | — | ✓ (never a forwarding rule) | ✓ after a preview; forwarding allowed unless draft-only | — | ✓ (labels only: never forwards, never trashes) |
| No send tools (by design) | ✓ | draft only | sends | sends | sends | sends; blockable, opt-in | sends; approval per message (a yes in the chat by default), or never | draft only |
| Least-privilege tool tiers | tier → scopes; gap named at start + on the call | partial | tiers, read-only, per-service levels or a union of them | — | scopes → tools | granted scopes → tools, per call | tier per mailbox → scopes; writes checked against the grant | fixed scopes, checked against the grant at sign-in; no tiers |
| Sender authentication reported (SPF/DKIM/DMARC) | ✓ | — | — | — | — | — | Google’s own report only, DKIM alignment | failures flagged from the first report, whoever issued it |
| Token encryption at rest | by default; key in the OS keystore, else a passphrase you set | n/a | file mode / CMEK | — | — | file mode | OS keychain; owner-only file on request | by default; key in the OS keychain |
| Structured outputs (schema per tool) | ✓ every tool | documented per tool in its reference | — | — | two tools | — | ✓ all 45 tools | — |
| 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. Reading them and saying what the result rests on — which Authentication-Results report is the receiving server’s, and that any other can be forged — was mailwarden’s alone when it came, and is no longer: since 19.09.2026 agentcomms does it too. Both now read only the topmost report issued by mx.google.com, count the others and ignore them, and say whether the DKIM signature is aligned with the From domain — agentcomms from the start, mailwarden since 0.23.0 of 13.10.2026; before that mailwarden read the first report whoever had issued it. The agentcomms column (@agentcomms/gmail-mcp, MIT) is new on 13.10.2026, read at 01cee751 — every cell checked against its source here, not taken from the report that brought it: the 45 tools and their output schemas in packages/gmail/src/mcp/server.ts, the verdict in domain/auth-results.ts, the tiers in auth/scopes.ts, the send policy (chat, confirm, never; chat by default) and the keychain in packages/core. Its bulk tool takes message or thread ids, not a query, and can undo each change exactly — the idea behind mailwarden’s own exact undo since 0.23.0. Its sending sits between the others’: every message needs an approval, and by default that approval is a yes in the chat — which, as its own README says, the server cannot tell apart from a yes the agent wrote itself; a stricter confirm mode asks for a code typed outside the agent. 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. On 10.10.2026 taylorwilsdon’s column was read again, at v2.1.0 (7897304), and one cell moved against us: since his v1.30.0 of 28.09.2026 the apply action of his filter tool turns filter criteria — including a free query — into a Gmail search and relabels every hit in batches. It changes labels only, per message, and sends the ids on without looking at them again, so the re-verification row stays empty for him. On 25.10.2026 aaronsb was read again, at 9c9f8033, and two of his cells moved, both since 16.10.2026. His new modifyMatching changes labels over everything a query matches: a preview counts the set (at most 10,000), the server holds the ids and hands back a token valid once and for 30 minutes, and the apply changes exactly that held set — beyond a sample, no id passes through the conversation, and no hit is checked again. mailwarden’s bulk_modify has taken the same idea since 27.10.2026, with the same limit: the preview reads the same index as the write, so our cell gained the binding and still says no re-check. His filters arrived the same day, behind the same kind of preview token and a new scope (gmail.settings.basic); a filter may forward mail, and only his draft-only policy refuses that. klodr, read at e5c080c3, now refuses a forwarding filter always, whatever scopes were granted (since 19.10.2026), and declares a second output schema — for download_all_attachments, since 25.09.2026; our cell still said one tool, a month behind his repository. Since 23.10.2026 taylorwilsdon’s per-service levels can also be named as a union (gmail:readonly+send), which requests only the scopes of the levels named instead of everything below the highest. The gmail-tidy column (gmail-tidy-mcp, MIT) is new on 25.10.2026, read at 6df48c69 — every cell checked against its source here, not taken from the report that brought it: tools and labels in src/gmail.js, scopes in src/auth.js, the risk notes in src/security.js, the token in src/store.js. Its promise is the one closest to ours: no send tool and no trash, kept by the code and not by a setting — drafts yes, sending never, and nothing may add the Trash or Spam label, filters included. Like ours its credential is gmail.modify, and its README says plainly that the token itself could send. Its bulk tool relabels over a query without a preview; a local log of every change lets you undo by hand, not exactly. Its sender check looks for failed SPF, DKIM or DMARC in the first Authentication-Results header without asking which server wrote it — what mailwarden did until 0.23.0. On the token row it was ahead of us when it came: encrypted by default, with the key in the macOS Keychain, the Linux Secret Service or a DPAPI-protected file on Windows. Since 28.10.2026 mailwarden does the same, with the design taken from gmail-tidy and saying so in its README; the one difference is Windows, where mailwarden keeps the key in the Credential Manager rather than in a DPAPI-protected file. The token encryption row asks who holds the key, which a tick cannot express: ours is the OS keystore by default, a key derived from MAILWARDEN_TOKEN_PASSPHRASE where that is set, and an unencrypted file only where neither is available, with a line saying so. taylorwilsdon stores the credential unencrypted at file mode 0600 locally, or hands the key to the bucket’s CMEK on GCS — both defensible, neither encryption the server does itself. 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 — aaronsb, added 08.09.2026, the first other column here that can be made unable to send, and agentcomms, added 13.10.2026, the other server that reads the sender verdict, which can be made unable to send as well (its never policy), and gmail-tidy, added 25.10.2026, which like mailwarden never wrote a send tool at all. 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, and it refuses before the request leaves. Since 0.22.0 mailwarden names that same gap in three places: the filters tier is never registered when the stored token is known to lack its scope, a startup line on stderr says which scope is missing, and a call Google refuses comes back as insufficient_scope naming the gap. What still differs is the order — his prevents the call, ours explains the refusal. What neither does is ask Google at run time: both read the scopes recorded when consent was given (his per account in the credential file, ours from the token — in the clear beside the ciphertext when a keystore key sealed it, decrypted where a passphrase did), so a permission withdrawn at Google afterwards surfaces on either side as a 403 and not before. 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, gmail-tidy neither, and klodr ships schemas deliberately one wave at a time — its schema file calls download_email the “first wave”, and download_all_attachments has followed it. Google’s cell said “not documented” until 13.10.2026, and that was our mistake: each of the eleven tool pages in Google’s MCP reference carries an “Output Schema” section and tool annotations, and they were already there at our 03.09 reading (last updated 21.07.2026). We counted ten tools then; create_label was missing. The cell says “documented” because it rests on the reference, not on a live tools/list — the server is hosted, so only a call against your own project shows the contract it actually serves.
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 and gmail-tidy are 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 of create_filter both act on what Gmail hands them. For bulk_modify 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. The sweep carries no such field, and deliberately so: its query is assembled from the filter criteria, which come out quoted and parenthesised, and the derivation gives up on both — so the list would come back empty, saying “nothing to distrust”, for criteria that do carry is:unread. An empty list that really means “could not tell” is worse than no list, so there the caveat sits in the tool’s own description, where it holds without exception. 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 bulk_modify 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. The filter sweep takes the same option and splits its outcome the same three ways — and there an unreadable end state is emphatically not a failure: the rule is created before the sweep runs, so a problem with the checking must not come back looking like a filter that was never made.
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 | |
| Tool | What it does |
search | Gmail query syntax → thread summaries, read-state predicates re-verified, paginated; every hit carries signals (newsletter, automated, calendar, reply-to mismatch, display-name spoof, lookalike domain) read from the thread’s first message, plus date, sender and read state of its last message and its attachments by name |
get_thread | Full thread: headers, plaintext + HTML bodies, attachment metadata, plus what the receiving server concluded about the sender (SPF, DKIM, DMARC). A long thread can be shrunk instead of skipped: text only, only what is new in each reply, or no bodies at all; the newest messages only, or those after one already read. messageCount and omittedMessages say what the selection left out, so a partial view never passes for the whole thread. Only the quoted history at the end of a reply is cut — inline answers and forwards stay whole. The deprecated metadata-only fetch omits the body and attachment fields rather than returning them empty — a missing field cannot be mistaken for “nothing attached” |
list_labels | All labels, system + user |
get_profile | Connected address + message/thread totals — confirm which mailbox is wired up; also returns the starting point for what_changed, and which build is answering (serverVersion, serverPath — the install directory usually contains your local user name and goes to the AI client) |
what_changed | What 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_digest | Decision-ready overview of a mailbox slice: top senders, label + age buckets, unread and attachment counts, plus how many threads carry each signal |
create_label | Create 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_modify | Label 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, whose previewToken binds the write to exactly the previewed set, and with undo: true reads every affected message’s labels before writing and returns the exact inverse per message (up to 500 messages; if the plan cannot be built, nothing is written) |
undo_bulk_modify | Replays that plan: what each message lost goes back, what it gained comes off, and a message that was already in the target state stays untouched — which swapping add and remove would not do |
archive, mark_read, mark_unread, trash, untrash | Everyday mailbox actions; archive, the two read-state tools and modify_labels also take an explicit list of up to 100 threads and report per thread what changed, failed or was not attempted |
follow_ups | Who is waiting on whom: your own messages left unanswered, and inbox threads whose last message came from a person — decided on that message’s live labels, not on the search index; notes to yourself and machine mail do not count |
export_thread | A thread as a Markdown or JSON file — senders, subjects and file names as code and each message body in a fence the mail text cannot close, so a preview makes no link or tracking image of them; same path rules as download_attachment |
download_attachment | Save an attachment locally — confined to MAILWARDEN_DOWNLOAD_DIR when that is set, never overwrites |
read_attachment | Read the text in an attachment without saving it — plain text, gzip, text files inside a ZIP (the way DMARC reports arrive). The format is decided from the bytes, never from the name or declared type; binary formats are refused, unpacking stops at a size limit whatever the archive declares, and the text is fenced and cleaned like a mail body |
snooze, unsnooze, list_snoozed, sweep_snoozed | Archive 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_subscriptions | Which 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_unsubscribe | RFC 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, preview_filter, create_filter, delete_filter | Server-side auto-triage rules (criteria → label actions only, never forwarding); optionally apply a new filter to existing mail, with the same opt-in read-back check as bulk_modify. preview_filter answers first, writing nothing: how many messages the criteria match today, which existing filters have the same criteria or share a field with them (a forwarding filter it overlaps included), which labels would be created, and whether the rule would send every future match to the trash |
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>.mcpbbundle as an asset — install it under Settings → Extensions → Install extension…. Self-contained at runtime (Desktop brings its own Node, nonpx), tool tiers as a setting; the one-offnpx -y mailwarden --authstays. - Claude Code plugin: the repository itself is the plugin —
claude --plugin-dir <clone>— including two skills:/mailwarden:setupwalks through the OAuth setup, starting atmailwarden --check, and/mailwarden:triagecarries 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 orclaude mcp addsetup gets the tools without them. - Directory listings: Smithery · Glama · mcpservers.org · CodeGuilds
mailwarden is developed and used in daily mailbox automation by C.Sitte Softwaretechnik. Contributions welcome.
