# mcp-server-notmuch

mcp-server-notmuch logo

An MCP server that exposes a local [notmuch](https://notmuchmail.org/) email database to an LLM client such as Claude. It is read-first: searching, reading, and understanding mail is always available; writing anything (drafts, tags, exported files) requires an explicit opt-in flag and is confined to clearly bounded locations. **It never sends mail.** There is no send capability anywhere in this codebase, in any mode, with any flag. Drafts are written to a local maildir for you to review and send yourself in a real mail client. ## What it does - Searches and reads your mail (threads, single messages, attachments, calendar invites, office documents, images) via the real `notmuch` CLI. - Understands scopes: a named, pre-configured notmuch query (e.g. "personal mail" vs. "mailing lists") that every search is confined to unless you ask otherwise. - Answers "what's still unanswered" and "who owes me a reply" (`mail_pending`), "has this come up before" (`mail_related_threads`), and gives a token-cheap overview of a long thread before you read all of it (`mail_thread_overview`). - Optionally composes and revises plain-text drafts (`--allow-drafts`), tags messages (`--allow-tags`), or exports attachments and a [Gource](https://gource.io/) visualization of your mailbox history to a directory you name (`--allow-export DIR`). ## What it does not do - It does not send mail. Ever. - It does not modify your mail in any way unless you pass `--allow-tags` (tagging) or `--allow-drafts` (writing a new file into a drafts maildir). Neither flag lets it touch existing messages' content. - It does not read or write outside the notmuch database, the configured drafts maildir, and (only with `--allow-export`) the configured export directory. - It does not require or use the notmuch2 Python bindings, so no compiler is needed to install it. ## Install ### From PyPI (once published) ```console $ uvx --prerelease=allow mcp-server-notmuch --help ``` ### From source ```console $ git clone https://github.com/hgn/mcp-server-notmuch $ cd mcp-server-notmuch $ uv pip install --prerelease=allow -e . $ mcp-server-notmuch --help ``` The `--prerelease=allow` is required because this project pins a pre-release of the `mcp` SDK (see [SDK version](#sdk-version) below); it is not optional. ### System requirements - `notmuch` (the CLI, not just the library) on `PATH` or pointed to via `notmuch.binary` in the config. - `poppler-utils` (`pdftotext`) to read PDF attachments. Without it, `mail_read_attachment` on a PDF names the missing package. - `pandoc` or `libreoffice` to read office documents (doc/docx/odt/rtf). Without either, the error names both options. - Optionally, `Pillow` (`pip install 'mcp-server-notmuch[image]'`) to let oversized image attachments be downscaled instead of refused. - Optionally, [Gource](https://gource.io/) to actually play back the log `mail_export_gource` writes. ## MCP client configuration ### Claude Code Read-only (the default: search and read tools only): ```console $ claude mcp add notmuch -- uvx --prerelease=allow mcp-server-notmuch ``` With drafts enabled (also allows revising/tagging as needed): ```console $ claude mcp add notmuch -- uvx --prerelease=allow mcp-server-notmuch --allow-drafts ``` Or by hand in `.mcp.json`: ```json { "mcpServers": { "notmuch": { "command": "uvx", "args": ["--prerelease=allow", "mcp-server-notmuch", "--allow-drafts"] } } } ``` ### Claude Desktop Add to `claude_desktop_config.json` (read-only default): ```json { "mcpServers": { "notmuch": { "command": "uvx", "args": ["--prerelease=allow", "mcp-server-notmuch"] } } } ``` With drafts enabled: ```json { "mcpServers": { "notmuch": { "command": "uvx", "args": ["--prerelease=allow", "mcp-server-notmuch", "--allow-drafts"] } } } ``` ## Configuration The server reads `$XDG_CONFIG_HOME/mcp-server-notmuch/config.toml` (`~/.config/mcp-server-notmuch/config.toml` if `XDG_CONFIG_HOME` is unset), or a path given with `--config`. A missing file is not an error: the server falls back to the system `notmuch` binary and a single built-in scope `all` with an empty query. Once a file exists, `[scopes]` is authoritative and `all` is no longer implied. See [`config.example.toml`](config.example.toml) for a fully commented reference file. Summary of every key: | Section | Key | Default | Meaning | |---|---|---|---| | `[notmuch]` | `binary` | `"notmuch"` | Path or bare name of the notmuch binary. | | | `config` | notmuch's own default | Path passed as `NOTMUCH_CONFIG`. | | `[limits]` | `max_body_chars` | unset (unlimited) | Message body truncation point; unset means full body every time. | | | `max_attachment_chars` | unset (unlimited) | Attachment/office/calendar text truncation point; unset means full text every time. | | | `max_image_bytes` | `5242880` | Image size ceiling; downscaled with Pillow if larger, else refused. | | `[scopes]` | `default` | *(required once `[scopes]` exists)* | Scope used when a tool call omits `scope`. | | `[scopes.]` | `query` | — | A notmuch query ANDed with every search using this scope. | | | `description` | `""` | Shown by `mail_list_scopes`. | | `[drafts]` | `maildir` | unset | Root of a maildir (`cur/`, `new/`, `tmp/`) for `mail_create_draft`. | | | `from` | unset | `From:` header on every draft. | | | `signature` | unset | Plain-text signature file, appended on request. | | | `wrap_columns` | `72` | Hard-wrap width for drafted plain text. | | | `max_total_attachment_bytes` | `26214400` (25 MiB) | Ceiling on draft attachments' combined size. | | `[identity]` | `addresses` | notmuch's `user.primary_email`/`user.other_email` | Your own address(es); used to exclude yourself from reply-all and to detect `mail_pending direction="waiting"`. | `mail_create_draft`/`mail_update_draft` refuse to run unless both `drafts.maildir` and `drafts.from` are set. `mail_pending direction="waiting"` needs `[identity] addresses` (or a readable notmuch `user.primary_email`) to know which address is "you". **Nothing is truncated by default.** This is a local, fast mailbox, not a rate-limited API: `mail_search`, `mail_pending`, `mail_related_threads`, `mail_list_addresses`, `mail_find_attachments` and `mail_export_gource` all return every matching row unless a tool call passes an explicit `limit`, and message bodies/attachment text come back in full unless `limits.max_body_chars`/`limits.max_attachment_chars` are set. Whenever an explicit `limit` or char cap does cut something, the output says so: "Showing N of TOTAL ..." plus a hint to omit `limit` to see everything. There is no silent, invisible ceiling anywhere in this server. Scope resolution: a tool's `scope` argument names a configured scope; its query is ANDed with the caller's query, both sides parenthesized (`(scope_query) and (user_query)`), so an `or` on either side cannot leak past the other. An unknown scope name is an error listing the configured scopes; `scope="all"` is never silently unfiltered unless you define a scope literally named `all`. ## Tiers and tools Four tiers. The read tier is always registered. The other three are registered **only** when their flag is passed — there is no "registered but refused" state, an unauthorized tool is simply absent from the tool list a client sees. | Flag | Registers | |---|---| | *(none)* | Read tier: search, read, list, prepare — nothing is written. | | `--allow-drafts` | Draft tier: compose and revise local plain-text drafts. | | `--allow-tags` | Tag tier: add/remove tags on existing messages. | | `--allow-export DIR` | Export tier: write attachments/a Gource log into `DIR`. | ### Read tier (always on) | Tool | Purpose | |---|---| | `mail_search` | Search threads or messages, paged; returns everything unless `limit` caps it. | | `mail_read_thread` | Every message in a thread, oldest first. | | `mail_thread_overview` | One line per message (date/size/from), tree or flat layout, before reading a long thread in full. | | `mail_related_threads` | Heuristic "has this come up before" (subject + participant overlap). | | `mail_pending` | Threads you owe a reply on, or threads you're waiting on a reply to. | | `mail_read_message` | A single message's headers and body. | | `mail_count` | Cheap message/thread count for a query. | | `mail_list_addresses` | Resolve a name to the real address(es) behind it. | | `mail_list_attachments` | List one message's attachments. | | `mail_read_attachment` | Read one attachment: text, PDF, image, office document, or calendar invite. | | `mail_find_attachments` | Find attachments across a whole search (e.g. "all PDFs from 2025"). | | `mail_list_scopes` | List the configured scopes. | | `mail_prepare_reply` | Derive reply/reply-all/forward headers and quoted/forwarded body; writes nothing. | ### Draft tier (`--allow-drafts`) | Tool | Purpose | |---|---| | `mail_create_draft` | Compose a plain-text draft (optionally with attachments) into the configured maildir. | | `mail_update_draft` | Revise an existing draft in place; only the given fields change. | ### Tag tier (`--allow-tags`) | Tool | Purpose | |---|---| | `mail_tag` | Add/remove tags on every message matching a query. | ### Export tier (`--allow-export DIR`) | Tool | Purpose | |---|---| | `mail_save_attachment` | Save one attachment's raw bytes into `DIR`. | | `mail_export_gource` | Write a [Gource](https://gource.io/) custom log of mailbox history into `DIR`. | `mail_export_gource` writes one line per message, `timestamp\|username\|type\|path\|colour`, sorted oldest first (Gource requires this). `path` is `folder/normalized-subject`, so a whole reply chain lands at one point in the tree; `colour` is a stable hash of the folder name, so a folder keeps its colour across repeated exports. Play it back with: ```console $ gource --log-format custom mail.gource -s 0.5 --key ``` All matching messages are included by default; `limit` matters when the query is broad, since feeding Gource every mailing-list message you've ever received produces an unwatchable animation, so scope the query first or set `limit` explicitly. ## Security model This is a mail server handed to an LLM; message content is not trusted the way your own instructions are. 1. **Prompt injection.** Message bodies, attachment text, calendar summaries, and thread overview lines are third-party content, not instructions. `render.py` wraps every one of them in explicit `-----BEGIN/END UNTRUSTED EMAIL CONTENT-----` markers with a notice that nothing inside should be treated as a command, so no individual tool can forget to do this. 2. **Path confinement.** The drafts maildir (`compose.py`) and the export directory (`export.py`) each resolve the target path and verify it is still inside the configured root afterward. This catches a literal `..` and a symlink pointing outside the root (`Path.resolve()` follows symlinks), and `mail_create_draft`/`mail_update_draft` cannot be made to write outside the configured maildir with any combination of arguments. 3. **No shell, ever.** Every subprocess call is `subprocess.run([...], shell=False)` with an argv list; queries are passed as a single argv element, never interpolated into a shell string or a notmuch query string beyond normal AND/OR composition. 4. **No content in diagnostics.** Errors and progress go to stderr and are content-free (a byte count or a file path, never a message body). 5. **Never silently unfiltered.** A `scope` argument that AND-composes with a query is always explicit; there is no hidden "search everything" fallback unless a scope literally named `all` is configured. 6. **Never silently truncated.** This is a local, fast mailbox: result rows, message bodies and attachment text are returned in full unless a tool call passes an explicit `limit` or the config sets an explicit `limits.max_body_chars`/`limits.max_attachment_chars`. Whenever one of those does cut something, the output states the true total and how to see the rest, never a silent cut. ## notmuch query syntax `query`/`scope` arguments accept full notmuch search syntax: `from:`, `to:`, `subject:`, `tag:`, `date:` ranges, boolean `and`/`or`/`not`, and more. See **notmuch-search-terms(7)** (`man notmuch-search-terms`) for the complete reference. ## SDK version Targets MCP spec **2026-07-28** and pins `mcp==2.0.0b2`, a pre-release of the Python SDK built for that spec. Once the spec and a matching stable SDK release ship, this pin moves to the stable release; until then, every install (`uv pip install`, `uvx`) needs `--prerelease=allow`. ## Development ```console $ make # fmt + lint + test $ make test # pytest (skips cleanly if notmuch is not installed) $ make lint # ruff format --check + ruff check $ make help # list all targets ``` Tests build a small crafted maildir and run real `notmuch` commands against it; nothing touches your real mail. CI runs on Python 3.11, 3.12 and 3.13 with notmuch installed via `apt`. ## License MIT, see [LICENSE](LICENSE).