AI agents & smarter search
Expose your archive's read surface to an
AI assistant over the Model Context Protocol (MCP), so a client like
Claude Desktop or Claude Code can search and read your mail on your
behalf — and let a local LLM sharpen your search queries with
--smart.
The MCP server
localmail can mount an MCP
server at /mcp inside the same localmail serve
process. An MCP-capable assistant connects to it and gets a handful of tools
for searching and reading the archive. It is read-only —
the same promise as the rest of localmail: an agent can search, browse, and
read, but can never send, delete, or modify mail, and can't pull raw
attachment bytes.
MCP reuses the API-user and per-account ACL model. An agent is just another user: it logs in with a username + password, and only ever sees the accounts you've granted it. Give an assistant its own user so you can revoke it independently.
Set it up
-
Install the optional extra.
The MCP server is behind an opt-in dependency:
uv sync --extra mcpWithout it,
servestill runs — it just logs a line noting the MCP mount was skipped. -
Enable it in
config.toml.[mcp] enabled = trueBoth gates must be on: the extra installed and
enabled = true. -
Create an agent user and grant accounts.
localmail add-api-user agent localmail grant-account agent horst-gmail localmail grant-account agent work-fastmailA new user sees no mail until you grant accounts.
-
Get a bearer token.
The agent authenticates with an opaque bearer token obtained by logging in. Against a running server:
curl -sk https://localhost:8443/v1/auth/login \ -H 'content-type: application/json' \ -d '{"username": "agent", "password": "…"}' # → {"token": "…", "expires_at": "…"}There's no separate refresh credential —
POST /v1/auth/refreshrotates the current bearer. -
Point your MCP client at
/mcp.URL: https://<host>:<port>/mcp Header: Authorization: Bearer <token>For a self-signed cert on localhost / LAN, configure the client to trust it (or front it with a reverse proxy holding a real cert).
The steps above hand the client a bearer token you obtained yourself —
the simplest path, and all most clients need. For spec-strict MCP clients
that expect to self-onboard, localmail can also act as an OAuth 2.1
authorization server: the client sends the user through a
browser login + consent screen and receives its own token, no
hand-pasting. It's opt-in — set
[mcp].authorization_server_enabled = true (which also
requires [serve].state_signing_key). Either way the token is
still scoped to the user's per-account grants, and access stays
read-only.
What the agent can do
Five read-only, ACL-scoped tools:
| Tool | What it does |
|---|---|
search | Hybrid lexical + vector search over
the archive — the default "find mail about X". Supports the same
filters as the CLI, pagination, and smart=true. |
list_messages | Date-ordered browse, newest first, when there's no query — "show me recent mail". |
get_message | Fetch one message's headers, body, and attachment list by ID. |
get_attachment | Read an attachment's extracted text or its metadata — never raw bytes. |
list_accounts | Enumerate the accounts this agent is allowed to read. |
An agent can read an attachment's extracted text or metadata, but not
its binary content (stored HTML/SVG blobs are an XSS sink). To download
original bytes, use the authenticated HTTP route
GET /v1/attachments/{sha256} instead.
For the full tool reference, paging semantics, and discovery details, see docs/mcp-usage.md in the repository.
Smarter queries with --smart
Search has an optional layer that runs your query through a local LLM (via Ollama) before retrieval. The model rewrites the free text for better recall, expands it with related terms, and pulls natural-language constraints out into proper filters — turning "that invoice grandma sent me last spring" into a cleaner query with a date range and a sender hint.
It's opt-in and runs entirely on your own machine — nothing leaves your network. From the CLI:
localmail search "invoice from grandma last spring" --smart
Agents get the same behaviour by passing smart=true to the
search tool. Either way, the response reports what happened via
rewrite_status:
| Status | Meaning |
|---|---|
applied | The rewrite ran and shaped the query. |
unavailable | Smart was requested but no rewriter is configured (e.g. the LLM isn't running). The plain query ran instead. |
failed | The rewrite was attempted but errored;
the plain query ran. A rewrite_note carries an actionable
detail (e.g. which model to pull). |
not_requested / not_attempted |
Smart was off, or this was a continuation page (rewrite only happens on page 1). |
If the local LLM isn't available, --smart never fails your
search — it quietly falls back to the ordinary query and tells you it did.
You need a running Ollama with the configured model pulled for the rewrite
to actually apply.