- Rust 99.7%
- Dockerfile 0.3%
|
All checks were successful
CI / tag-ancestry (pull_request) Successful in 4s
CI / cargo (pull_request) Successful in 3m1s
CI / docker (pull_request) Successful in 1m18s
CI / tag-ancestry (push) Successful in 4s
CI / docker (push) Successful in 19s
CI / cargo (push) Successful in 3m13s
|
||
|---|---|---|
| .forgejo/workflows | ||
| .github/workflows | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CODE_OF_CONDUCT.md | ||
| compose.yaml | ||
| CONTRIBUTING.md | ||
| deny.toml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
| rust-toolchain.toml | ||
| rustfmt.toml | ||
| SECURITY.md | ||
jmap-mcp
A remote Model Context Protocol server that lets Claude and other streamable-HTTP MCP clients read, search, compose, and organise mail in a Stalwart JMAP mailbox using the user's existing Logto identity.
jmap-mcp validates each inbound OAuth access token against Logto's JWKS and forwards the same token to Stalwart. It stores no mailbox passwords and has no per-user database or persistent volume.
Important
The current authentication bridge is specifically tested with Logto and Stalwart. Other JMAP or OIDC implementations may work only when their issuer, token, audience, claim, and dynamic-registration behaviour matches this deployment model.
Features
The server exposes 48 tools with MCP read-only, destructive, and idempotency annotations:
- Identity:
whoami,get_identities,create_identity - Read: mailbox listing and metadata, recent mail and activity, search, message and thread reading, unread summaries, headers, and attachments
- Enumerate:
query_email_ids, bounded id-only paging over one mailbox (below) - State: read/unread, flags, keywords, move, and copy
- Compose: send, reply, forward, save/update drafts, and attachments
- Delete: trash, permanent deletion, empty trash, and empty spam
- Mailbox management: create, rename, delete, subscribe, and unsubscribe
- Profile: profile, account information, vacation response, and session verification
- Self-audit: append envelope-only audit notes to a designated mailbox
Sender-controlled bodies, subjects, snippets, addresses, structured headers,
and attachment filenames are treated as untrusted. Returned bodies are wrapped
in <email:message trust="external"> delimiters, prompt-injection markers are
escaped, and suspicious content is flagged.
Enumerating a mailbox with query_email_ids
query_email_ids walks one mailbox and returns only JMAP Email ids plus
the metadata needed to ask for the next page. It issues a single Email/query
and never Email/get, so no body, subject, address or header passes through
it, and it writes nothing (readOnlyHint: true, destructiveHint: false).
Use it to size or enumerate a mailbox; use list_recent_emails or read_email
when you want content.
Arguments
| argument | required | meaning |
|---|---|---|
mailbox_id |
yes | the mailbox to enumerate, as an opaque JMAP Mailbox id |
received_after |
no | UTCDate; matches mail received at or after this instant |
received_before |
no | UTCDate; matches mail received strictly before this instant |
position |
no | zero-based index of the first id (default 0) |
limit |
no | page size, 1–100 (default 50) |
expected_query_state |
no | drift check; requires expected_query_fingerprint |
expected_query_fingerprint |
no | drift check; requires expected_query_state |
The schema is closed: any other argument is refused rather than ignored. There
is no raw JMAP filter or sort passthrough — the sort is always receivedAt
ascending, because position-based paging is only coherent while every page asks
the same question.
Dates are RFC 8620 §1.4 UTCDate: 2026-01-31T09:30:00Z, uppercase T,
literal Z, optional fractional second of 1–9 digits. An all-zero fraction
(.000Z), a numeric offset, a naive local time and a leap second are each
refused with a reason. The value you send is forwarded to the backend verbatim.
Result
| field | meaning |
|---|---|
ids |
this page's ids, in the server's order |
position |
the index the page actually starts at |
total |
total matching mail, or null if the backend declined to count |
has_more |
whether another page may follow |
next_position |
the position for the next call, or null when terminal |
query_state |
the backend's queryState for this query |
query_fingerprint |
identifies the account, filter and sort this state belongs to |
Continue from next_position, never from position + limit. A JMAP server
may enforce a smaller limit than you asked for (RFC 8620 §5.5), and the
difference between those two numbers is a silently skipped id.
When total is null, a short page is not the end: keep going until a page
comes back empty. With a total, the walk ends on the last full page without
spending an extra round-trip.
The drift checkpoint
If both checkpoint fields are omitted, pagination is best-effort: it cannot certify complete, exactly-once coverage while the mailbox changes. For checked continuation, repeat the same mailbox and date arguments on every page and carry both returned checkpoint fields forward; page size may change.
A mailbox can change under an enumeration. Pass the previous page's
query_state and query_fingerprint back on the next call and the tool
refuses to continue if the ordered set has moved, rather than splicing pages
from two different orderings into a result that looks complete:
// page 2, checked against page 1
{ "mailbox_id": "mb-1", "position": 50, "limit": 50,
"expected_query_state": "<page 1's query_state>",
"expected_query_fingerprint": "<page 1's query_fingerprint>" }
The two are inseparable, and sending one without the other is refused. A
queryState is only meaningful against the same account, filter and sort
(RFC 8620 §5.5), so a state compared without the fingerprint that identifies
which query it came from can match by coincidence and report a coverage
guarantee it never made. The fingerprint is a sha256 of the account id, the
canonical filter and the fixed sort; it carries no credential or session
material and is safe to store and log.
Both failures answer with JSON-RPC code -32026 and no ids at all:
query_fingerprint_mismatch— the checkpoint belongs to a different account, mailbox, date window or sort. Refused before the query is issued.query_state_changed— the same query, but the mailbox's ordering moved. The message names both states.
Either way the walk must restart from position: 0; nothing partial is
returned, and the tool never claims exact coverage across a drift.
A malformed backend response — a missing queryState, position or ids, a
page longer than the limit in force, duplicate or empty ids, a total behind
the window, or an empty page while total says results remain — is refused
with upstream_response_invalid rather than answered with a page. Each of
those shapes has a lenient reading that looks exactly like the end of a
mailbox, which is the one thing an enumerator must never be told by mistake.
When an empty page is allowed to mean "finished"
ids: [] is the only thing that ends an enumeration, so the tool is strict
about which empty pages it will accept as one. An empty page ends the walk when
it answers the position that was requested, and in exactly one other case:
the request was at or past a known total and the server clamped to the end of
the result set. Everything else is refused:
- an empty page at a position nobody asked for, with no
total— there is nothing that could make the shift consistent; - an empty page at a shifted position while
totalsays results exist at the position that was requested; - an empty page under a server-reported enforced
limitof0, which is empty because of the cap rather than because of the mailbox.
A reported enforced limit above the requested one is fine: RFC 8620 §5.5 has the server report the maximum it enforces, and the requested limit still bounds the page.
Every page of a walk asks an identical Email/query apart from position:
the same accountId and filter, sort fixed to receivedAt ascending,
collapseThreads: false (sent explicitly — true would return one Email per
Thread, a different ordered set), and calculateTotal: true.
Requirements
- A public HTTPS hostname for jmap-mcp
- A Logto tenant with a public application and API resource
- A Stalwart server with JMAP enabled and an OIDC directory that trusts Logto
- An MCP client supporting streamable HTTP and OAuth 2.1
Quick start
Copy the environment template, fill in your deployment-specific values, and start the published Linux/amd64 image:
cp .env.example .env
docker compose up -d
curl http://127.0.0.1:3000/health
The image is published to:
ghcr.io/jlxq0/jmap-mcp:0.2.14
forge.oddie.app/jlxq0/jmap-mcp:v0.2.14
linux/amd64only. Noarm64variant is published, so on Apple Silicon, AWS Graviton, or any other ARM hostdocker pullfails withno matching manifest for linux/arm64/v8 in the manifest list entries. Add--platform linux/amd64to run it there under emulation:docker pull --platform linux/amd64 ghcr.io/jlxq0/jmap-mcp:0.2.14
For a direct container invocation:
docker run --rm -p 3000:3000 --env-file .env \
ghcr.io/jlxq0/jmap-mcp:0.2.14
Expose port 3000 through HTTPS and configure the MCP client with:
https://jmap-mcp.your-domain.example/mcp
GET /health is public and returns the running package version:
{"status":"healthy","version":"0.2.14"}
Logto and Stalwart setup
The following identifiers must remain aligned:
-
Create a Logto API resource whose indicator is the bare jmap-mcp origin, such as
https://jmap-mcp.your-domain.example. -
Create a public SPA/native Logto application. Put its client ID in
JMAP_MCP_DCR_CLIENT_ID; no client secret is used by the DCR flow. -
Register
https://jmap-mcp.your-domain.example/oauth/callbackas the Logto application's redirect URI. -
Put every MCP client's callback URI in
JMAP_MCP_OAUTH_REDIRECT_URIS. HTTPS and native private-use callbacks match exactly. Loopback HTTP callbacks match the configured host, path, and query while allowing the client to choose an ephemeral local port, as required by RFC 8252. -
Configure Stalwart's OIDC directory to trust the same Logto issuer, accept the jmap-mcp origin as the token audience, and map the token's
username,email, orsubclaim to the mailbox principal. -
Confirm that a Logto access token for the resource can discover a JMAP session directly:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \ https://mail.your-domain.example/.well-known/jmap
The JWT aud value accepted by jmap-mcp and Stalwart must contain the exact
bare origin. JMAP_MCP_RESOURCE_URL accepts either the origin or an origin
ending in /mcp; jmap-mcp canonicalises it to the bare origin and advertises
<origin>/mcp through RFC 9728 metadata.
Configuration
| Variable | Required | Default | Purpose |
|---|---|---|---|
JMAP_MCP_RESOURCE_URL |
yes | — | Public origin and required JWT audience; a trailing /mcp is accepted and removed |
JMAP_MCP_AUTHORIZATION_SERVER |
yes | — | Logto OIDC issuer, normally ending in /oidc |
JMAP_MCP_STALWART_JMAP_BASE_URL |
yes | — | Stalwart base used for /.well-known/jmap discovery |
JMAP_MCP_DCR_CLIENT_ID |
for DCR clients | disabled | Pre-provisioned public Logto client returned by /register |
JMAP_MCP_OAUTH_REDIRECT_URIS |
for OAuth proxy | empty | Comma-separated MCP client callback allowlist; only loopback HTTP ports may vary |
JMAP_MCP_BIND_ADDR |
no | 0.0.0.0:3000 |
Public HTTP listener |
JMAP_MCP_METRICS_BIND_ADDR |
no | 127.0.0.1:9090 |
Internal Prometheus listener; never publish it directly |
POD_IP |
no | — | Derives the metrics listener as <pod-ip>:9090 in Kubernetes |
JMAP_MCP_RATE_LIMIT_READS_PER_MIN |
no | 60 |
Per-identity read quota |
JMAP_MCP_RATE_LIMIT_WRITES_PER_MIN |
no | 30 |
Per-identity write quota |
JMAP_MCP_DOWNLOAD_MAX_BYTES |
no | 5242880 |
Maximum attachment download response |
JMAP_MCP_UPLOAD_MAX_BYTES |
no | 10485760 |
Maximum remote URL attachment fetch |
JMAP_MCP_TRUSTED_PROXY_HOPS |
no | 1 |
Trusted rightmost X-Forwarded-For proxy count; use 0 when directly exposed |
JMAP_MCP_EXTRA_FROM_ADDRESSES |
for mailbox aliases | empty | Extra sendable addresses, comma- or whitespace-separated. Stalwart exposes principal aliases only through x:Account/*, which need sysAccountGet/sysAccountQuery permissions an ordinary user token lacks, so aliases must be named here. Role addresses are refused |
JMAP_MCP_STALWART_CONNECT_IP |
no | DNS | Optional fixed Stalwart socket address for private routing while preserving TLS host validation |
JMAP_MCP_LOGTO_CLIENT_ID |
no | — | Opaque-token introspection client ID; must be paired with the secret |
JMAP_MCP_LOGTO_CLIENT_SECRET |
no | — | Opaque-token introspection client secret |
JMAP_MCP_LOG_FORMAT |
no | compact | Set to json for structured logs |
RUST_LOG |
no | application defaults | Standard tracing filter |
OTEL_EXPORTER_OTLP_ENDPOINT |
no | disabled | Enables OTLP trace export |
OTEL_SERVICE_NAME |
no | jmap-mcp |
OpenTelemetry service name |
The app fails startup when required values are missing or malformed. When DCR is configured, an empty redirect allowlist fails authorization closed.
Security and privacy
jmap-mcp grants an MCP client access to a user's mailbox. Operators and users must trust the MCP client, model provider, Logto, Stalwart, and the jmap-mcp operator.
- Access tokens are held in memory and forwarded to Stalwart; raw tokens are not logged or persisted.
- Audit logs include the account identity, MCP method, mailbox/message resource identifiers, outcome, latency, result count, error class, and the first 16 hexadecimal characters of a SHA-256 token hash. They exclude message bodies, subjects, recipients, attachment contents, and free-form tool arguments.
- Remote attachment fetches require HTTPS, reject private/reserved addresses, pin the validated socket address, bypass environment proxies, and enforce byte limits.
- In-memory caches, pending OAuth state, rate-limit key maps, MCP sessions, and request bodies have hard bounds.
- Release CI runs formatting, Clippy, tests, independent RustSec and OSV dependency audits, cargo-deny, container vulnerability scanning, SBOM generation, provenance attestation, and keyless image signing.
See SECURITY.md for reporting and supported-version policy.
Building and testing
Rust 1.93 is pinned in rust-toolchain.toml.
cargo fmt --all --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-features --locked
cargo audit
cargo deny check bans licenses sources
docker build --platform linux/amd64 -t jmap-mcp:local .
Contributing
See CONTRIBUTING.md and CODE_OF_CONDUCT.md. Release history is maintained in CHANGELOG.md.
License
MIT.