mcp-agent-mail
README.md
# mcp-agent-mail
Encrypted email + PGP for AI agents, as a local **MCP server**. Read, send,
and reply to GPG-encrypted mail; manage a contact book with key provenance;
archive and search everything you read.
Built to be called by any MCP-aware agent harness (Claude, opencode, etc.)
over stdio. Works with Gmail and any IMAP/SMTP provider.
> **Standalone rewrite.** This project is a clean, hardened rebuild of the
> earlier prototype, built around one hard-won invariant: consequence-critical
> bytes must **never** enter the model's context window. The prototype earned
> that invariant the hard way — a low-parameter local model reading and
> repeating high-bit key material corrupted it. This rewrite carries the
> invariant over verbatim; several deliberately-added behavioral changes are
> flagged with ⚠ below.
## Why this design
The model is the least reliable component in the pipeline. Small local models
(the ones that run on consumer hardware) occasionally mangle byte-exact data
— a quoting battle, a garbled regex, a corrupted key block. For ordinary text
that is noise; for private keys, passphrases, and ciphertext it is
unacceptable. So the design rule is:
**Anything whose bytes must not change never enters the model's context.**
That rule, not a threat model, is the primary reason for every invariant
below. They are reliability controls: they keep high-consequence data on
the side of the boundary the model cannot corrupt. The same controls also
happen to harden the tool against an untrusted model and a partially-trusted
host — useful, but derived. The primary enemy here is entropy, not malice.
There's a third, pragmatic face of the same rule: **context is the scarcest
resource on a local model, and high-entropy bytes are its worst possible
consumer.** A key block, an armored ciphertext, a base64 payload is nearly
incompressible noise — it burns tokens at maximal density and returns zero
usable signal. Keeping it out of the context window doesn't just protect its
bytes; it wins back expensive context the model would otherwise waste
repeating and re-mangling noise it was never going to use.
- **Inbound PGP key interception** — public keys that arrive by email are
imported and linked to the sender's contact *before* the body reaches the
model. The block is replaced by a notice, never shown.
- **No silent downgrade (⚠)** — `email_send` encrypts by default. If no key
is on file for the recipient it **refuses to send**; the model must
explicitly choose `encrypt=False` to send in the clear. A model that
guesses wrong silently is worse than one that stops and asks.
- **Full fingerprints only** — 16-char key IDs are rejected everywhere
(Evil32 collision attack).
- **Secrets are opaque** — `SecretString` wraps passphrase/password; reprs,
logs, tracebacks show `***`. Private keys are never exported.
- **Fail-fast config (⚠)** — missing secrets abort startup with a list,
never a warning and never a fallback default. A half-configured server is
a guesser; fail-fast is fail-safe.
- **Immutable identity entries (⚠)** — the agent (`EMAIL_ADDRESS`) and the
owner (`OWNER_EMAIL`) each have exactly one contact entry, fixed at setup.
No tool can add, remove, re-key, or clear them, and the server refuses to
start until both exist. Identity is a provisioning decision, not a model
action.
## Tool surface
| Domain | Tools |
|---|---|
| Email | `email_check_inbox`, `email_read`, `email_send`, `email_reply` |
| Contacts | `contact_list`, `contact_get`, `contact_add`, `contact_link_key`, `contact_set_fingerprint`, `contact_clear_key`, `contact_remove` |
| GPG | `gpg_list_keys`, `gpg_encrypt`, `gpg_verify`, `gpg_export_own_pubkey`, `gpg_own_status` |
| Archive | `archive_search`, `archive_get` |
| Utility | `get_current_datetime` |
## Requirements
- Python 3.13+
- [uv](https://docs.astral.sh/uv/)
- GnuPG — **Gpg4win** on Windows, `gnupg2` on Linux/macOS
- An email account with IMAP/SMTP app-password access (Gmail: enable 2FA,
create an app password)
- *(optional)* [KeePassXC](https://keepassxc.org/) — for the `keepassxc`
secret backend (production)
## Install
```powershell
git clone <repo-url> mcp_agent_mail
cd mcp_agent_mail
uv sync
```
## Setup
1. Generate a dedicated agent PGP key pair:
```powershell
gpg --full-generate-key
```
2. Create `.env` from the template and fill it (see `.env.example` for
passphrase quoting pitfalls):
```powershell
Copy-Item .env.example .env
```
Required: `EMAIL_ADDRESS`, `OWNER_EMAIL`, `EMAIL_PASSWORD` (app password),
`GPG_KEY_ID` (the agent key's full 40-char fingerprint),
`GPG_PASSPHRASE`.
> Two backends are implemented. The default `env` backend stores secrets
> in plaintext on disk — fine for development on a machine whose disk you
> trust. For production, use `SECRET_BACKEND=keepassxc` (see
> *Secret backends* below), which keeps the secrets in a dedicated,
> key-file-only KeePassXC database and leaves `.env` holding only
> non-secret paths and identity values.
3. Sanity check:
```powershell
uv run mcp-agent-mail setup # deps + key presence + secret status
uv run mcp-agent-mail doctor # offline config diagnostics
uv run mcp-agent-mail doctor --live # opt-in IMAP + keyring checks
```
4. Provision the identity entries (see below). `doctor` shows a
`[!!]` line and the server refuses to start until they exist.
### Secret backends
The two secrets (`EMAIL_PASSWORD`, `GPG_PASSPHRASE`) are resolved through
the provider named by `SECRET_BACKEND`. `GPG_KEY_ID` is a public fingerprint
and always comes from `.env`, never from a secret store.
- **`env` (default)** — reads `EMAIL_PASSWORD` / `GPG_PASSPHRASE` from the
process environment / `.env`. Plaintext on disk; development only.
- **`keepassxc`** — reads them from a dedicated KeePassXC database via
`keepassxc-cli`, unlocked non-interactively by a **key file** (no master
password is stored anywhere).
#### Setting up the `keepassxc` backend
1. In KeePassXC, create the database with **no password and a key file**:
*New Database* → uncheck **Password** → check **Key file** → Create.
Keep the `.kdbx` and the `.keyx` **outside this repo** (e.g. a
`<vault-dir>` on any local drive).
2. Add one entry per secret, with the secret in the entry's **password
field** — e.g. entries `gpg-passphrase` and `mail-app-password`.
3. Confirm the CLI reads them with no prompt:
```powershell
keepassxc-cli show -q -a password -k <vault-dir>/mail-agent.keyx `
--no-password <vault-dir>/mail-agent.kdbx gpg-passphrase
```
4. In `.env`, set the backend and mappings, and **blank out**
`EMAIL_PASSWORD` / `GPG_PASSPHRASE` so no plaintext copy lingers:
```ini
SECRET_BACKEND=keepassxc
KEEPASSXC_DB=<vault-dir>/mail-agent.kdbx
KEEPASSXC_KEYFILE=<vault-dir>/mail-agent.keyx
KEEPASSXC_ENTRY_GPG_PASSPHRASE=gpg-passphrase
KEEPASSXC_ENTRY_EMAIL_PASSWORD=mail-app-password
```
`mcp-agent-mail doctor` reports the binary, database, and key-file state and
whether each secret resolves — booleans and paths only, never values.
> **Back up the key file.** With a key-file-only database it is the single
> point of failure: lose it and neither you nor the agent can open that vault.
### Identity & setup
Two contact entries pin the identity boundary of the whole system and are
**immutable from the tool surface**. They are provisioned once, by hand
(or by a future owner-facing CLI) — never created or edited by the tools:
- **Agent entry** — the record whose `email` equals `EMAIL_ADDRESS`. It is
the agent's own identity inside the contact book and carries the agent key
fingerprint (`GPG_KEY_ID`). Conventional surname: `agent of <owner given name>`.
- **Owner entry** — the record whose `email` equals `OWNER_EMAIL`. That is
the human running the server.
`contact_add`, `contact_remove`, `contact_link_key`,
`contact_set_fingerprint` and `contact_clear_key` refuse to touch either
entry (status `protected`). The server fails fast at startup if an identity
email is absent or held by more than one record.
Minimal `data/contacts.json` with both identities provisioned:
```json
{
"Hermes": {
"added": "2026-09-13T00:00:00",
"given_name": "Hermes",
"surname": "agent of John",
"email": "agent@example.com",
"gpg_key_fingerprint": "AAAABBBBCCCCDDDDEEEEFFFF0000111122223333",
"key_source": "keyring_uid_match",
"key_linked_at": "2026-09-13T00:00:00",
"key_cleared_at": "",
"notes": "",
"updated": "2026-09-13T00:00:00"
},
"John Smith": {
"added": "2026-09-13T00:00:00",
"given_name": "John",
"surname": "Smith",
"email": "owner@example.com",
"gpg_key_fingerprint": "4444555566667777888899990000AAAABBBBCCCC",
"key_source": "keyring_uid_match",
"key_linked_at": "2026-09-13T00:00:00",
"key_cleared_at": "",
"notes": "",
"updated": "2026-09-13T00:00:00"
}
}
```
> The `added`/`updated` timestamps are ISO 8601. `key_source` values:
> `"keyring_uid_match"`, `"manual"`, `"cleared"`.
## Usage
### Run as an MCP server (stdio — what harnesses expect)
```powershell
uv run mcp-agent-mail
```
To serve over HTTP instead:
```powershell
uv run mcp-agent-mail serve --http --port 8000
```
### Register in an MCP client
Point the client at the project — the server reads `.env` directly and does
not need the environment pre-seeded:
```json
{
"mcpServers": {
"mcp-agent-mail": {
"command": "uv",
"args": ["--project", "C:/path/to/mcp_agent_mail", "run", "mcp-agent-mail"]
}
}
}
```
### HTTP transport with curl (manual probing)
The HTTP mode speaks the standard MCP *streamable-http* transport. A raw
HTTP call is **not** a single request-response: MCP is session-based. You
initialize, then send JSON-RPC messages that carry an `Mcp-Session-Id`
header, and results come back as **Server-Sent Events**
(`text/event-stream`), not plain JSON.
Start the server:
```powershell
uv run mcp-agent-mail serve --http --port 8000
```
**1. Initialize the session** — the `mcp-session-id` header in the
response is the session token for every following call:
```bash
curl -s -D - -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
```
**2. Signal the client is initialized** (protocol requirement; produces
no response body):
```bash
curl -s -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <session-id>" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
```
**3. Call a tool** — list the three most recent inbox messages:
```bash
curl -s -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <session-id>" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"email_check_inbox","arguments":{"limit":3}}}'
```
The result arrives as an SSE frame; the useful payload is the `"text"`
field inside the JSON (example, sanitized):
```
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\n \"folder\": \"INBOX\",\n \"count\": 1,\n \"messages\": [\n {\n \"uid\": \"100\",\n \"folder\": \"INBOX\",\n \"from\": \"John Smith <john.smith@example.com>\",\n \"to\": \"agent@example.com\",\n \"subject\": \"Hello\",\n \"date\": \"Tue, 01 Jan 2026 10:00:00 +0100\"\n }\n ]\n}","type":"text"}],"isError":false}}
```
Reading runs the same PGP pipeline as stdio: inbound ciphertext is
decrypted, sender key blocks are intercepted and linked in the contact
book, and key material never reaches you as raw bytes:
```bash
curl -s -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: <session-id>" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"email_read","arguments":{"uid":"100"}}}'
```
> stdio is what MCP harnesses expect and what the server runs by default.
> HTTP serves manual probing, remote access, and web-based clients — it
> changes the transport only, never which tools are exposed.
### Tighten your harness's tool-use prompt
Small local models occasionally misread an otherwise-unambiguous tool contract
on first use — e.g. batching two local MCP calls into one invocation array and
getting the batch rejected. Don't fight this in the server; fix it in the
harness. Add a one-line tool-use rule to the harness's always-injected context
(`SOUL.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules` — whichever the harness
loads from *user-owned* data, so updates don't clobber it):
> One local tool invocation is exactly one entry per `tool_call`; only
> `connectors__`-type names may be batched together. Mixed or multi-local
> batches are rejected.
The rule costs tokens once per session prefix, not per call, and removes the
whole class of first-tool-call failures.
### CLI commands
| Command | Purpose |
|---|---|
| `serve` *(default)* | Run the MCP server. `--http --port` for streamable-http |
| `setup` | Check dependencies, key presence, secret status, identity setup |
| `doctor` | Offline diagnostics; `--live` runs IMAP/keyring checks |
| `keys` | List keyring keys (`--secret` for private keys) |
| `archive` | Inspect the JSONL archive (`--search <term>`) |
## Development
```powershell
uv run ruff check .
uv run pytest -q
```
187 tests cover: secret handling (env + KeePassXC backends), fail-fast config,
identity immutability,
fingerprint/key-block validation, contact provenance, archive dedup/search,
the outbound encryption gate, and the full tool surface. No `.env` or real
account is needed — tests seed fake secrets and mock the transports.
## Configuration reference
| Var | Default | Purpose |
|---|---|---|
| `EMAIL_ADDRESS` | — *(required)* | Agent account identity / IMAP+SMTP login |
| `OWNER_EMAIL` | — *(required)* | Owner (human) identity entry holder |
| `EMAIL_PASSWORD` | — *(env backend)* | IMAP/SMTP app password |
| `GPG_KEY_ID` | — *(required)* | Agent key fingerprint (40 hex) |
| `GPG_PASSPHRASE` | — *(env backend)* | Agent key passphrase |
| `SECRET_BACKEND` | `env` | Secret backend: `env` or `keepassxc` |
| `KEEPASSXC_DB` | — *(keepassxc)* | Absolute path to the `.kdbx` |
| `KEEPASSXC_KEYFILE` | — *(keepassxc)* | Absolute path to the key file |
| `KEEPASSXC_ENTRY_GPG_PASSPHRASE` | — *(keepassxc)* | Vault entry for the GPG passphrase |
| `KEEPASSXC_ENTRY_EMAIL_PASSWORD` | — *(keepassxc)* | Vault entry for the app password |
| `KEEPASSXC_CLI` | `keepassxc-cli` on PATH | Binary override |
| `KEEPASSXC_ATTR_<SECRET>` | `password` | Entry attribute to read |
| `IMAP_HOST` / `IMAP_PORT` | `imap.gmail.com` / `993` | IMAP (SSL) |
| `SMTP_HOST` / `SMTP_PORT` | `smtp.gmail.com` / `587` | SMTP (STARTTLS) |
| `SMTP_USE_SSL` | `false` | Set `true` for implicit TLS on 465 |
| `EMAIL_DISPLAY_NAME` | `AI Agent` | From display name |
| `CONTACTS_PATH` | `data/contacts.json` | Relative to project root |
| `ARCHIVE_PATH` | `data/archive.emails.jsonl` | JSONL mail archive |
| `LOGS_DIR` | `logs` | Rotating DEBUG logs (5×5 MB) |
| `PUBKEY_EXPORT_DIR` | `exported_keys` | Where public keys are exported |
| `GPG_HOME` | *(blank = system default)* | Must be blank or absolute |
## Documentation
- `docs/SECURITY.md` — threat model, guarantees, trade-offs
- `docs/ARCHITECTURE.md` — module map and data flow
## License
MIT © 2026 Christof Milius
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues