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.

Same boundary as the desktop app

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

  1. Install the optional extra.

    The MCP server is behind an opt-in dependency:

    uv sync --extra mcp

    Without it, serve still runs — it just logs a line noting the MCP mount was skipped.

  2. Enable it in config.toml.

    [mcp]
    enabled = true

    Both gates must be on: the extra installed and enabled = true.

  3. Create an agent user and grant accounts.

    localmail add-api-user agent
    localmail grant-account agent horst-gmail
    localmail grant-account agent work-fastmail

    A new user sees no mail until you grant accounts.

  4. 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/refresh rotates the current bearer.

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

Optional: browser self-onboarding (OAuth 2.1)

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:

ToolWhat it does
searchHybrid lexical + vector search over the archive — the default "find mail about X". Supports the same filters as the CLI, pagination, and smart=true.
list_messagesDate-ordered browse, newest first, when there's no query — "show me recent mail".
get_messageFetch one message's headers, body, and attachment list by ID.
get_attachmentRead an attachment's extracted text or its metadata — never raw bytes.
list_accountsEnumerate the accounts this agent is allowed to read.
Attachments are never raw bytes over MCP

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:

StatusMeaning
appliedThe rewrite ran and shaped the query.
unavailableSmart was requested but no rewriter is configured (e.g. the LLM isn't running). The plain query ran instead.
failedThe 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).
Graceful by design

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.