The MCP server
mail-muncher mcp serves the mail already on disk to an agent over the Model
Context Protocol, on stdin and stdout.
- What it is
- Wiring it into a client
- What it can and cannot do
list_ruleslist_messagesread_messagesearch_messagessync- Limits
- Errors
- Troubleshooting
What it is
The archive on disk is the source of truth. The server reads the .eml and
.md files under each rule’s dest and answers questions about them. It is
launched by the client, not run by hand:
mail-muncher mcp --config ~/.config/mail-muncher/config.yml
Five tools:
| Tool | What it answers |
|---|---|
list_rules |
What am I collecting, and which senders am I subscribed to right now? |
list_messages |
What has arrived? Filter by rule, account, thread or date; optionally grouped into conversations. |
search_messages |
Where is the message that mentions X? |
read_message |
Give me one message in full — and optionally its whole thread. |
sync |
Fetch new mail once, and tell me exactly what landed. |
Everything but sync is read-only. sync runs the same cycle run does, takes
the same cross-process lock, and can only ever add files.
Wiring it into a client
The server speaks stdio. Any MCP client that launches a subprocess can use it. Give it an absolute path to both the binary and the config — the client’s environment is not your shell’s.
Claude Code, in .mcp.json at the project root (or ~/.claude.json for a
user-level server):
{
"mcpServers": {
"mail-muncher": {
"command": "/usr/local/bin/mail-muncher",
"args": ["mcp", "--config", "/Users/you/.config/mail-muncher/config.yml"]
}
}
}
Claude Desktop, in claude_desktop_config.json:
{
"mcpServers": {
"mail-muncher": {
"command": "/usr/local/bin/mail-muncher",
"args": ["mcp", "--config", "/Users/you/.config/mail-muncher/config.yml"]
}
}
}
The shape is the same for any other stdio MCP client: a command, and
["mcp", "--config", "<path>"] as its arguments.
Check it starts before wiring it up — a config error is reported at startup, exit 1, rather than as a tool error buried in a transcript:
mail-muncher validate --config /Users/you/.config/mail-muncher/config.yml
--log-level debug is safe to leave on. stdout carries protocol frames and
nothing else; every log line goes to stderr, where the client collects it.
What it can and cannot do
- Read-only over mail. No tool sends, deletes, moves, or modifies anything —
not in the mailbox, and not on disk.
syncis the single tool that changes anything at all, and it can only add files. - Paths are jailed to the configured
destroots. A caller-supplied path is checked lexically against each rule’sdest, then resolved with symlinks followed and checked again, so neither..nor a symlink planted inside the tree reaches a file the archive does not own. Only.emland.mdfiles are readable, and the directory walk never follows symlinks. - Nothing outside the archive is reachable or named. Not the config file,
not
credentials_fileortoken_file, not the state directory, not the quarantine directory. The one exception is deliberate:list_rulesnames each rule’sfrom_domains_file, because that file belongs to the agent. - A refused path is indistinguishable from a missing one, so the jail cannot be used to probe the filesystem.
- The config is read once, at startup. Editing
config.ymlneeds a restart. Thefrom_domains_filelists it points at are not cached — they are re-read on everylist_rulescall.
list_rules
No arguments. Start here: it is the only tool that says what is being collected.
The point of it is the resolution. Every from_domains_file in every rule’s
match tree is read at call time, with the same parsing the filter engine
uses, so an agent that rewrote its own subscription file a second ago sees the
new list.
Returns {"rules": [...]}, in config order:
| Field | Type | Meaning |
|---|---|---|
name |
string | Rule name. |
account |
string, omitted when unset | The account the rule is restricted to. Absent means every account. |
accounts |
array of string | The configured accounts this rule actually applies to. |
dest |
string | Destination directory, as configured. |
formats |
array of string | Renderings this rule writes. |
domain_files |
array, omitted when the rule has none | One entry per from_domains_file, resolved as of this call. |
stored_messages |
integer | Messages currently on disk under this rule’s dest. |
Each domain_files entry:
| Field | Type | Meaning |
|---|---|---|
path |
string | The domain list, as configured. |
exists |
bool | Whether it was readable at the time of the call. |
domains |
array of string | The domains currently listed, normalized and de-duplicated. [] when there are none. |
count |
integer | domains length. |
modified_at |
RFC3339 UTC, omitted when unreadable | When the file was last written. |
note |
string, omitted when the list is fine | Why the list is empty: not written yet, unreadable, a directory, or listing no usable domains. |
{
"rules": [
{
"name": "job-search",
"account": "personal",
"accounts": ["personal"],
"dest": "/Users/you/Mail/job-search",
"formats": ["eml", "markdown"],
"domain_files": [
{
"path": "/Users/you/.local/share/jobsearch/domains.txt",
"exists": true,
"domains": ["acme.com", "globex.io"],
"count": 2,
"modified_at": "2026-07-28T09:02:11Z"
}
],
"stored_messages": 37
}
]
}
A missing file is an empty list plus a note, never a tool error — the program
that owns it may simply not have written it yet.
list_messages
Every argument is optional. With none of them, the newest 50 messages across the whole archive.
| Argument | Type | Meaning |
|---|---|---|
rule |
string | Only messages claimed by this rule. |
account |
string | Only messages from this configured account. |
thread_id |
string | Only messages in this conversation. |
since |
string | Inclusive lower bound on the message date. RFC3339 or YYYY-MM-DD. |
until |
string | Exclusive upper bound. A bare YYYY-MM-DD includes the whole day. |
limit |
integer | Maximum to return. Default 50, maximum 500. |
group_by_thread |
bool | Also return the same messages grouped into conversations. |
Returns:
| Field | Type | Meaning |
|---|---|---|
messages |
array of summary | Newest first. |
threads |
array of thread, only when group_by_thread |
The returned messages grouped, most recently active first. |
count |
integer | Messages returned. |
matched |
integer | Messages matching the filters before the limit. |
truncated |
bool | Whether matched exceeded the limit. |
limit |
integer | The limit actually applied. |
A message summary:
| Field | Type | Meaning |
|---|---|---|
id |
string | Stable identity digest for this message. Pass it to read_message. |
path |
string | Absolute path of the preferred rendering on disk. |
rule |
string | Rule that claimed it. |
account |
string | Account it was fetched from. |
from |
string | From header, display name included when there is one. |
to |
array of string | To recipients. |
subject |
string | Subject header. |
date |
RFC3339 UTC | Message date. |
message_id |
string | RFC822 Message-ID. |
thread_id |
string | Conversation id. Never empty. |
thread_id_source |
string | provider, references, in_reply_to or self. |
labels |
array of string | Provider labels or folders. |
has_attachment |
bool | Whether it carries a non-inline attachment. |
formats |
array of string | Renderings of this message that exist on disk. |
snippet |
string | Text around the search hit. search_messages only. |
A thread adds thread_id, subject (of its oldest message), count,
first_date, last_date, participants (distinct From addresses, oldest
first) and messages (oldest first — reading order).
Two details worth knowing:
- A thread’s
thread_id_sourceis the weakest among its messages, so a partly-reconstructed thread is not reported as provider-grouped. group_by_threadgroups the page you got back. A thread whose other messages fell outside the filters or the limit is shown partially; filter bythread_idto get all of it.
read_message
Exactly one of id or path is required.
| Argument | Type | Meaning |
|---|---|---|
id |
string | The id from a message summary. |
path |
string | A path from a summary or from a run manifest. Must be inside a configured dest. |
thread |
bool | Also return every other message in the same conversation, oldest first. |
max_body_chars |
integer | Truncate each body to this many characters. Default 20000, maximum 500000. |
Returns {"message": {...}}, plus "thread": [...] when thread was set. The
detail shape is a summary plus:
| Field | Type | Meaning |
|---|---|---|
cc |
array of string | Cc recipients. |
in_reply_to |
string, omitted when empty | The parent message’s id. |
attachments |
array, omitted when none | {filename, content_type, bytes} per attachment. |
files |
array | {format, path, bytes} for every rendering on disk. |
body |
string | The message body. |
body_format |
"markdown" | "text" |
markdown when the body came from a rendered .md or an HTML part. |
body_truncated |
bool | Whether max_body_chars cut it. |
Attachment bytes are never returned. An agent that wants one opens the file
next to the message, from the path in attachments / the .attachments/
directory beside the .md.
Returning the thread is nearly free — the index already groups by thread id — so
thread: true is a flag rather than a second round trip. Use it: a hiring
process, a booking or a support case is a conversation, not a message.
search_messages
| Argument | Type | Meaning |
|---|---|---|
query |
string, required | Text to look for. |
rule, account, thread_id, since, until, limit |
Exactly as list_messages. |
Case-insensitive substring search across subject, sender, recipients, labels,
attachment names and body. Returns the same shape as list_messages plus
the echoed query, and each summary carries a snippet — the text around the
first hit, whitespace collapsed, with … where it was cut.
This is substring matching, not a query language: there is no index, no stemming, and no boolean syntax. A blank query is an error rather than a match on everything.
sync
| Argument | Type | Meaning |
|---|---|---|
dry_run |
bool | Evaluate rules and report what would be written, without writing anything. |
Runs one pipeline cycle — the same one run and daemon run, cross-process
lock included — and returns:
{
"dry_run": false,
"manifests": [ { "account": "personal", "...": "..." } ],
"error": "why the cycle ended early, if it did"
}
One manifest per configured account, in config order. The full field-by-field
contract is docs/manifest.md — it is the same struct
run --json writes.
Two guards keep it well-behaved as a server:
- One sync at a time in this process. A second concurrent call is refused immediately rather than queued, because a tool call that blocks for the length of a mail fetch is indistinguishable, to an agent, from a hung server.
- A lock held by another process — a cron
run, a daemon tick — comes back asa sync is already in progress; try again shortly. It is never waited on and never retried.
A provider failure is deliberately not a tool error: the manifests still
describe everything that landed before it, so they are returned with the failure
recorded alongside in error.
Limits
Part of the contract: a call that asks for more comes back capped, with
truncated set, rather than refused.
| Limit | Value |
|---|---|
Default limit |
50 |
Maximum limit |
500 |
Default max_body_chars |
20000 |
Maximum max_body_chars |
500000 |
| Search snippet context | 120 characters each side |
Errors
Tool errors are ordinary results with isError set, so the agent sees them and
can act:
| Error | Cause |
|---|---|
path is not inside a configured destination directory |
The path jail refused it — or nothing is there. The two are deliberately indistinguishable. |
no such stored message |
The id or path names nothing in the archive. |
one of id or path is required / give either id or path, not both |
read_message was called with neither or with both. |
query is required and must not be blank |
search_messages with an empty query. |
a sync is already in progress; try again shortly |
Another cycle holds the lock, here or in another process. |
cannot read ... as a date |
A since / until that is neither RFC3339 nor YYYY-MM-DD. |
Startup failures are different: a config that will not load or validate exits 1 before any client connects.
Troubleshooting
The server exits immediately with no rule has a dest directory to read.
Every rule needs a dest; with none, there is no archive to serve.
list_messages returns nothing but mail is on disk. The server only reads
files under a configured dest. Check list_rules — its dest and
stored_messages say exactly which directories it is looking at and how many
messages it found in each.
A message is missing its .eml or .md. formats on the summary lists the
renderings that actually exist. A rule with formats: [eml] produces no
markdown, and read_message then has no body to return from a rendered .md.
The client reports a protocol error. Something wrote to stdout. Nothing in
mail-muncher does — all logging goes to stderr — so check for a wrapper script
echoing before exec.