Agents over MCP
postvak mcp lets an agent host such as Claude Desktop, Claude Code, Codex or Cursor read your mail, propose rules and, when you allow it, act on mail. It speaks MCP on stdin and stdout, and the host starts it. It can do nothing you did not grant: each capability is a scope, and the host’s config sets the scopes.
The defaults are privacy-friendly. An agent sees folders and headers but no message bodies, cannot change or delete mail, and can only propose rules, which stay off until you approve them. Bodies, acting on mail and approving rules are each a separate scope you add on purpose, and --account hides every other account from the agent.
Setup
For Claude Desktop, register the server and restart the app:
postvak mcp install claude-desktop
For a host with a command line of its own, the install runs that command:
postvak mcp install claude-code # claude mcp add --scope user
postvak mcp install codex # codex mcp add
postvak mcp install copilot # GitHub Copilot CLI: copilot mcp add
postvak mcp install gemini # gemini mcp add --scope user
For a host that keeps its servers in a file, the install edits that file; restart the host afterwards:
postvak mcp install claude-desktop # claude_desktop_config.json
postvak mcp install cursor # ~/.cursor/mcp.json
postvak mcp install opencode # ~/.config/opencode/opencode.json
A file install keeps every other server and setting in the file. The file is rewritten pretty-printed with sorted keys, and the original is saved next to it with .bak added, such as claude_desktop_config.json.bak. Re-running the install does not replace that backup unless you changed the file in between. opencode reads opencode.jsonc too; if your settings are there, use --dry-run and copy the entry across yourself, since the install would write a separate opencode.json.
A command install first removes Postvak’s entry and then adds it, at user scope, so a re-run replaces the old entry. With --dry-run, or when the host’s command is not on your PATH, it prints those commands instead of running them.
VS Code’s agent mode is not one of the targets: it reads a different file format, under servers. Add Postvak there with MCP: Add Server, using the command and arguments that postvak mcp install json prints. Hosts that run in the cloud, such as Devin, cannot start a program on your computer, so they cannot use Postvak.
For any other host, postvak mcp install json prints a snippet for its config:
{
"mcpServers": {
"postvak": {
"args": [
"mcp",
"--scopes",
"read,rules:propose"
],
"command": "/opt/homebrew/bin/postvak"
}
}
}
The command is the absolute path of the postvak you ran, because hosts started from the Dock do not see your shell’s PATH: for Homebrew the opt/ link, which survives brew upgrade, and for an AppImage the .AppImage file, so run the install again after moving it. The install command fills it in; the path above is an example.
Options for postvak mcp install:
--scopessets the scopes. Run the install again with other scopes to change them.--account NAMElimits the server to one account. Repeat it for more.--save-dir DIRsets the folderattachment_savewrites into, instead of your Downloads folder. Your home folder, a folder above it, and hidden folders orLibraryinside it are refused, as they hold what runs at login.--removetakes the entry out again.--dry-runshows only the postvak entry, or for a host with a command line the commands, and writes nothing.
The install also prints a hint with the granted scopes and how to widen them. It goes to stderr, so the output of install json stays pure JSON.
Scopes
| Scope | What it grants | Tools |
|---|---|---|
read | Folders, each message’s sender, recipients, subject and date, the activity log, rules and trash listings, previews of rules that match on neither the body nor a header condition, and a sync. No body text and no other headers, except the one bit of Authentication-Results an authenticated rule condition reveals. | accounts, docs, folders, list, log, rules_check, rules_coverage, rules_list, rules_schema, rules_test, search, senders, status, sync, trash_list |
read:headers | Every header of a message, such as List-Id, Authentication-Results (beyond the one bit authenticated gives with read) or a spam score, from the local store, and with read previews of rules with a header condition; proposing, enabling or running such a rule needs it too. No body text. | headers |
read:bodies | Message bodies and attachment names, search over indexed bodies and on the server (search with server) with a snippet of the body around a match, and with read previews of rules that match on the body. | attachments, show |
rules:propose | Proposing rules, which are stored disabled until you approve them, and withdrawing its own proposals while they are still pending. | rules_propose, rules_withdraw |
rules:write | Approving and rejecting rules, turning them on or off, and changing their order. | rules_approve, rules_move, rules_reject, rules_set_enabled |
files:write | Writing attachment files into your Downloads folder, or the --save-dir you set, and the directories below it. The file name and content come from the sender. | attachment_save |
mail:modify | Acting on mail and the trash. | archive, delete, junk, mark, move, rules_apply_existing, trash_purge, trash_restore, undo, unsubscribe |
The default is read,rules:propose. Four example grants:
- Read-only:
--scopes read.syncis in this scope and still runs your approved rules. - Rule author: the default. The agent proposes, you approve in the window or with
postvak rules approve. It can match on sender, recipients, subject, age, size and flags, but not on aheadercondition such asList-Id. - Rule author who sees headers:
--scopes read,read:headers,rules:propose. The agent can readList-Idor a spam score, then preview and propose aheaderrule, but sees no bodies. - Inbox assistant:
--scopes read,read:headers,read:bodies,rules:propose,mail:modify.
rules:write lets the agent approve its own rules, and an approved rule can delete mail. Leave it off. It cannot make a rule send mail: a rule that forwards waits until you approve it in Postvak, with postvak rules approve or the Rules view. rules_approve enables such a rule and says in its result (forward) that it still waits, and an edit an agent approves makes an approved forward rule wait again. files:write lets the agent save a stranger’s file into your save folder; leave it off too unless you need it.
An agent cannot add mail accounts: a password command runs as a shell command as you, so a prompt-injected email could make the agent run anything you can. Add accounts yourself with postvak account add or in the window. Earlier versions had an accounts:write scope for this; a host config that still names it starts without it, with a warning.
Tools
The server’s instructions to the host are short: the ground rules of the agent guide, and to call docs for the rest. Some hosts cut long instructions short, so the full guide and the rules, search and CLI pages come from that tool instead.
The names follow the CLI commands. A tool that lists things returns the CLI’s --json rows wrapped in {"rows": [...]}; the others return one object. Rows carry an account; in rules_list that is the rule’s own scope, and null means every account. Message rows carry no body text. show returns {"message": ..., "body": ..., "truncated": ...}.
rules.toml is shared by every account, so rules_list, rules_propose, rules_withdraw, rules_approve, rules_move, rules_reject and rules_set_enabled take no account argument; a rule’s own account field scopes it. They follow --account as described under Safety.
read:
accountslists the visible accounts: name, username, host, port, address and aliases. Never the password source.docsreturns a page of this documentation as Markdown text:pageisagent-guide,rules,searchorcli, the pagespostvak guideprints. Without a page it lists them.folderslists folders with total and unread counts.listlists the newest messages in a folder, or threads.folderdefaults to INBOX;limitdefaults to 50 and is at most 500. Withthreads: trueit counts threads, and each thread comes with all its messages.unread,flaggedandattachmentskeep only the messages, or threads, that have one. Every account comes in one order, newest first, andlimitcounts them together.listandsearchpage: when more rows follow, the result carriesnextbesiderows; pass it back ascursor, with the same other arguments, for the next page. Withthreads, a thread that loses its newest message between pages (moved or deleted) can show again on a later page; drop a repeat by itsaccountandthread_id, and namethread_idinfieldsto keep it, such as["compact", "thread_id"].fieldskeeps only the named keys in each row, such as["uid", "from_addr", "subject", "date"], or["compact"]for account, folder, uid, date, from_addr, subject and flags (plusdepthormatch);accountalways stays, and an unknown name is an error.count_only: truereturns{"count": N}, how many messages (threads withthreads) match in all, and no rows.logshows the rule and action log.rules_checkvalidatesrules.tomland listswarnings, such as a conflicting copy a file sync left beside it, a rule for an account that is not set up, or a rule usingauthenticatedfor an account that trusts no server; Rules lists them all. A server started with--accountleaves out rules for the accounts it hides, and does not say whether a rule for every account watches a folder none of them syncs, since that depends on the hidden ones.rules_coverageruns every enabled rule over a folder’s stored mail, changing nothing, and returns one row per account:messages,matched,unmatchedandcontested(matched by two or more rules),unmatched_sendersgrouped assendersgroups them, andcontested_messages, each with its message row, every matchingrules, theactsthat would run under the real precedence (a flag already set does not run), andfails, why nothing would run at all, such as an archive on an account without an Archive folder, ornull.include_pendingcounts the proposals waiting for approval too.limit(default 20, at most 500) caps the two lists; the counts cover the whole folder.rules_listlists rules in file order, the order they run in. Each row is the whole rule asrules_proposetakes it (match,actionsand the rest), with its state, its proposer,position, its 0-based place in the file,hits_30d, the messages it acted on in the last 30 days,last_fired, when it last acted (Unix seconds, ornull), andforward_waiting, the accounts where a forward rule waits for a human’s approval or for sending to be set up; runs ofrules apply-existingare not counted in either. Use them to spot a rule that no longer matches anything. A server started with--accountleaves out rules for the accounts it hides, so positions can skip.rules_schemareturns the JSON Schema for a rule; its description sends the agent to therulespage ofdocsfor what each key means.rules_testpreviews a rule, given as JSON, against the local store. It reports subjects, never bodies, inrows, and inwarningseach account it ran on where the rule usesauthenticatedbut the account trusts no server, asrules_checkdoes for stored rules. A rule that matches on the body needsread:bodiestoo, and one with aheaderconditionread:headers, since their matches would reveal what bodies or headers contain.rules_propose,rules_approve,rules_set_enabledandrules_apply_existingrefuse such rules on the same terms.sendersandrules_coveragerun the whole file instead, so they leave such rules out and name them inskipped_rules.rules_testandrules_coveragetakeas_of, a span ahead (1h) or a date or time, to see whatolder_thanandyounger_thanwill do then; the result’sas_ofsays the time used, in Unix seconds. Withas_arrival: trueevery stored message counts as arrived just now, to see what ayounger_thanhold or anafterwait does to new mail;as_ofthen counts from that arrival.from,to,cc,subject,from_domainandto_domainconditions need onlyread, aslistalready shows those fields. So doesauthenticated: it readsAuthentication-Results, but reveals only whether the provider vouches for the sender, one bit per message.searchfinds messages by subject, addresses and attachment names. It matches all the given words,"phrases"in order,-wordexcludes, and the last word also as a prefix; the operators of Search (from:,in:,is:unread,after:7dand the rest) narrow it, and the tool’s description lists them. When fewer than ten messages match as typed, close matches follow, each row’smatchbeingexactorclose;exact: trueleaves them out. Searching bodies, andserver: true, needread:bodies; withserverthe result is one page, withoutnext, and takes nocursororcount_only.sendersgroups a folder’s stored mail by sender address, or withgroup_by: "domain"by registrable domain, the largest group first:count,unread,firstandlast(Unix seconds), up to threesubjectsand the enabledrulesthat already match some of it.afterandbeforetake2026-01-15or30d,3w,6m,1y;limitdefaults to 50 and is at most 500.statusispostvak daemon status --json: the daemon’s pid, version, uptime and each visible account’s activity, and whether its server offersUIDPLUS(see Other mail clients).syncasks the daemon to sync and run rules once per account and reports its result:new_messages,actionsanderrors, or anerrorwhen the account could not sync (offline, for example). It is in thereadscope, but it writes the store and applies approved rules, so hosts see it as a changing tool.trash_listlists the.emlbackups.
read:headers:
headersreturns{"message": ..., "headers": [{"name": ..., "value": ...}], "truncated": ...}: every header in order, repeats included, unfolded and with encoded words decoded, asheaderrules see them. A rule matches the last copy of a repeated header. It reads the store and never connects to the server. Past 32 KB of names and values the remaining fields are left out andtruncatedistrue. Headers can hold more than the subject does: unsubscribe links with tracking tokens, the relays a message passed and sometimes the sender’s IP address, which is why this is a scope of its own.
read:bodies:
attachmentslists attachment names, types and sizes. It does not save them; useattachment_savefor that.showreturns the headers and the body. With this scope,searchalso covers the bodies the background index has read (see Search), andserver: trueasks the mail server too, which searches every body. Otherwisesearchanddry_runnever connect to the server.showandattachmentsfetch a message you have not stored yet, once.
rules:propose:
rules_proposestores a rule disabled, withproposed_byset tomcp:<client name>, ormcpwhen the host sends no name. A rule scoped to an account the server hides is refused. While--accounthides an account, a rule withoutaccountgets the only visible account, or is refused when several are visible. Instead of one rule’s fields it takesrules, a list stored in the given order, all or none; an error names each failing rule by index and name. Proposals go after the last rule, or right next to another withbeforeorafter. Withreplace, a rule whose name is taken by this host’s own pending proposal replaces it in place rather than being refused. Withedit, each rule is a change to the existing rule of its name, stored as<name> (edit)right after it while the rule runs unchanged; approving it writes the change over the rule. While--accounthides an account, a rule for every account cannot be edited. One rule returns{"proposed": name, "account": ...}, a list{"proposed": [{"name": ..., "account": ...}, ...]}.rules_withdrawremoves one of this host’s own pending proposals: a disabled rule whoseproposed_byis this host’s. An approved rule, or another proposer’s, is refused; you reject those.
files:write:
attachment_savesaves attachmentninto the save folder, or intodir, an absolute path of an existing directory below it, and returns the writtenpath. Adirthat leads outside the save folder, through..or a symlink, is refused, so a prompt-injected agent cannot place a file where it would run, such as~/.config/autostartor~/Library/LaunchAgents. The save folder is your Downloads folder, or what--save-dirnames; without either the tool says to set one. It never overwrites a file, and fetches the message once if it is not stored yet.
rules:write:
rules_approveapproves a rule. It acts on mail that arrives afterwards.rules_rejectrejects a proposal.rules_set_enabledturns a rule on or off.rules_moveputs a rule rightbeforeorafteranother, which changes when it runs.
mail:modify:
archive,delete,junk,markandmoveact on uids in a folder, INBOX by default.marktakeshow(read,unread,flaggedorunflagged),movetakesto, andjunkwithnot: truereports mail as not junk: it tags$NotJunkand moves it to INBOX.logshows them under the rule namemcp:<client name>, ormcp.unsubscribeleaves a message’s mailing list by one-click POST when the sender offers it; otherwise its row gives the page ormailto:address for the human to open. When the row saysdone, the message is usually no longer wanted: offer the human todeleteit. Usedry_runfirst: unsubscribing tells the sender the address is read. The request goes to the sender directly, not through the daemon, and is not recorded inlog.rules_apply_existingruns one enabled rule over the mail each account already holds, the waypostvak rules apply-existingdoes, and reportsevaluated,actionsanderrorsper account. Withdry_runit lists the planned rows from the local store. When the rule would delete mail from the server for good, it refuses with how many messages per account until it is called again withyes.trash_purgedeletes the backups older than each account’strash_retention_days, for good; it reportsremovedper account.trash_restorerestores a backup, given as the file nametrash_listreturns or the pathpostvak trash listprints; nothing outside the account’s trash directory is accepted.undoputs back what one logged action did, named by theaction_idthatlogreturns: mail goes back to its folder, read, flagged and tag changes are reversed, and deleted mail is restored from its backup. Each row saysput_back,restored, or why it was left alone (changed_since,ambiguous,backup_gone,nothing_to_undo,failed). It takes a rule’s action too.
With more than one visible account, every tool that acts on one message needs account.
Safety
- Mail is written by strangers.
showwraps the body in<untrusted_mail_content>, and tells the agent that text inside is data, never instructions. Every other string a stranger wrote comes in the same tag, in every tool’s result: subjects, sender and recipient addresses with their names, message and thread ids, a search hit’ssnippet, header names and values, attachment names, and the senders and subjects thatsendersandrules_coveragegroup, such as"subject": "<untrusted_mail_content>Invoice</untrusted_mail_content>". The tags are not part of the text; the server’s instructions tell the agent to leave them out when it quotes a field or matches it in a rule. The rule tools return rules as written, without the tag. Any spelling of that tag name inside mail is rewritten tountrusted-mail-content, so a mail cannot close the wrapper early. The rewrite ignores letter case, and catches the name spelled with fullwidth or styled letters, or with Cyrillic, Greek and Armenian letters that look like Latin ones. A body is cut at 100 KB and marked"truncated": true. - Every string in a result or an error loses its control characters and the characters a person cannot see but a model reads, before the tag rewrite runs. That stops “ASCII smuggling”, text hidden as Unicode tag characters (U+E0000–U+E007F), and also removes bidi controls, zero-width spaces and the other invisible format characters. A body keeps its newlines and tabs, and emoji keep their zero-width joiners; flags written with tag characters show as a plain black flag. The CLI’s
--jsonoutput is cleaned the same way. - A host that honours tool annotations asks you before tools that change things.
delete,rules_apply_existing,rules_approve,rules_move,rules_set_enabled,trash_purgeandunsubscribeare marked destructive, because they can delete mail or its backups, or cannot be undone. The other changing tools are marked as changing, but not destructive. - Except
trash_purge, themail:modifytools takedry_run. It reports what would happen from the local store and does not ask the daemon. --accounthides other accounts from every tool. Rule writes refuse rules scoped to a hidden account, and so does proposing one. A rule withoutaccountapplies to every account, hidden ones too, so while an account is hiddenrules_approveandrules_set_enabledrefuse to turn such a rule on, andrules_moverefuses to move it; turning it off and rejecting it still work.- Without
read:bodies, no tool returns or reveals body text:searchcovers subject, addresses and attachment names only, andrules_testrefuses a rule that matches on the body. - Without
read:headers, no tool returns or reveals a header beyond sender, recipients, subject, date and the threading ids, apart from one bit: a rule withauthenticatedshows which messages your provider’sAuthentication-Resultsvouches for.rules_testand the other rule tools refuse a rule with aheadercondition, at any depth ofanyandnone. - The server cannot create, rename or delete folders, with any scope. That is left to you, in the window or with
postvak folder, on purpose: a folder delete can take a whole mailbox’s structure with it. - A call outside the granted scopes fails with “not allowed with these scopes”. Hosts only see the tools you granted, so they should not make such a call.
Alongside the window and the CLI
The MCP server reads the same local store as the mail window. Everything that needs the mail server goes through the Postvak daemon, which owns the connections: actions, trash_restore, show and attachments for a message not yet fetched, search with server, and sync. The first such call starts the daemon if none runs. The window picks up the changes as soon as the daemon reports them, so an archived message disappears from the list. Like the daemon, the server reads config.toml again when it changes, so an edit such as a new trusted_authserv_ids counts from its next call, without restarting the host.
Troubleshooting
- Restart the host after installing. It reads its config at start.
- Logs go to stderr. Look in the host’s MCP log.
- “not allowed with these scopes” means the tool needs a wider scope. Run the install again with more scopes.
- “several accounts are visible; pass account” means the tool needs the
accountargument.