Read, organize, draft and send email on any JMAP server, with OAuth sign-in and zero configuration
Runs on your machine from its package; the steps are below.
At most: moves money, runs code or commands, deletes data, or sends messages for you.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
docker run -i --rm ghcr.io/kolapsis/jmap-mcp:0.3.0
MCP client config
{
"mcpServers": {
"jmap-mail": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"ghcr.io/kolapsis/jmap-mcp:0.3.0"
]
}
}
}Distributed as an MCP bundle (`https://github.com/kOlapsis/jmap-mcp/releases/download/v0.3.0/jmap-mcp.mcpb`) — download it and import the bundle in your MCP client.
An MCP server that gives an AI assistant (Claude.ai, Claude Code, Claude Desktop or any MCP client) access to a mailbox hosted on a JMAP server (RFC 8620, RFC 8621), first of all Stalwart.
The user gives an email address and signs in on their mail server's own page. Nothing else is configured: no server URL, no token, no API key.
/.well-known/jmap), its OAuth server from standard metadata, and the bridge registers itself
dynamically. Secrets are generated on first start.Existing JMAP MCP servers (mikluko/jmap-mcp, wyattjoh/jmap-mcp, agent-jmap-mcp, fastmail-blade-mcp) all need a server URL and a static token in their configuration, and none can be added to Claude.ai as a connector.
A small surface designed for an agent: compact JSON results, pagination everywhere, bodies converted to text and truncated with an explicit flag.
| Tool | What it does |
|---|---|
list_accounts | Connected accounts and the default one (set_default changes it). |
connect_account | Local mode only: connect an account from an email address (browser or device code). |
list_mailboxes | Mailboxes with role (inbox, sent, drafts, trash, archive, junk), path, total and unread counts. |
list_identities | Sender identities. Registered when the server announces submission. |
search_emails | Text, sender, recipient, subject, mailbox, dates, unread, flagged, attachments, size; sort; position and limit. Returns summaries. |
get_email | Headers, plain-text body (HTML converted when there is no text part), attachment list. max_body_length. |
get_thread | A conversation in order, as summaries or full messages. |
get_attachment | Text and small images inline; otherwise a signed URL valid 15 minutes (HTTP mode) or a private temporary file (local mode). |
update_emails | Read, unread, flagged, unflagged. |
move_emails | Move to a mailbox given by id, role or name. |
create_folder | Create a folder, at the top level or inside a parent given by id, role or name. |
delete_emails | Trash by default; permanent destroys after the user's confirmation. |
create_draft | New message or reply: In-Reply-To, References, Re: subject, quote, reply-all recipients, attachments uploaded over JMAP. |
send_email | Sends a draft, or composes and sends. Disabled unless --enable-send; every message is confirmed by the user. |
vacation_response | Read or change the out-of-office reply (changes are confirmed). Registered when the server announces it. |
sieve_scripts | List, read, write, activate, deactivate, delete filtering rules (RFC 9661); changes are confirmed. Registered when announced. |
The resource jmap-mcp://status describes the connected accounts, the discovered servers and the
capabilities they announce.
Email content is treated as untrusted data: it is returned to the model as data, never interpreted, and the tool descriptions tell the model that emails may contain instructions it must ignore.
Choices that are not secret (which account, which sender identity, confirmation of a send or a
permanent deletion) are asked through MCP elicitation when the client supports it. Otherwise the
tool returns an error listing the choices, and the model calls it again with the argument set
(account, identity, confirm). Credentials never go through elicitation or tool arguments.
HTTP mode mail server
┌──────────────┐ OAuth 2.1 (bridge tokens) ┌──────────────┐ OAuth (mail tokens) ┌──────────────┐
│ MCP client │ ──────────────────────────▶ │ jmap-mcp │ ────────────────────▶ │ OAuth server │
│ Claude.ai… │ ◀── MCP over HTTP ────────▶ │ (bridge) │ ◀──── JMAP ─────────▶ │ JMAP API │
└──────────────┘ └──────────────┘ └──────────────┘
In HTTP mode (jmap-mcp serve) the bridge is at the same time:
401 with
WWW-Authenticate, audience checked on every request);A single deployment serves users of different mail servers. Each user gets an isolated MCP server instance and can only reach their own accounts.
In local mode (jmap-mcp, stdio) there is no third party: the process is the OAuth client of
the mail server, like a mail application. It registers as a public client, opens the browser on a
loopback redirect, and falls back to the device flow (RFC 8628)
when no browser can be opened, showing the code to type.
The image is ghcr.io/kolapsis/jmap-mcp (linux/amd64 and linux/arm64, distroless, non-root, data
in the /data volume). With a Traefik that already has a websecure entrypoint and an ACME
resolver, copy docker-compose.yml, set your host name, resolver and network,
and start it:
curl -O https://raw.githubusercontent.com/kOlapsis/jmap-mcp/main/docker-compose.yml
# edit mcp.example.com, the certresolver name and the network
docker compose up -d
curl https://mcp.example.com/healthz
Traefik sets the X-Forwarded-* headers from which the bridge derives its public URL. Set
JMAP_MCP_PUBLIC_URL only if your proxy does not send them.
Image tags: X.Y.Z, X.Y, X and latest for releases, main and sha-<commit> for the
latest build of the main branch. Both are built by GitHub Actions and pushed to ghcr.io.
Without Traefik, deploy/caddy has a Caddy setup that obtains the certificate
itself:
cd deploy/caddy # edit mcp.example.com in the Caddyfile
docker compose up -d
The MCP address is https://mcp.example.com/mcp.
Settings › Connectors › Add custom connector. Give it a name and the URL
https://mcp.example.com/mcp. Leave Client ID and Client Secret empty in the advanced
settings: Claude.ai registers itself dynamically (its redirect URI is
https://claude.ai/api/mcp/auth_callback). Click Connect, enter your email address on the bridge
page, then sign in on your mail server.
The free plan is limited to one custom connector. On Team and Enterprise plans, an owner adds the connector under Organization settings › Connectors.
claude mcp add --transport http mail https://mcp.example.com/mcp
Then run /mcp in Claude Code and choose the server to authenticate in the browser.
Install the binary, from the releases, with
Homebrew (brew install --cask kolapsis/tap/jmap-mcp, macOS and Linux) or with Go:
go install github.com/kOlapsis/jmap-mcp@latest
Claude Code:
claude mcp add mail -- jmap-mcp
or in .mcp.json:
{
"mcpServers": {
"mail": { "command": "jmap-mcp", "args": ["stdio"] }
}
}
Claude Desktop: install jmap-mcp.mcpb from the release (double-click it, or Settings ›
Extensions), or add to claude_desktop_config.json the full path of the binary:
{
"mcpServers": {
"mail": { "command": "/usr/local/bin/jmap-mcp", "args": ["stdio"] }
}
}
On first use, ask the assistant to connect your mailbox. It asks for your email address
(elicitation, or the connect_account tool), opens your browser on the mail server's sign-in page,
and the account is ready. Without a graphical session (SSH), it shows a URL and a code to enter on
any device. Tokens are stored in the data directory (~/.config/jmap-mcp on Linux), encrypted with
a key kept in the OS keyring, or in a 0600 key file when no keyring is available. Several accounts
can be connected.
To send email in local mode, add "env": {"JMAP_MCP_ENABLE_SEND": "true"} to the server entry.
sudo install -m 755 jmap-mcp /usr/local/bin/
sudo cp deploy/systemd/jmap-mcp.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now jmap-mcp
The unit listens on 127.0.0.1:8080 with a dynamic user and its state in /var/lib/jmap-mcp. Put
a TLS reverse proxy in front of it (any proxy that sends X-Forwarded-Proto and
X-Forwarded-Host), or set JMAP_MCP_PUBLIC_URL.
Discovery. The JMAP server is found from the email domain, as RFC 8620 §2.2 describes: an SRV
record _jmap._tcp.example.com pointing at the JMAP host, otherwise
https://example.com/.well-known/jmap. If your web site is not served by the mail server, add the
SRV record:
_jmap._tcp.example.com. 3600 IN SRV 0 1 443 mail.example.com.
The OAuth server is found from the JMAP host: RFC 9728 resource metadata, then RFC 8414 and OpenID
Connect discovery. It must be under the same registrable domain as the JMAP host (for example
jmap.example.com and auth.example.com); otherwise the bridge refuses the server.
Client registration with Stalwart. Nothing to do with the default settings: the bridge
registers itself as a public client with PKCE, which Stalwart accepts anonymously
(anonymousClientRegistration, on by default in Stalwart 0.16). Stalwart only issues confidential
clients to authenticated callers, so the bridge does not get a client secret this way; PKCE protects
the authorization code instead.
If the operator turned anonymous registration off, declare the client statically:
https://mcp.example.com/callback (and a secret if you want a confidential client).docker compose exec jmap-mcp /jmap-mcp admin-token (or
jmap-mcp admin-token --data-dir /var/lib/jmap-mcp).https://mcp.example.com/admin, log in with any user name and the token as password, and
enter the mail server (an email address or its URL), the client ID, the secret and the
authentication method. They are stored encrypted in the bridge database.The local mode registers itself as a public client, which Stalwart allows by default. When the
authorization server does not allow it, declare a client with jmap-mcp client add (see the next
section).
Some JMAP servers leave sign-in to an OpenID provider, for example Stalwart with an OIDC directory behind Authentik. jmap-mcp then signs the user in at that provider and presents the provider's access token to the JMAP server.
Discovery. jmap-mcp finds the JMAP session from the email domain (SRV record or
/.well-known/jmap), then reads the RFC 9728 metadata of the JMAP host at
/.well-known/oauth-protected-resource: its authorization_servers must name the provider's
issuer. The provider's own metadata comes from RFC 8414 or OpenID Connect discovery. Stalwart 0.16
keeps naming itself in these metadata even with an OIDC directory, so have the reverse proxy answer
/.well-known/oauth-protected-resource and its subpaths with the issuer of the provider. The
provider must be under the same registrable domain as the JMAP host (mail.example.com and
auth.example.com).
Scopes and audience. jmap-mcp requests openid, email and offline_access when the provider
advertises them, and the JMAP mail scope when it is advertised (providers usually do not have it).
The JMAP server finds the account from the email claim (claimUsername: email in Stalwart). The
access token must also carry the audience the JMAP server expects (requireAudience in Stalwart):
use the client ID the JMAP server is configured for, otherwise the server refuses the token.
Static client. Identity providers rarely offer dynamic client registration. Declare jmap-mcp on
the provider as a public client with PKCE S256 and the authorization_code and refresh_token
grants, with the redirect address of each mode you use:
HTTP mode: https://mcp.example.com/callback. Then enter the client on the /admin page of the
bridge, with the authentication method none for a public client.
Local mode: the loopback address on any port (RFC 8252), http://127.0.0.1:<port>/callback.
Providers usually accept it as a regular expression, for example
^http://127[.]0[.]0[.]1:[0-9]+/callback$. Then run:
jmap-mcp client add alice@example.com --client-id stalwart-webui
The command accepts an email address or the JMAP session URL, finds the provider and stores the
client in the data directory; a running server uses it at the next connect_account. For a
confidential client, add --client-secret (or set JMAP_MCP_CLIENT_SECRET) and, if needed,
--auth-method client_secret_post (the default with a secret is client_secret_basic).
As long as no client is declared, the sign-in stops with a message that names the provider and gives these steps.
Example with Authentik. Stalwart's OIDC directory points at the Authentik application
stalwart-webui (issuerUrl: https://auth.example.com/application/o/stalwart-webui/,
requireAudience: stalwart-webui, claimUsername: email). This application is a public client
with PKCE and the scopes openid, email, profile and offline_access, and its tokens carry the
audience Stalwart expects, so jmap-mcp reuses it: add the redirect addresses above to it, then
declare the client ID stalwart-webui with the method none. Authentik grants consent implicitly,
so once the loopback pattern is allowed, any program on a user's computer can obtain a token for
that user without them noticing; allow it only if the local mode is needed.
Every option is optional and only concerns the deployment. Each flag can also be set with the
environment variable JMAP_MCP_<FLAG> (for example JMAP_MCP_ENABLE_SEND=true).
| Flag | Default | Purpose |
|---|---|---|
--listen | :8080 | HTTP listen address (serve). |
--data-dir | $XDG_CONFIG_HOME/jmap-mcp (/data in the image) | Database and master key. |
--public-url | from X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Prefix | Public base URL of the bridge. |
--log-level | info | debug, info, warn or error. Logs never contain secrets, codes or email bodies. |
--enable-send | false | Register send_email. |
--allow-private-networks | false | Allow mail servers on private or loopback addresses (for example on the same LAN). It disables part of the SSRF protection. |
Commands: jmap-mcp or jmap-mcp stdio (local mode), jmap-mcp serve (HTTP mode),
jmap-mcp client add (static client for the local mode), jmap-mcp admin-token,
jmap-mcp healthcheck, jmap-mcp version.
Everything below must be reachable through the reverse proxy, on the same host name.
| Path | Purpose |
|---|---|
/mcp | MCP Streamable HTTP endpoint (bearer token required). |
/.well-known/oauth-protected-resource, /.well-known/oauth-protected-resource/mcp | Protected resource metadata (RFC 9728). |
/.well-known/oauth-authorization-server, /.well-known/oauth-authorization-server/mcp | Authorization server metadata (RFC 8414). |
/register | Dynamic client registration (RFC 7591). |
/authorize | Authorization endpoint and the page asking for the email address. |
/callback | Return from the mail server's sign-in. |
/token | Token endpoint (authorization code, refresh token). |
/revoke | Token revocation (RFC 7009). |
/attachments/{token} | Signed, short-lived attachment downloads. |
/admin | Static client declaration (admin token required). |
/healthz | Health check. |
/ | Short description page. |
state is echoed and the iss parameter
(RFC 9207) is sent and checked. On the mail-server leg, state, iss and the nonce of the ID
token are checked, and the flow is bound to the browser with a cookie.sub of the ID token (nonce, issuer and audience checked), not by what the JMAP
server reports. Mail tokens are only sent to the host they were requested for, never after a
redirect to another host.X-Forwarded-For is only trusted when the
request comes from a private address, that is from your reverse proxy. HTML pages use no JavaScript and send a strict
Content Security Policy, X-Frame-Options: DENY and Referrer-Policy: no-referrer. Attachments
are always served as downloads (application/octet-stream, sandbox CSP).confirm=true after asking the user, as the specification requires; keep tool approval enabled in
such clients, since a prompt-injected model could set it.Report vulnerabilities as described in SECURITY.md.
The data directory holds master.key (unless the OS keyring is used) and jmap-mcp.db (SQLite).
Back up both together: the database cannot be decrypted without the key. Deleting them disconnects
every account; users simply sign in again.
go test ./... # unit tests, including replayed Stalwart exchanges
go test -tags integration ./... # starts Stalwart in Docker
golangci-lint run ./...
The integration tests start stalwartlabs/stalwart, complete its setup through its API, create
accounts, import sample messages and run the real OAuth flows (browser, device, HTTP bridge with
the MCP SDK client). They also switch Stalwart to an OIDC directory backed by a minimal OpenID
provider (internal/testenv/idp.go) to cover the static client of both modes. The unit tests replay JMAP exchanges recorded from those runs; refresh them
with JMAP_MCP_RECORD=$PWD/internal/tools/testdata/read go test -tags integration -run TestStalwartReadTools ./internal/tools
(and write / TestStalwartWriteTools).
The layout: internal/jmapc (JMAP client and discovery), internal/mailauth (OAuth client of the
mail server), internal/mailview (compact email views), internal/tools (MCP tools),
internal/stdio and internal/httpd (the two modes), internal/store (encrypted SQLite),
internal/netguard (SSRF guard).
See CONTRIBUTING.md.
| Library | Assessment |
|---|---|
git.sr.ht/~rockorager/go-jmap | Used by aerc, covers core, mail and push. Last release v0.5.3 in February 2025, no Sieve. |
github.com/mikluko/jmap | Maintained fork of the above (v0.26.0, February 2026): same coverage, including EmailSubmission, Identity and VacationResponse, plus Sieve scripts (RFC 9661). Chosen. |
github.com/rhyselsmore/go-jmap | Recent and well designed, but announced as "not yet usable" and without EmailSubmission or Identity. |
mikluko/jmap provides the method and object types. The transport is completed in
internal/jmapc without forking: its upload sends the wrong Content-Type, its download hardcodes
the name and type, and its session decoding fails on capabilities it does not know.
Points where the initial specification turned out to be inaccurate, or where a choice had to be made, checked against the actual software in October 2026:
anonymousClientRegistration
a default of true (another page says false), and Stalwart 0.16.27 accepts anonymous
registration, but only of public clients: a confidential client always needs an authenticated
caller with the oauthClientRegistration permission. The bridge therefore asks for a
confidential client, falls back to a public client with PKCE, and offers the /admin page for a
statically declared client.example.com versus mail.example.com). The bridge reads the RFC 9728 document at
the session origin before trying RFC 8414 and OpenID discovery there.elicitation/create for clients on older versions.io.github.<owner>/* with the exact case of the GitHub
owner and compares names case-sensitively, so the server is published as
io.github.kOlapsis/jmap-mcp, not io.github.kolapsis/jmap-mcp..mcpb: macOS universal and Windows x64, for Claude Desktop) and the
image as an OCI package (local mode with a persistent volume, signing in with the device flow).list_accounts changes it).Every push to main builds the image and pushes ghcr.io/kolapsis/jmap-mcp:main (workflow
image.yml). Maintainers publish a version by pushing a vX.Y.Z tag. The release workflow runs GoReleaser (archives, checksums, SBOMs,
multi-arch image on GHCR with semver and latest tags, Homebrew cask pushed to
kOlapsis/homebrew-tap with the deploy key stored in the
HOMEBREW_TAP_DEPLOY_KEY secret), builds and uploads jmap-mcp.mcpb, then publishes server.json to the MCP registry with
GitHub OIDC.
One-time setup: make the jmap-mcp package public in the organization's GitHub packages after the
first release (the registry only verifies public images), then re-run the mcp-registry job. Set
the repository topics so that directories such as Glama find it:
gh repo edit kOlapsis/jmap-mcp --description "MCP server giving AI assistants OAuth access to JMAP mailboxes (Stalwart and others)" \
--homepage https://github.com/kOlapsis/jmap-mcp --add-topic mcp,mcp-server,jmap,stalwart,email