Skip to main content
Glama
ChristofMilius

mcp-agent-mail

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.

Related MCP server: phison-mail-mcp

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

  • 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 — for the keepassxc secret backend (production)

Install

git clone <repo-url> mcp_agent_mail
cd mcp_agent_mail
uv sync

Setup

  1. Generate a dedicated agent PGP key pair:

    gpg --full-generate-key
  2. Create .env from the template and fill it (see .env.example for passphrase quoting pitfalls):

    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:

    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:

    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:

    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:

{
  "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)

uv run mcp-agent-mail

To serve over HTTP instead:

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:

{
  "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:

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:

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):

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:

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:

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

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to interact with email accounts via IMAP and SMTP, supporting mailbox listing, email search, retrieval, sending, and management.
    MIT
  • F
    license
    B
    quality
    C
    maintenance
    Enables AI models to send, receive, search, and manage emails via SMTP/IMAP, including support for attachments, contacts, and advanced search.
    18
    -
  • A
    license
    A
    quality
    B
    maintenance
    Lets AI agents send email over SMTP and read, search, and manage email over IMAP or POP3 using app passwords or authorization codes, with attachment handling and support for common providers.
    7
    17 npm
    MIT