Metadata-Version: 2.4
Name: mcp-server-notmuch
Version: 0.1.0
Summary: MCP server exposing a local notmuch email database, read-first
Project-URL: Homepage, https://github.com/hgn/mcp-server-notmuch
Author: Hagen Paul Pfeifer
License-Expression: MIT
License-File: LICENSE
Keywords: email,mcp,notmuch
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Requires-Python: >=3.11
Requires-Dist: html2text>=2024.2.26
Requires-Dist: mcp==2.0.0b2
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: image
Requires-Dist: pillow>=10; extra == 'image'
Description-Content-Type: text/markdown

# mcp-server-notmuch

<p align="center">
  <img src="logo.jpg" alt="mcp-server-notmuch logo" width="200"/>
</p>

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]` | `default_limit` | `20` | Rows returned when a tool call omits `limit`. |
| | `max_limit` | `100` | Hard ceiling on `limit`, whatever is requested. |
| | `max_body_chars` | `40000` | Message body truncation point. |
| | `max_attachment_chars` | `100000` | Attachment/office/calendar text truncation point. |
| | `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.<name>]` | `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".

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, with a truncation notice. |
| `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
```

`limit` (default 100000) matters: feeding Gource every mailing-list message
you've ever received produces an unwatchable animation, so scope the query
first.

## 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.

## 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).
