
Product
Socket Now Protects the Firefox Extension Ecosystem
Socket is bringing experimental protection to Firefox, scanning 97,000+ extensions in Mozilla's official directory for malware and risky updates.
mailwarden
Advanced tools
A reliable, native Gmail MCP server with full mailbox control — search, labels, archive, trash, attachments, and snooze.
A reliable, native Gmail MCP server — full mailbox triage for AI assistants, with the feature no other Gmail MCP server ships: mailbox-side snooze.
threads.list — the call any thread search goes through — can
answer is:unread from a stale thread-level read state: measured in one real mailbox, 86% of
the threads it returned held no unread message at all; in a second mailbox, no drift whatsoever.
You cannot tell which mailbox you are in without looking, so search re-verifies every hit against
its live labels. Paginated via pageToken/nextPageToken.bulk_modify archives/labels everything matching a query at
1000 messages per API request — with per-chunk partial-success reporting instead of
all-or-nothing. The snooze sweep uses the same batch path.outputSchema and returns validated
structuredContent alongside fenced JSON text — no parsing guesswork for clients.unsubscribe / bulk_unsubscribe (manage
tier) contact the opt-out endpoint named in a message's own header — the only non-Google host
mailwarden ever reaches, and a read-tier deployment makes no outbound request at all. Details under
Security & privacy and
Unsubscribing.=?UTF-8?B?…?= → readable text),
bodies decoded in their declared charset (no mojibake for ISO-8859-1/Shift_JIS mail),
429/5xx retried with exponential backoff.Connectors that sync or cache your mailbox can lag behind it — and even Gmail's own search index is sometimes loose (see below). mailwarden talks straight to the live Gmail API (no cached snapshot) and re-verifies what the index returns, so what you see is what's actually there. It's a generic Gmail capability layer — keep your own rules/logic in your AI client, not in the server.
search goes one step further than the raw API: Gmail's threads.list index can answer read-state operators from a stale copy of that state, so is:unread returns threads you finished reading weeks ago — in one measured mailbox, the large majority of what came back. Since every hit is fetched live anyway, search re-checks the unambiguous predicates (is:unread/is:read/is:starred/in:inbox/category:…, with negation) against each thread's true labels and drops the index's false positives.
Most Gmail MCP servers cover the same read/label/send surface. Two capabilities are still unique to mailwarden (mailbox-side snooze, search re-verification), and one deliberate omission is a security feature, not a gap. Google's own server is also narrower than it looks: draft-only, and no trash, filters or unsubscribe.
| Capability | mailwarden | Google official | taylorwilsdon | a-bonus | klodr |
|---|---|---|---|---|---|
| Mailbox-side snooze — archive now, resurface in the inbox on a date/time or preset | ✅ | — | — | — | — |
| Search-result re-verification — drops the thread index's false positives against live labels | ✅ | — | — | — | — |
| Sweep / bulk over a query — one action across every thread a search returns | ✅ 1000/req, partial-success | — | ⚠️ batch by explicit ids | — | ⚠️ batch by explicit ids |
| Unsubscribe — per-sender overview + RFC 8058 one-click opt-out, no send scope needed | ✅ | — | ⚠️ header shown, no action | — | — |
| Inbox triage overview — one call that buckets what is waiting | ✅ sender/label/age + header signals | — | — | ✅ heuristic flags + stats | — |
| Server-side filters — rules that keep triaging with no assistant in the loop | ✅ never forwarding | — | ✅ | — | ✅ |
| No send tools — by design — a prompt-injected mail has no exfiltration path | ✅ no compose at all | ⚠️ draft-only | ❌ sends | ❌ sends | ❌ sends |
| Least-privilege tool tiers — OAuth scopes derived from the tools you enable | ✅ | ⚠️ scope split | — | — | ⚠️ inverse: tools gated by granted scopes |
| Token encryption at rest (optional) | ✅ AES-256-GCM | n/a (hosted) | ✅ | — | — |
| No vendor cloud — you operate the server | ✅ | ❌ Google-hosted | ✅ | ✅ | ✅ |
Structured outputs — every tool declares an outputSchema | ✅ | — | — | — | — |
Snapshot as of 16 August 2026, from each project's public docs and source; — = not offered / not documented. Columns are the servers a reader is most likely to reach for — Google's first-party one, plus the two largest community servers — and klodr, which comes closest to mailwarden's own least-privilege design. Send capability is listed as a security property: mailwarden's lack of it is intentional (see Security & privacy). The last row asks who operates the server, not where it happens to run: self-hosting is common ground here, and every community server on this table offers some remote deployment except klodr (stdio only) — mailwarden via --http, taylorwilsdon over streamable HTTP with OAuth 2.1, a-bonus on Cloud Run. Running one of them on your own host is not a cloud copy; running it on the vendor's is.
The moat isn't any single row — it's snooze + live re-verification together: an actual inbox-workflow layer that acts on the mailbox's current state, not a cached snapshot. Where others have caught up it's noted honestly above: at-rest encryption (taylorwilsdon), scope-driven tool gating (klodr), a richer per-message triage heuristic (a-bonus), and bulk organize over a mailbox (the hosted mcpemails.com, which has no snooze either). What none of them do is act on a query and check the mailbox's answer before acting on it.
Ask an assistant to "archive the unread promotional mail that's already skipped my inbox" and it will reach for the obvious query, category:updates is:unread -in:inbox. A server that trusts Gmail's index now archives threads you had already read — mail you never meant to touch, gone in a bulk action you can't easily reverse.
Measured, not asserted. One real mailbox (~70,000 messages), 15.08.2026, read-only:
Query (threads.list) | Threads returned | With an unread message | Stale |
|---|---|---|---|
category:updates is:unread | 131 | 17 | 87% |
category:updates is:unread -in:inbox | 128 | 14 | 89% |
is:unread -in:inbox | 235 | 99 | 58% |
The index is not ignoring the predicate — the same query without is:unread returns 800+ threads, so it is being applied. It is applied against a thread-level read state that has not caught up: threads whose every message is read still count as unread there. One returned thread carried a single label, SENT. And it is not a quirk of exotic operator combinations: the plainest query of the three shows it too — with the lowest share (58%) but the most wrong threads in absolute terms (136).
It is the thread index specifically. The same query, same mailbox, same minute, asked through messages.list instead: 19 messages, none stale. So this is not "Gmail search is unreliable" — it is that the thread view of read state lags while the per-message view does not. search goes through threads.list, which is exactly why it re-verifies.
A second mailbox, measured the same way on the same day, drifted not at all — zero raw-index hits for is:unread, although it is read-marked through the API many times a day. So this is a property of a mailbox, not of Gmail everywhere. What separates them is open: they differ in volume (roughly three orders of magnitude) and age, and the second is missing something more basic — no thread in it was ever archived while still unread, which is the only shape a stale read-state can show up on. So it is not a counter-example to any particular cause; it is a mailbox without the candidate.
Which is the whole point: a server cannot know which kind of mailbox it is in. Re-verification costs nothing where nothing drifts, and saves you where it does — in the measurement above, every thread search dropped was genuinely read, and it discarded no genuinely unread mail.
Where it is not free: the bulk tools. search re-verifies because it fetches every hit anyway; bulk_modify (and create_filter's applyToExisting sweep) is sized in thousands of messages, where one fetch per hit is a different order of cost. Those act on what the index returns — so they now report unverifiedPredicates, the conditions from your query that were taken on the index's word (+UNREAD, -INBOX, …). Empty means there was nothing to distrust. Non-empty and the result has to be read-state-precise? Resolve the set with search first and act on those thread ids. A dryRun does not close this gap: it re-reads the same index, so it confirms how big the set is, never whether it is right.
mailwarden fetches every hit live anyway, so search re-checks the unambiguous predicates (is:unread, is:read, in:inbox, category:…, with negation) against each thread's true labels and drops the index's false positives before any tool sees them. The bulk action then runs on exactly the set you asked for. This is the difference between acting on what Gmail indexed and acting on what's actually in the mailbox right now — and it's why snooze/sweep are safe to hand to an assistant: the sweep resurfaces only threads whose snooze is genuinely due, verified against live labels at run time.
See it yourself — no Gmail account needed. From a clone of the repo (the demo is a repo-only verification script, not part of the npm package):
git clone https://github.com/csitte/mailwarden && cd mailwarden
npm install && npm run build
node scripts/demo-reverify.mjs
There is a second script next to it, node scripts/probe-reverify.mjs, which measures the same thing in your mailbox instead of a fake one — read-only, metadata only (no subject, sender or body is fetched), printing counts and label names. It is how the numbers above were produced, and how you can check whether your mailbox drifts at all.
The demo drives the real search() against a fake Gmail API whose index is deliberately stale (returns a read thread for an is:unread query, exactly as Gmail does) and shows mailwarden dropping the false positive. It asserts the outcome, so it exits non-zero if the behavior ever regresses. The same case is locked by unit tests in test/gmail.test.ts ("drops index false positives via live-label re-verify").
| Tool | What it does |
|---|---|
search | Gmail query syntax → thread summaries (from/subject/date/labels/snippet); read-state/category predicates are re-verified against each hit's live labels; paginated via pageToken/nextPageToken. Each hit carries signals — newsletter (List-Id / List-Unsubscribe / Precedence bulk or list), automated (Auto-Submitted, auto-reply/suppress headers, no-reply-style senders), calendar (text/calendar or .ics part), replyToMismatch (Reply-To on another domain than From; a subdomain of the same domain counts as the same) — read off the first message's headers/MIME, no extra call. Spam and trash are excluded unless the query says in:spam / in:trash — see Looking in spam |
get_thread | Full thread: headers, plaintext + HTML bodies, attachment metadata |
list_labels | All labels (system + user) |
get_profile | Connected account's address + total message/thread counts — confirm which mailbox is wired up before acting |
triage_digest | Structured overview of a mailbox slice for decisions: top senders (each with the signals its threads carry), label and age buckets, unread + attachment counts, and how many threads are newsletters / automated / calendar invites / reply-to mismatches — instead of a raw thread list |
list_unsubscribe | What opt-out options a thread advertises (List-Unsubscribe) — contacts nobody |
list_subscriptions | A mailbox slice grouped by sender: thread/unread counts, the date span each was seen over, and each one's opt-out options — one header fetch per sender, contacts nobody. sendersFound reports how many senders there were before topN truncated the list |
create_label | Create a user label (idempotent; nested via Parent/Child) and return its id |
modify_labels | Add/remove labels by name or id — an unknown name in add is auto-created (archive = remove INBOX, read = remove UNREAD) |
bulk_modify | Batch label changes for every message matching a query — 1000 messages per API request, partial success reported per chunk (thread-id list capped at 500, modifiedThreadCount has the total). Acts on the raw index, so unverifiedPredicates names the conditions it could not vouch for (see below). dryRun: true resolves the query and reports the matched threads and the labels it would create, touching nothing |
archive / mark_read / mark_unread | Convenience wrappers |
trash / untrash | Move to / restore from Trash |
download_attachment | Save an attachment to a local path (never overwrites — collisions get a numeric suffix) |
unsubscribe | One-click opt-out (RFC 8058) using the endpoint from the message's own header — the only tool that contacts a non-Google host (details) |
bulk_unsubscribe | The same for several threads, sequentially and at most one request per sender; partial success reported per thread. dryRun: true runs the same header reads and dedupe and reports the endpoint each thread wouldCall — contacting nobody |
snooze | Archive now, resurface on/after a date (YYYY-MM-DD), a date+time (2026-06-20 9am), or a preset (tomorrow, tomorrow 9am, weekend, next week, a weekday name, in N days, in N hours) |
unsnooze | Cancel a snooze, return to inbox now |
list_snoozed | All snoozed threads + due dates |
sweep_snoozed | Resurface threads whose snooze is due (run on demand, via cron, or the daemon); batched, with partial-failure reporting. dryRun: true answers "what is due right now?" (dueLabels/dueThreads) without waking anything |
list_filters | All Gmail filters (criteria + label actions); surfaces any forward address on existing filters for auditing |
create_filter | Create a server-side auto-triage rule (criteria → label actions only; no forwarding — see below). Optionally applyToExisting to also sweep matching mail already in the mailbox |
delete_filter | Delete a filter by id |
All tools declare an outputSchema and return structured content (validated, machine-readable)
alongside the same JSON as fenced text — clients never have to parse prose.
snooze removes INBOX and applies a dated label MCP/Snoozed/<key>, where the key is either YYYY-MM-DD (due all day) or YYYY-MM-DDTHHMM (due at that local minute). The until argument takes an explicit date, a date+time (2026-06-20 9am, …T17:00), or a preset resolved server-side — today, tomorrow, weekend (next Saturday), next week (next Monday), a weekday name (monday–sunday, next occurrence), in N days, or in N hours — and a date preset may carry a trailing time (tomorrow 9am, monday 8:30), so the caller never has to compute the moment itself. sweep_snoozed finds due labels and returns those threads to the inbox (marked unread); a timed snooze wakes at the first sweep on/after its minute, so wake latency equals your sweep interval. Run the sweep:
sweep_snoozed tool),mailwarden --sweep,MAILWARDEN_AUTO_SWEEP=1 (hourly sweep while the server runs).create_filter sets up a Gmail server-side rule: mail matching the criteria automatically gets the
given label actions — the mailbox keeps triaging itself with no assistant in the loop.
from, to, subject, query (full Gmail search syntax), negatedQuery,
hasAttachment, excludeChats, and size + sizeComparison (smaller/larger, given together).
At least one is required.addLabels / removeLabels, by name or id (an unknown name in
addLabels is auto-created, nested via /). Common recipes: skip the inbox → removeLabels: ["INBOX"];
auto-mark-read → removeLabels: ["UNREAD"]; auto-trash → addLabels: ["TRASH"];
star → addLabels: ["STARRED"]; never-spam → removeLabels: ["SPAM"]; file under a label → addLabels: ["Receipts"].applyToExisting: true to also apply the same actions once to mail already in the mailbox —
mailwarden builds a Gmail search from the criteria and runs a bulk modify (up to maxMessages,
default 1000; same unverified-index caveat as bulk_modify, and the one-off pass excludes Spam/Trash).
This requires at least one positive criterion (from/to/subject/query/hasAttachment:true/size):
an exclusion-only rule (negatedQuery or hasAttachment:false) is refused for applyToExisting
because it would match almost the whole mailbox — create such a filter without the flag.
The outcome comes back under applied (the query used, matchedMessages/modifiedMessages/modifiedThreadCount
counts, capped when the match set hit maxMessages, per-chunk failed, and an error string if the whole
pass failed); it's null when applyToExisting was not set. The filter is created first, so a partial or
failed backlog pass is reported in applied, never raised — the rule still stands.gmail.settings.basic scope; re-run --auth once if you authorized an older version.
Not available in read-only mode.list_unsubscribe (read tier) reports what the sender offers, without contacting anyone. It reads the
newest message that actually carries a List-Unsubscribe header — a reply threaded onto a newsletter
sits at the end and advertises nothing, which would otherwise read as "this list has no opt-out".
list_subscriptions (read tier) does the same across a whole slice, grouped by sender, so you can see
who keeps writing and which of them can actually be left — one header fetch per sender rather than
per thread. unsubscribe and bulk_unsubscribe (manage tier) act on it — and that is the only
place mailwarden ever talks to a host that isn't Google, so the rules are tight:
List-Unsubscribe-Post.
A plain https: link is meant for a human in a browser and is handed back, not fetched.mailto: opt-outs are never performed. They would require sending mail, which mailwarden cannot
do. The address is reported so you can act on it yourself.List-Unsubscribe=One-Click and is
never derived from anything; the response body is cancelled unread. What returns to the model is the
status code and the URL actually called — no content from the endpoint, so it cannot answer with
instructions. (A 301/302/303 redirect is followed as a GET, i.e. with no body at all.)bulk_unsubscribe takes thread ids
(never a query — a query-driven bulk would fire off a request per matched sender before anyone had
looked). Threads from a sender whose request already went out are reported with duplicateOf and
cost no second request: two threads from one list share an opt-out, and calling it twice only
confirms your address twice. A sender is only recorded once a request actually reached an
endpoint, so a refusal or a dropped connection still leaves the next thread its own try — and if
the skipped thread advertises a different endpoint, the reason says so, since one sender can run
several lists. Capped at 25 threads and 60 seconds per call; whatever the budget doesn't cover comes
back as skippedOutOfTime rather than silently undone. None of it can be reversed, which is why all
three limits exist.::1 and 0:0:0:0:0:0:0:1 alike); an
address that does not parse is refused. DNS resolution and all hops share one 10-second budget. Not
rebinding-proof (fetch resolves again when it connects) — see SECURITY.md; what
survives that gap is a blind POST whose response is never read.Check it against your own mail before you trust it. From a repo clone (repo-only, not in the
npm package), after npm run build and mailwarden --auth:
node scripts/probe-unsubscribe.mjs --vet # category:promotions, 25 threads
node scripts/probe-unsubscribe.mjs "from:substack.com" --max 50 --vet
It prints each real List-Unsubscribe header next to what the parser made of it, and --vet also
runs the endpoint through the URL vetting and the address guard — so you see both whether the parser
understood the header and whether the guards would have let that opt-out through. Strictly
read-only: no request is ever made to a sender, and nothing in the mailbox changes.
What it can't undo: the request tells the sender your address is live. A sender that ignores its own
opt-out is beyond any client's reach — pair unsubscribe with create_filter or trash for those.
Not offering an automatable option is reported as unsubscribed:false with the alternatives, not as an
error. A read-only deployment gets list_unsubscribe and list_subscriptions, and never makes the
request at all.
A query that does not name a place never sees spam or trash. Gmail excludes both from any
search that does not say in:spam / in:trash, so from:someone returns nothing for a mail that
is sitting in the spam folder — and nothing in the answer says so. Measured against a live mailbox:
the same from: query returned 0 hits by default and 1 with spam included.
This matters because of why mail gets misfiled. A spam filter judges a message on its own; it cannot know that you signed up for something a minute ago, requested a password reset, or placed an order — so the confirmation you are waiting for is exactly the kind of mail that lands there. You know what you just did. The filter does not.
So when mail someone expects is missing, ask again with the place named:
search("in:spam newer_than:2d") # what got filed as spam recently
search("in:spam from:example.com") # the confirmation that never arrived
A thread returns to the inbox with modify_labels (remove SPAM, add INBOX), and a sender that
keeps being misjudged is best fixed for good with a never-spam rule — create_filter with
removeLabels: ["SPAM"] (see Filters).
Two things this server deliberately does not do. It does not scan the spam folder and judge what belongs there: measured over one real spam folder, 89% of it carries no mailing-list machinery at all, so "looks unlike bulk mail" flags nearly the whole folder and filters nothing. And it does not act on that judgement by itself — releasing mail from spam is a decision, and the context that makes it obvious ("I just registered there") lives in the conversation, not in the mailbox.
For the full threat model — trust boundary, per-threat mitigations, explicit non-goals, and how to report a vulnerability — see SECURITY.md. The highlights:
--http listener binds to 127.0.0.1
(not the LAN) and refuses to start without a MAILWARDEN_TOKEN bearer token — set
MAILWARDEN_ALLOW_NO_TOKEN=1 to override on a trusted, isolated network. On a loopback bind it
also validates the Host header (DNS-rebinding defense). For remote hosting, set MAILWARDEN_HOST
and front it with TLS.create_filter follows
the same rule: it can label, archive, trash, star or mark mail, but never creates a forwarding
filter (which would be an exfiltration path). list_filters still surfaces any forwarding filter
already on the account, so you can spot one. This holds because no such tool exists and none can be
registered at runtime; for the stronger variant, where Google refuses to send rather than
mailwarden declining to, see Read-only mode below.unsubscribe tool is the only code path that
contacts a non-Google host. Its endpoint is read from the message's List-Unsubscribe header —
never from a tool argument — the request body is fixed and the response body is discarded, so it
cannot become a data channel. https/default-port only, redirects re-validated, and any hop resolving
to a private, loopback, link-local or metadata address is refused. See
Unsubscribing.MAILWARDEN_TOOLS advertises only the tiers
you name — read (the read tools), manage (mailbox mutations, snooze, downloads), filters
(server-side filter CRUD, the only tier whose tools need gmail.settings.basic). Default is all
three; e.g. read,manage gives a full triage surface without filter management. The OAuth scopes
requested at --auth are derived from the enabled tiers — a read deployment asks only for
gmail.readonly, and gmail.settings.basic is requested only when the filters tier is on. And the
filter tools are hidden automatically when the stored token doesn't carry gmail.settings.basic
(e.g. a token authorized before you enabled the tier) — re-run --auth to grant it. Older tokens
without a recorded scope are advertised as before, with the runtime insufficient-scope message as the
fallback.MAILWARDEN_READONLY=1 (shorthand for MAILWARDEN_TOOLS=read) and only the
read tools (search, get_thread, list_labels, list_snoozed, get_profile, triage_digest,
list_unsubscribe, list_subscriptions)
are registered — nothing that can change the mailbox or write
files is even advertised to clients (the filter tools, which need the broader gmail.settings.basic
scope, are excluded too). Recommended for shared/HTTP deployments that only triage.
It is also the only tier whose no-send property Google enforces: it holds a gmail.readonly
token, which Gmail's send endpoints reject outright. manage needs gmail.modify, and Gmail
does accept that scope for sending — mailwarden simply exposes no tool that would. So a read
deployment could not send even if this binary were replaced; a manage one cannot send because
there is nothing to call. (There is no send-free write scope to switch to — see
SECURITY.md, threat 1.)MAILWARDEN_DOWNLOAD_DIR set, attachment writes are confined to that
directory (realpath-canonicalized, symlink-aware) and never overwrite an existing file.<untrusted-tool-output> markers
and stripped of invisible/BiDi-override characters, so clients can tell quoted mail content from
instructions.~/.mailwarden/.token.json holds a refresh token; on disk it is protected
only by mode 0o600 (a no-op on Windows). Set MAILWARDEN_TOKEN_PASSPHRASE to a passphrase and the token
is stored AES-256-GCM-encrypted (scrypt-derived key), so a copy of the file — a backup, a synced
folder, another machine — is useless without the passphrase. Re-run mailwarden --auth once after
setting it to encrypt the existing token. Note the boundary: this defends against file theft, not
against malware running as your user (which can read the passphrase from the environment too).claude mcp add mailwarden -- npx -y mailwarden
That's the whole install — npx fetches and runs the published package, no clone or build step. You only need Google OAuth credentials once (below).
First time setting up a Google OAuth app? Follow the step-by-step setup guide — it walks through the Google Cloud Console with exact click paths, explains the "unverified app" screen, and covers the trap that makes tokens die after 7 days. The short version:
credentials.json.credentials.json in ~/.mailwarden/ (or set MAILWARDEN_CREDENTIALS=/path/to/credentials.json).~/.mailwarden/token.json:
npx -y mailwarden --auth
Scopes requested: gmail.modify (read + label/archive/trash) and gmail.settings.basic
(filter management only). If you authorized a version before filters existed, re-run --auth
once to grant the added scope. To hold a token that Gmail itself refuses to send with, authorize
with MAILWARDEN_TOOLS=read — see Read-only mode above.npx -y mailwarden --check
It checks credentials.json, whether a token exists (and if it's encrypted), whether the
granted scopes cover your enabled tiers, and makes one live Gmail call to prove the token
still works — printing a concrete fix for anything that's wrong, and exiting non-zero if so
(handy in CI/health checks). Diagnoses the common traps: no/wrong credentials file, never
authorized, an encrypted token with no MAILWARDEN_TOKEN_PASSPHRASE, a missing scope, or the
7-day "Testing"-consent token expiry.Claude Code (local stdio):
claude mcp add mailwarden -- npx -y mailwarden
Claude Code plugin — the same server plus a /mailwarden:setup skill that walks you through the
OAuth setup and diagnoses a broken one. The repo root is the plugin (.claude-plugin/plugin.json), so
from a clone:
claude --plugin-dir /path/to/mailwarden
It is submitted to Anthropic's community marketplace; once listed, /plugin marketplace add anthropics/claude-plugins-community
then /plugin install mailwarden@claude-community does the same without a clone. The plugin runs the full
tool surface — for a narrower tier (MAILWARDEN_TOOLS=read) or a second account, use claude mcp add with
the env you want instead (see Config and Multiple accounts).
Claude Desktop — add to claude_desktop_config.json:
{
"mcpServers": {
"mailwarden": { "command": "npx", "args": ["-y", "mailwarden"] }
}
}
Or install the MCPB bundle (mailwarden-<version>.mcpb, attached to
GitHub releases from 0.10.0 on) as a Desktop extension — Settings →
Extensions → Install extension… — the same server, self-contained at run time (no npx; Claude
Desktop brings the Node runtime), with the tool tiers as a setting. The bundle is built from the packed
npm package (same file set as published; npm run mcpb, verified in CI: validated, unpacked and booted)
and is the same file set Smithery distributes. The one-time npx -y mailwarden --auth still applies
(Node needed once for that) — the bundle reads the same ~/.mailwarden/ token.
Smithery — listed as csitte/mailwarden, which serves
that bundle:
npx -y @smithery/cli install csitte/mailwarden --client claude # local stdio entry in the client's config
Note which of Smithery's two paths you take. The install above writes a plain local server entry: the
process, your token and your mail stay on your machine, exactly as with npx. Adding it to Smithery's
toolbox instead (smithery mcp add) also runs the bundle locally, but relays the tool traffic
through Smithery's gateway so a remote client can reach it — the mailbox content in those responses then
passes through a third party. That is a property of the gateway, not of mailwarden; if you want the
no-third-party guarantee, use the local install, the npm package, or the .mcpb from the release page.
Remote (Streamable HTTP) — for a VPS / claude.ai custom connector:
# Loopback + token required by default. For real hosting, bind outward and keep the token:
MAILWARDEN_TOKEN=<secret> MAILWARDEN_HOST=0.0.0.0 npx -y mailwarden --http # :8787/mcp
Then in claude.ai: Settings → Connectors → Add custom connector → your https://your-host/mcp URL. In Claude Code: claude mcp add --transport http mailwarden https://your-host/mcp.
One OAuth app (one credentials.json) can authorize several Gmail accounts. Each account keeps its
own refresh token in a separate file, selected by MAILWARDEN_ACCOUNT:
mailwarden --auth --account work # stores token.work.json
mailwarden --auth --account personal # stores token.personal.json
Run them side by side by registering the server once per account, each with its own
MAILWARDEN_ACCOUNT. Every instance is fully isolated — its own token, its own granted scopes, its
own tool surface — so nothing can act on the wrong mailbox:
{
"mcpServers": {
"gmail-work": { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "work" } },
"gmail-personal": { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "personal" } }
}
}
Account names are case-insensitive — they become filenames, so Work and work would be the
same file on Windows/macOS. mailwarden lower-cases them (--account Work → token.work.json) so a
name always maps to exactly one mailbox.
Which file --auth writes depends only on --account / MAILWARDEN_ACCOUNT — never on the
account you pick in the browser. Authorizing a second mailbox without --account would
therefore aim straight at the first one's token file, so --auth checks first and refuses
rather than replacing another mailbox's token; --force overrides it deliberately. The two knobs
are not interchangeable: MAILWARDEN_ACCOUNT is the one for several mailboxes out of one config
directory (it picks token.<name>.json), while MAILWARDEN_DIR moves the whole directory —
useful to keep setups apart entirely, but it does not give you a second account inside one.
npm run auth from a repo clone passes neither, i.e. it always serves the default account.
mailwarden --check shows the active account and lists the others it finds. With no
MAILWARDEN_ACCOUNT set, everything uses the default token.json exactly as before — this is fully
backward compatible.
git clone https://github.com/csitte/mailwarden && cd mailwarden
npm install && npm run build
node dist/index.js --auth
| Var | Meaning |
|---|---|
MAILWARDEN_DIR | config dir (default ~/.mailwarden) |
MAILWARDEN_CREDENTIALS | path to credentials.json |
MAILWARDEN_ACCOUNT | select a named account (its token is token.<name>.json; names are lower-cased); unset = the default token.json. See Multiple accounts |
MAILWARDEN_TOKEN_PASSPHRASE | passphrase → encrypt token.json at rest (AES-256-GCM); re-run --auth after setting |
MAILWARDEN_AUTO_SWEEP | 1 → snooze sweep at startup + hourly while running (writes labels — needs the manage/gmail.modify scope; a read-only grant can't sweep) |
MAILWARDEN_DOWNLOAD_DIR | restrict download_attachment to this directory (strongly recommended for HTTP hosting) |
MAILWARDEN_READONLY | 1 → register only the read tools (search/get_thread/list_labels/list_snoozed/get_profile/triage_digest/list_unsubscribe/list_subscriptions). Shorthand for MAILWARDEN_TOOLS=read |
MAILWARDEN_TOOLS | comma-separated tool tiers to advertise: read, manage, filters (default: all). Also derives the OAuth scopes requested at --auth. E.g. read,manage drops the filter tools and their gmail.settings.basic scope |
MAILWARDEN_DEBUG | 1 → print full errors with stack traces instead of a one-line message (for bug reports) |
PORT | HTTP port (default 8787) |
MAILWARDEN_HOST | HTTP bind address (default 127.0.0.1; set e.g. 0.0.0.0 for remote hosting) |
MAILWARDEN_TOKEN | bearer token for the HTTP endpoint — required for --http unless overridden |
MAILWARDEN_ALLOW_NO_TOKEN | 1 → allow --http without a token (trusted/isolated networks only) |
MAILWARDEN_ALLOWED_HOSTS | extra comma-separated host:port values accepted by the loopback Host allowlist |
Working and used in daily mailbox automation. Core Gmail tools + snooze implemented against googleapis, covered by a vitest suite (789 tests — npm run coverage). Current version: see the npm badge above, the changelog, or releases. PRs welcome.
MIT © C.Sitte Softwaretechnik
FAQs
A reliable, native Gmail MCP server with full mailbox control — search, labels, archive, trash, attachments, and snooze.
The npm package mailwarden receives a total of 657 weekly downloads. As such, mailwarden popularity was classified as not popular.
We found that mailwarden demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Product
Socket is bringing experimental protection to Firefox, scanning 97,000+ extensions in Mozilla's official directory for malware and risky updates.

Research
/Security News
Three compromised Rust crates pulled in a malicious dependency that downloaded and executed cross-platform malware during Cargo builds.

Research
/Security News
Socket uncovered 77 linked Firefox extensions, including 40 that steal wallet secrets or credentials and 37 deceptive sports-score shells.